mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-08 18:30:00 -04:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dcb25b295 | |||
| f43f2b354c | |||
| c2ba0bef6b | |||
| 83b47693c3 | |||
| 1ca86ad973 | |||
| 7afe537c69 | |||
| e566c082f1 | |||
| bc93da4a46 | |||
| 4194d55522 | |||
| f0d1029719 | |||
| b274db88b0 | |||
| 9b7b402737 | |||
| 5e6370363b | |||
| dd6020656e | |||
| 8c758e443b | |||
| 0df6aed6ca | |||
| aa05fd23b3 | |||
| fd09760903 | |||
| 3a4772abb4 | |||
| 2cf65c025a | |||
| f3f1204802 | |||
| b3aea1c39d | |||
| 79a493e7f5 | |||
| 952d4d446f | |||
| 0b84e24e65 | |||
| 3776975d5c | |||
| d2c99ba97c | |||
| 6f3a3600b9 | |||
| 9ca650f97c | |||
| db3b54a30d | |||
| b4f769f695 | |||
| e5ef00b8b8 | |||
| 917d6449e3 | |||
| db31c42e39 | |||
| c79ced174e | |||
| 8ba8af1dd9 | |||
| 6e82f5d3b9 | |||
| 48d1a6e5b9 | |||
| bc47030d4d | |||
| e6d20440f9 | |||
| c9cbd2b1f4 | |||
| 292dfa3036 | |||
| 3bb0d7fda0 | |||
| 8977881e09 | |||
| e6c9b6bef7 | |||
| 2092350cfa | |||
| 5fb0d7c99c | |||
| a1b4843a33 | |||
| cc7827fe08 | |||
| 76e4d88d21 | |||
| 439ed66c7b | |||
| 047d434aa2 | |||
| d7651519f3 | |||
| 1eb3a43add |
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- v2
|
||||
|
||||
jobs:
|
||||
generate:
|
||||
|
||||
@@ -251,4 +251,3 @@ opencode-drive stop --name demo
|
||||
```bash
|
||||
opencode-drive dir --name demo
|
||||
```
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@@ -395,6 +395,7 @@
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"turndown": "7.2.0",
|
||||
@@ -570,6 +571,20 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode-ai/merman",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"string-width": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.18.8",
|
||||
@@ -678,6 +693,7 @@
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"modal": "0.9.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
@@ -880,6 +896,7 @@
|
||||
"dependencies": {
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/merman": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
@@ -1511,6 +1528,18 @@
|
||||
|
||||
"@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-darwin-x64": ["@cbor-extract/cbor-extract-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-linux-arm": ["@cbor-extract/cbor-extract-linux-arm@2.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-linux-arm64": ["@cbor-extract/cbor-extract-linux-arm64@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-linux-x64": ["@cbor-extract/cbor-extract-linux-x64@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-win32-x64": ["@cbor-extract/cbor-extract-win32-x64@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA=="],
|
||||
|
||||
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
|
||||
|
||||
"@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="],
|
||||
@@ -1719,6 +1748,10 @@
|
||||
|
||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||
|
||||
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
|
||||
|
||||
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
|
||||
|
||||
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="],
|
||||
@@ -1813,6 +1846,8 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
|
||||
|
||||
"@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
|
||||
|
||||
"@jsx-email/all": ["@jsx-email/all@2.2.3", "", { "dependencies": { "@jsx-email/body": "1.0.2", "@jsx-email/button": "1.0.4", "@jsx-email/column": "1.0.3", "@jsx-email/container": "1.0.2", "@jsx-email/font": "1.0.3", "@jsx-email/head": "1.0.2", "@jsx-email/heading": "1.0.2", "@jsx-email/hr": "1.0.2", "@jsx-email/html": "1.0.2", "@jsx-email/img": "1.0.2", "@jsx-email/link": "1.0.2", "@jsx-email/markdown": "2.0.4", "@jsx-email/preview": "1.0.2", "@jsx-email/render": "1.1.1", "@jsx-email/row": "1.0.2", "@jsx-email/section": "1.0.2", "@jsx-email/tailwind": "2.4.4", "@jsx-email/text": "1.0.2" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-OBvLe/hVSQc0LlMSTJnkjFoqs3bmxcC4zpy/5pT5agPCSKMvAKQjzmsc2xJ2wO73jSpRV1K/g38GmvdCfrhSoQ=="],
|
||||
@@ -2063,6 +2098,8 @@
|
||||
|
||||
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
|
||||
|
||||
"@opencode-ai/merman": ["@opencode-ai/merman@workspace:packages/merman"],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
|
||||
|
||||
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
|
||||
@@ -3297,6 +3334,8 @@
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
"abort-controller-x": ["abort-controller-x@0.5.0", "", {}, "sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
@@ -3537,6 +3576,10 @@
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
|
||||
|
||||
"cbor-extract": ["cbor-extract@2.2.2", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", "@cbor-extract/cbor-extract-linux-arm": "2.2.2", "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", "@cbor-extract/cbor-extract-linux-x64": "2.2.2", "@cbor-extract/cbor-extract-win32-x64": "2.2.2" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng=="],
|
||||
|
||||
"cbor-x": ["cbor-x@1.6.5", "", { "optionalDependencies": { "cbor-extract": "^2.2.2" } }, "sha512-yO64CxnSh6kp+pHNRK9IfwnMvCB+c8HvmUjQY/9l9YRF0/cAPka/tUHLwS64QqUpFCq3/OtbKziVJYXH2EaRig=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||
@@ -4569,6 +4612,8 @@
|
||||
|
||||
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
||||
|
||||
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
|
||||
|
||||
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
|
||||
|
||||
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||
@@ -4795,6 +4840,8 @@
|
||||
|
||||
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
||||
|
||||
"modal": ["modal@0.9.0", "", { "dependencies": { "cbor-x": "^1.6.0", "long": "^5.3.1", "nice-grpc": "^2.1.12", "protobufjs": "^7.5.0", "smol-toml": "^1.3.3", "uuid": "^11.1.0" } }, "sha512-kCXcdJkhbJorf/q/6T9Wdlg6in9JmRnCNQnV6rVBMyeqNV/iXI6BYk4IzY4cvZ6dbauNeDMjk/Q08cbxvoIaXg=="],
|
||||
|
||||
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
|
||||
|
||||
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
|
||||
@@ -4833,6 +4880,10 @@
|
||||
|
||||
"nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="],
|
||||
|
||||
"nice-grpc": ["nice-grpc@2.1.16", "", { "dependencies": { "@grpc/grpc-js": "^1.14.0", "abort-controller-x": "^0.5.0", "nice-grpc-common": "^2.0.3" } }, "sha512-Cl3Pn00212Hl8/U6bpgMxmhZj5lyv3nWoJov4cd3FjWarktrMHP4DNvSjCnDwkMWYx4W1tyscEia4JX6Y4GVCQ=="],
|
||||
|
||||
"nice-grpc-common": ["nice-grpc-common@2.0.3", "", { "dependencies": { "ts-error": "^1.0.6" } }, "sha512-MEhnD3JMah0mgyivpb9hpRDbOBuXBxI/TVO+OK1h6rC97WM42HsPMR+zzRNQ0C5BqYJTw1nyWiQRD0DucO+pjQ=="],
|
||||
|
||||
"nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="],
|
||||
|
||||
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
|
||||
@@ -5631,6 +5682,8 @@
|
||||
|
||||
"ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="],
|
||||
|
||||
"ts-error": ["ts-error@1.0.6", "", {}, "sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA=="],
|
||||
|
||||
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||
|
||||
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
|
||||
@@ -6589,6 +6642,8 @@
|
||||
|
||||
"builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
|
||||
|
||||
"cbor-extract/node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-test": "build-test.js", "node-gyp-build-optional-packages-optional": "optional.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="],
|
||||
|
||||
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
@@ -6733,6 +6788,8 @@
|
||||
|
||||
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||
|
||||
"modal/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
|
||||
|
||||
"motion/framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
|
||||
|
||||
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
|
||||
|
||||
@@ -368,11 +368,12 @@ Other provider exports listed above remain direct facades until they explicitly
|
||||
|
||||
## Provider options & HTTP overlays
|
||||
|
||||
Three escape hatches in order of stability:
|
||||
Request options in order of stability:
|
||||
|
||||
1. **`generation`** — portable knobs (`maxTokens`, `temperature`, `topP`, `topK`, penalties, seed, stop).
|
||||
2. **`providerOptions: { <provider>: {...} }`** — typed-at-the-facade provider-specific knobs (OpenAI `promptCacheKey`, Anthropic `thinking`, Gemini `thinkingConfig`, OpenRouter routing).
|
||||
3. **`http: { body, headers, query }`** — last-resort serializable overlays merged into the final HTTP request. Reach for this only when a stable typed path doesn't yet exist.
|
||||
2. **`promptCacheKey`** — stable cache affinity lowered by every protocol that supports it.
|
||||
3. **`providerOptions: { <provider>: {...} }`** — typed-at-the-facade provider-specific knobs (OpenAI `store`, Anthropic `thinking`, Gemini `thinkingConfig`, OpenRouter routing).
|
||||
4. **`http: { body, headers, query }`** — last-resort serializable overlays merged into the final HTTP request. Reach for this only when a stable typed path doesn't yet exist.
|
||||
|
||||
Route/provider defaults are overridden by request-level values for each axis.
|
||||
|
||||
|
||||
@@ -33,9 +33,10 @@ const model = OpenAI.configure({
|
||||
//
|
||||
// - `generation`: common controls such as max tokens, temperature, topP/topK,
|
||||
// penalties, seed, and stop sequences.
|
||||
// - `promptCacheKey`: stable cache affinity for protocols that support it.
|
||||
// - `providerOptions`: namespaced provider-native behavior. For example,
|
||||
// OpenAI cache keys and store behavior, Anthropic thinking, Gemini thinking
|
||||
// config, or OpenRouter routing/reasoning.
|
||||
// OpenAI store behavior, Anthropic thinking, Gemini thinking config, or
|
||||
// OpenRouter routing/reasoning.
|
||||
// - `http`: last-resort serializable overlays for final request body, headers,
|
||||
// and query params. Prefer typed `providerOptions` when a field is stable.
|
||||
//
|
||||
@@ -45,9 +46,7 @@ const request = LLM.request({
|
||||
system: "You are concise and practical.",
|
||||
prompt: "Tell me a joke",
|
||||
generation: { maxTokens: 80, temperature: 0.7 },
|
||||
providerOptions: {
|
||||
openai: { promptCacheKey: "tutorial-joke" },
|
||||
},
|
||||
promptCacheKey: "tutorial-joke",
|
||||
})
|
||||
|
||||
// 3. `generate` sends the request and collects the event stream into one
|
||||
|
||||
@@ -133,7 +133,8 @@ const countHints = (request: LLMRequest) =>
|
||||
|
||||
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
|
||||
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
|
||||
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto")) return request
|
||||
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto"))
|
||||
return request
|
||||
const policy = resolve(request.cache)
|
||||
if (!policy.tools && !policy.system && !policy.messages) return request
|
||||
|
||||
|
||||
@@ -25,8 +25,20 @@ import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||
|
||||
const ADAPTER = "gemini"
|
||||
const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES)
|
||||
// Google documents this sentinel for replaying Gemini 3 function calls after their original signature was lost.
|
||||
const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
|
||||
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
|
||||
// Gemini 3 rejects replayed function calls without a thought signature. Google's SDKs avoid that in normal chats by
|
||||
// retaining complete model responses, but OpenCode reconstructs durable history and may encounter an unsigned call
|
||||
// from an older or external session. Model IDs are open-ended, so unknown Gemini aliases inherit current behavior.
|
||||
const requiresThoughtSignatureFallback = (modelID: string) => {
|
||||
if (!/(^|\/)gemini-/i.test(modelID)) return false
|
||||
if (/(^|\/)gemini-(?:1|2)(?:[.-]|$)/i.test(modelID)) return false
|
||||
if (/(^|\/)gemini-pro(?:-vision)?$/i.test(modelID)) return false
|
||||
return !/(^|\/)gemini-robotics-er-1\.5(?:[.-]|$)/i.test(modelID)
|
||||
}
|
||||
|
||||
export interface OptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly cachedContent?: string
|
||||
@@ -145,6 +157,9 @@ const GeminiGenerationConfig = Schema.Struct({
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
topP: Schema.optional(Schema.Number),
|
||||
topK: Schema.optional(Schema.Number),
|
||||
frequencyPenalty: Schema.optional(Schema.Number),
|
||||
presencePenalty: Schema.optional(Schema.Number),
|
||||
seed: Schema.optional(Schema.Number),
|
||||
stopSequences: optionalArray(Schema.String),
|
||||
thinkingConfig: Schema.optional(GeminiThinkingConfig),
|
||||
})
|
||||
@@ -202,11 +217,13 @@ interface ParserState {
|
||||
// keys on non-object scalars. Mirrors OpenCode's historical Gemini rules.
|
||||
//
|
||||
// 2. Project — lossy mapping from JSON Schema to Gemini's schema dialect:
|
||||
// drop empty objects, derive `nullable: true` from `type: [..., "null"]`,
|
||||
// coerce `const` to `[const]` enum, recurse properties/items, propagate
|
||||
// drop empty root parameter schemas while preserving nested empty objects,
|
||||
// expand type arrays into `anyOf`, derive `nullable: true` from null members,
|
||||
// coerce `const` to `[const]` enum, recurse properties/items, and propagate
|
||||
// only an allowlisted set of keys (description, required, format, type,
|
||||
// properties, items, allOf, anyOf, oneOf, minLength). Anything outside the
|
||||
// allowlist (e.g. `additionalProperties`, `$ref`) is silently dropped.
|
||||
// nullable, enum, properties, items, allOf, anyOf, oneOf, minLength).
|
||||
// Anything outside the allowlist (e.g. `additionalProperties`, `$ref`) is
|
||||
// silently dropped.
|
||||
//
|
||||
// Sanitize runs first, then project. The implementation lives in
|
||||
// `utils/gemini-tool-schema` so this protocol keeps the same shape as the other
|
||||
@@ -282,6 +299,8 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
|
||||
// Parallel Gemini 3 calls may carry one signature on the first call; unsigned sibling calls are valid.
|
||||
let hasSignedToolCall = false
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
|
||||
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
|
||||
@@ -294,7 +313,17 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
parts.push(lowerToolCall(part))
|
||||
const lowered = lowerToolCall(part)
|
||||
const signature = lowered.thoughtSignature
|
||||
parts.push({
|
||||
...lowered,
|
||||
thoughtSignature:
|
||||
signature ??
|
||||
(requiresThoughtSignatureFallback(request.model.id) && !hasSignedToolCall
|
||||
? SKIP_THOUGHT_SIGNATURE_VALIDATOR
|
||||
: undefined),
|
||||
})
|
||||
if (signature !== undefined) hasSignedToolCall = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -388,6 +417,9 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
temperature: generation?.temperature,
|
||||
topP: generation?.topP,
|
||||
topK: generation?.topK,
|
||||
frequencyPenalty: generation?.frequencyPenalty,
|
||||
presencePenalty: generation?.presencePenalty,
|
||||
seed: generation?.seed,
|
||||
stopSequences: generation?.stop,
|
||||
thinkingConfig: options.thinkingConfig,
|
||||
}
|
||||
|
||||
@@ -539,7 +539,7 @@ const lowerOptions = (request: LLMRequest) => {
|
||||
return {
|
||||
...(options.instructions ? { instructions: options.instructions } : {}),
|
||||
...(options.store !== undefined ? { store: options.store } : {}),
|
||||
...(options.promptCacheKey ? { prompt_cache_key: options.promptCacheKey } : {}),
|
||||
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
|
||||
...(options.include ? { include: options.include } : {}),
|
||||
...(options.reasoningEffort || options.reasoningSummary
|
||||
? { reasoning: { effort: options.reasoningEffort, summary: options.reasoningSummary } }
|
||||
|
||||
@@ -132,6 +132,7 @@ export const bodyFields = {
|
||||
stream: Schema.Literal(true),
|
||||
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
prompt_cache_key: Schema.optional(Schema.String),
|
||||
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
|
||||
max_completion_tokens: Schema.optional(Schema.Number),
|
||||
max_tokens: Schema.optional(Schema.Number),
|
||||
@@ -370,12 +371,13 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||
return text
|
||||
})()
|
||||
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
|
||||
const cacheControl = options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined)
|
||||
const result = {
|
||||
role: "assistant" as const,
|
||||
content: content.length === 0 ? null : ProviderShared.joinText(content),
|
||||
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
|
||||
reasoning_details: details,
|
||||
cache_control: options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined),
|
||||
content: content.length > 0 ? content.map((part) => part.text).join("") : toolCalls.length > 0 ? null : "",
|
||||
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
||||
...(details !== undefined ? { reasoning_details: details } : {}),
|
||||
...(cacheControl !== undefined ? { cache_control: cacheControl } : {}),
|
||||
}
|
||||
if (field === undefined || reasoningText === undefined) return result
|
||||
return { ...result, [field]: reasoningText }
|
||||
@@ -509,6 +511,7 @@ const lowerOptions = (request: LLMRequest) => {
|
||||
const options = OpenAIOptions.resolve(request)
|
||||
return {
|
||||
...(options.store !== undefined ? { store: options.store } : {}),
|
||||
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
|
||||
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
|
||||
}
|
||||
}
|
||||
@@ -707,23 +710,20 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
Boolean(delta?.content) ||
|
||||
reasoning !== undefined ||
|
||||
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
|
||||
toolDeltas.some(
|
||||
(tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments),
|
||||
)
|
||||
toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments))
|
||||
if (state.finishReason !== undefined) {
|
||||
if (hasLateContent)
|
||||
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat received content after the finish reason")
|
||||
return [{ ...state, usage }, events] as const
|
||||
}
|
||||
|
||||
const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
|
||||
const reasoningField = state.reasoningField ?? reasoning?.field
|
||||
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
|
||||
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
|
||||
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
|
||||
const deltaMetadata = reasoningMetadata(reasoningField)
|
||||
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
|
||||
if (!state.lifecycle.text.has("text-0") && text !== undefined)
|
||||
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
|
||||
if (text !== undefined) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
|
||||
else if (
|
||||
reasoningDetailsObserved &&
|
||||
!lifecycle.reasoning.has("reasoning-0") &&
|
||||
@@ -749,8 +749,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const fallback = toolDeltas.length > 1 ? position : (latestToolIndex ?? position)
|
||||
const fallbackTool = tools[fallback] ?? pendingTools[fallback]
|
||||
const index =
|
||||
tool.index ?? matched ??
|
||||
(tool.id && fallbackTool?.id && fallbackTool.id !== tool.id ? nextToolIndex : fallback)
|
||||
tool.index ?? matched ?? (tool.id && fallbackTool?.id && fallbackTool.id !== tool.id ? nextToolIndex : fallback)
|
||||
const current = tools[index]
|
||||
const pending = pendingTools[index]
|
||||
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
|
||||
@@ -813,14 +812,18 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
|
||||
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
const events: LLMEvent[] = []
|
||||
const hasToolCalls = state.toolCallEvents.length > 0
|
||||
const toolCallEvents =
|
||||
state.finishReason === undefined && Object.keys(state.tools).length > 0
|
||||
? Effect.runSync(ToolStream.finishAll(ADAPTER, state.tools)).events
|
||||
: state.toolCallEvents
|
||||
const hasToolCalls = toolCallEvents.length > 0
|
||||
const reason = state.finishReason
|
||||
? {
|
||||
...state.finishReason,
|
||||
normalized:
|
||||
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
|
||||
}
|
||||
: undefined
|
||||
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("unknown" as const) }
|
||||
const metadata = reasoningMetadata(
|
||||
state.reasoningField,
|
||||
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
@@ -830,9 +833,9 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField))
|
||||
: state.lifecycle
|
||||
const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
|
||||
const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
|
||||
events.push(...state.toolCallEvents)
|
||||
if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
|
||||
const lifecycle = toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
|
||||
events.push(...toolCallEvents)
|
||||
Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
|
||||
return events
|
||||
}
|
||||
|
||||
|
||||
@@ -61,37 +61,57 @@ const emptyObjectSchema = (schema: Record<string, unknown>) =>
|
||||
(!isRecord(schema.properties) || Object.keys(schema.properties).length === 0) &&
|
||||
!schema.additionalProperties
|
||||
|
||||
const projectNode = (schema: unknown): Record<string, unknown> | undefined => {
|
||||
const projectNode = (schema: unknown, nested = false): Record<string, unknown> | undefined => {
|
||||
if (!isRecord(schema)) return undefined
|
||||
if (emptyObjectSchema(schema)) return undefined
|
||||
return Object.fromEntries(
|
||||
if (!nested && emptyObjectSchema(schema)) return undefined
|
||||
const types = Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null") : undefined
|
||||
const anyOf = Array.isArray(schema.anyOf) ? schema.anyOf : undefined
|
||||
const hasNullAnyOf = anyOf?.some((item) => isRecord(item) && item.type === "null") ?? false
|
||||
const anyOfTypes = hasNullAnyOf ? anyOf?.filter((item) => !isRecord(item) || item.type !== "null") : anyOf
|
||||
const flattenedAnyOf = hasNullAnyOf && anyOfTypes?.length === 1 ? projectNode(anyOfTypes[0], true) : undefined
|
||||
const result = Object.fromEntries(
|
||||
[
|
||||
["description", schema.description],
|
||||
["required", schema.required],
|
||||
["format", schema.format],
|
||||
["type", Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null")[0] : schema.type],
|
||||
["nullable", Array.isArray(schema.type) && schema.type.includes("null") ? true : undefined],
|
||||
["type", types ? (types.length === 0 ? "null" : undefined) : schema.type],
|
||||
[
|
||||
"nullable",
|
||||
(Array.isArray(schema.type) && schema.type.includes("null") && types && types.length > 0) || hasNullAnyOf
|
||||
? true
|
||||
: undefined,
|
||||
],
|
||||
["enum", schema.const !== undefined ? [schema.const] : schema.enum],
|
||||
[
|
||||
"properties",
|
||||
isRecord(schema.properties)
|
||||
? Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, projectNode(value)]))
|
||||
? Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, projectNode(value, true)]))
|
||||
: undefined,
|
||||
],
|
||||
[
|
||||
"items",
|
||||
Array.isArray(schema.items)
|
||||
? schema.items.map(projectNode)
|
||||
? schema.items.map((item) => projectNode(item, true))
|
||||
: schema.items === undefined
|
||||
? undefined
|
||||
: projectNode(schema.items),
|
||||
: projectNode(schema.items, true),
|
||||
],
|
||||
["allOf", Array.isArray(schema.allOf) ? schema.allOf.map(projectNode) : undefined],
|
||||
["anyOf", Array.isArray(schema.anyOf) ? schema.anyOf.map(projectNode) : undefined],
|
||||
["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map(projectNode) : undefined],
|
||||
["allOf", Array.isArray(schema.allOf) ? schema.allOf.map((item) => projectNode(item, true)) : undefined],
|
||||
[
|
||||
"anyOf",
|
||||
anyOfTypes
|
||||
? hasNullAnyOf && anyOfTypes.length === 1
|
||||
? undefined
|
||||
: anyOfTypes.map((item) => projectNode(item, true))
|
||||
: types && types.length > 0
|
||||
? types.map((type) => ({ type }))
|
||||
: undefined,
|
||||
],
|
||||
["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map((item) => projectNode(item, true)) : undefined],
|
||||
["minLength", schema.minLength],
|
||||
].filter((entry) => entry[1] !== undefined),
|
||||
)
|
||||
return flattenedAnyOf ? { ...result, ...flattenedAnyOf } : result
|
||||
}
|
||||
|
||||
export const convert = (schema: unknown) => projectNode(sanitizeNode(schema))
|
||||
|
||||
@@ -33,7 +33,6 @@ export const ServiceTierSchema = Schema.Literals(ServiceTiers)
|
||||
export interface Resolved {
|
||||
readonly instructions?: string
|
||||
readonly store?: boolean
|
||||
readonly promptCacheKey?: string
|
||||
readonly reasoningEffort?: string
|
||||
readonly reasoningSummary?: "auto" | "concise" | "detailed"
|
||||
readonly include?: ReadonlyArray<ResponseIncludable>
|
||||
@@ -50,7 +49,6 @@ export const resolve = (request: LLMRequest): Resolved => {
|
||||
return {
|
||||
instructions: typeof input?.instructions === "string" ? input.instructions : undefined,
|
||||
store: typeof input?.store === "boolean" ? input.store : undefined,
|
||||
promptCacheKey: typeof input?.promptCacheKey === "string" ? input.promptCacheKey : undefined,
|
||||
reasoningEffort: typeof input?.reasoningEffort === "string" ? input.reasoningEffort : undefined,
|
||||
reasoningSummary:
|
||||
reasoningSummary === "auto" || reasoningSummary === "concise" || reasoningSummary === "detailed"
|
||||
|
||||
@@ -5,7 +5,6 @@ export interface OpenResponsesOptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly instructions?: string
|
||||
readonly store?: boolean
|
||||
readonly promptCacheKey?: string
|
||||
readonly reasoningEffort?: ReasoningEffort
|
||||
readonly reasoningSummary?: "auto" | "concise" | "detailed"
|
||||
readonly include?: ReadonlyArray<ResponseIncludable>
|
||||
|
||||
@@ -17,7 +17,6 @@ const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): Provide
|
||||
const openai = Object.fromEntries(
|
||||
definedEntries({
|
||||
store: options?.store,
|
||||
promptCacheKey: options?.promptCacheKey,
|
||||
reasoningEffort: options?.reasoningEffort,
|
||||
reasoningSummary: options?.reasoningSummary,
|
||||
include: options?.include,
|
||||
|
||||
@@ -55,7 +55,6 @@ export interface OpenRouterOptions {
|
||||
readonly debug?: Readonly<{ echo_upstream_body?: boolean }>
|
||||
readonly models?: ReadonlyArray<string>
|
||||
readonly plugins?: ReadonlyArray<OpenRouterPlugin>
|
||||
readonly promptCacheKey?: string
|
||||
readonly provider?: OpenRouterProviderRouting
|
||||
readonly reasoning?: Readonly<{
|
||||
enabled?: boolean
|
||||
@@ -122,6 +121,7 @@ export const protocol = Protocol.make({
|
||||
...body,
|
||||
messages,
|
||||
...bodyOptions(request.providerOptions?.openrouter),
|
||||
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
|
||||
} as OpenRouterBody
|
||||
}),
|
||||
),
|
||||
@@ -161,7 +161,6 @@ const bodyOptions = (input: unknown) => {
|
||||
...(isRecord(debug) ? { debug } : {}),
|
||||
...(typeof user === "string" ? { user } : {}),
|
||||
...(isRecord(reasoning) ? { reasoning } : {}),
|
||||
...(typeof promptCacheKey === "string" ? { prompt_cache_key: promptCacheKey } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@ const chatRoute = Route.make({
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
||||
transport: OpenAICompatibleChat.route.transport,
|
||||
headers: ({ request }): Record<string, string> =>
|
||||
request.promptCacheKey ? { "x-grok-conv-id": request.promptCacheKey } : {},
|
||||
})
|
||||
|
||||
export const routes = [responsesRoute, chatRoute]
|
||||
|
||||
@@ -255,13 +255,7 @@ const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent,
|
||||
if (LLMEvent.is.finish(event) || LLMEvent.is.providerError(event)) terminal = true
|
||||
return Effect.succeed(event)
|
||||
}),
|
||||
Stream.onEnd(
|
||||
Effect.suspend(() =>
|
||||
terminal
|
||||
? Effect.void
|
||||
: Effect.fail(incompleteStreamError(route)),
|
||||
),
|
||||
),
|
||||
Stream.onEnd(Effect.suspend(() => (terminal ? Effect.void : Effect.fail(incompleteStreamError(route))))),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, Context, Effect, Layer } from "effect"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
Headers,
|
||||
@@ -198,8 +198,20 @@ const responseBody = (body: string | void, request: HttpClientRequest.HttpClient
|
||||
return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
|
||||
}
|
||||
|
||||
const decodeProviderBody = Schema.decodeUnknownOption(
|
||||
Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
message: Schema.optionalKey(Schema.String),
|
||||
error: Schema.optionalKey(Schema.Struct({ message: Schema.optionalKey(Schema.String) })),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const providerMessage = (status: number, body: { readonly body?: string }) => {
|
||||
if (body.body && body.body.length <= 500) return `Provider request failed with HTTP ${status}: ${body.body}`
|
||||
if (body.body && body.body.length <= 500) {
|
||||
const decoded = Option.getOrUndefined(decodeProviderBody(body.body))
|
||||
return `Provider request failed with HTTP ${status}: ${decoded?.error?.message ?? decoded?.message ?? body.body}`
|
||||
}
|
||||
return `Provider request failed with HTTP ${status}`
|
||||
}
|
||||
|
||||
|
||||
@@ -45,35 +45,34 @@ const isToolResultValue = (value: unknown): value is ToolResultValue =>
|
||||
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
|
||||
"value" in value
|
||||
|
||||
export const ToolResultValue = Object.assign(
|
||||
Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("json"),
|
||||
value: Schema.Unknown,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
value: Schema.Unknown,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("error"),
|
||||
value: Schema.Unknown,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("content"),
|
||||
value: Schema.Array(Tool.Content),
|
||||
}),
|
||||
]).annotate({ identifier: "LLM.ToolResult" }),
|
||||
{
|
||||
is: isToolResultValue,
|
||||
make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => {
|
||||
if (isToolResultValue(value)) return value
|
||||
if (type === "content") return { type, value: Array.isArray(value) ? value : [] }
|
||||
return { type, value }
|
||||
},
|
||||
const toolResultValueSchema = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("json"),
|
||||
value: Schema.Unknown,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
value: Schema.Unknown,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("error"),
|
||||
value: Schema.Unknown,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("content"),
|
||||
value: Schema.Array(Tool.Content),
|
||||
}),
|
||||
]).annotate({ identifier: "LLM.ToolResult" })
|
||||
export type ToolResultValue = Schema.Schema.Type<typeof toolResultValueSchema>
|
||||
|
||||
export const ToolResultValue = Object.assign(toolResultValueSchema, {
|
||||
is: isToolResultValue,
|
||||
make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => {
|
||||
if (isToolResultValue(value)) return value
|
||||
if (type === "content") return { type, value: Array.isArray(value) ? value : [] }
|
||||
return { type, value }
|
||||
},
|
||||
)
|
||||
export type ToolResultValue = Schema.Schema.Type<typeof ToolResultValue>
|
||||
})
|
||||
|
||||
export interface ToolOutput {
|
||||
readonly structured: unknown
|
||||
@@ -272,6 +271,8 @@ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
|
||||
providerOptions: Schema.optional(ProviderOptions),
|
||||
http: Schema.optional(HttpOptions),
|
||||
cache: Schema.optional(CachePolicy),
|
||||
// Stable cache affinity for protocols that support provider-managed prompt caching.
|
||||
promptCacheKey: Schema.optional(Schema.String),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}) {}
|
||||
|
||||
@@ -289,6 +290,7 @@ export namespace LLMRequest {
|
||||
providerOptions: request.providerOptions,
|
||||
http: request.http,
|
||||
cache: request.cache,
|
||||
promptCacheKey: request.promptCacheKey,
|
||||
metadata: request.metadata,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { Effect, JsonSchema, Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import type {
|
||||
ToolCallPart,
|
||||
ToolDefinition as ToolDefinitionClass,
|
||||
ToolOutput as ToolOutputType,
|
||||
} from "./schema"
|
||||
import type { ToolCallPart, ToolDefinition as ToolDefinitionClass, ToolOutput as ToolOutputType } from "./schema"
|
||||
import { ToolDefinition, ToolFailure, ToolOutput } from "./schema"
|
||||
|
||||
/**
|
||||
@@ -244,8 +240,7 @@ const project = (
|
||||
): ToolOutputType =>
|
||||
ToolOutput.make(
|
||||
toStructuredOutput?.(output) ?? output,
|
||||
toModelOutput?.({ id, parameters, output }) ??
|
||||
(typeof output === "string" ? [{ type: "text", text: output }] : []),
|
||||
toModelOutput?.({ id, parameters, output }) ?? (typeof output === "string" ? [{ type: "text", text: output }] : []),
|
||||
)
|
||||
|
||||
export { ToolFailure }
|
||||
|
||||
+1
-7
@@ -1,13 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:anthropic-messages-cache",
|
||||
"provider:anthropic",
|
||||
"protocol:anthropic-messages",
|
||||
"cache",
|
||||
"tool"
|
||||
],
|
||||
"tags": ["prefix:anthropic-messages-cache", "provider:anthropic", "protocol:anthropic-messages", "cache", "tool"],
|
||||
"name": "anthropic-messages-cache/keeps-a-long-tool-turn-inside-the-cache-lookback",
|
||||
"recordedAt": "2026-07-24T16:22:29.494Z"
|
||||
},
|
||||
|
||||
+1
-6
@@ -1,12 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:bedrock-converse-cache",
|
||||
"provider:amazon-bedrock",
|
||||
"protocol:bedrock-converse",
|
||||
"cache"
|
||||
],
|
||||
"tags": ["prefix:bedrock-converse-cache", "provider:amazon-bedrock", "protocol:bedrock-converse", "cache"],
|
||||
"name": "bedrock-converse-cache/writes-then-reads-cachepoint-on-identical-second-call",
|
||||
"recordedAt": "2026-07-23T02:29:10.955Z"
|
||||
},
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:google-images",
|
||||
"provider:google",
|
||||
"protocol:google-images"
|
||||
],
|
||||
"tags": ["prefix:google-images", "provider:google", "protocol:google-images"],
|
||||
"name": "google-images/generates-an-image",
|
||||
"recordedAt": "2026-07-19T16:05:51.868Z"
|
||||
},
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:openai-images",
|
||||
"provider:openai",
|
||||
"protocol:openai-images"
|
||||
],
|
||||
"tags": ["prefix:openai-images", "provider:openai", "protocol:openai-images"],
|
||||
"name": "openai-images/generates-an-image",
|
||||
"recordedAt": "2026-07-19T14:41:43.188Z"
|
||||
},
|
||||
|
||||
+1
-5
@@ -1,11 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:openai-responses-images",
|
||||
"provider:openai",
|
||||
"protocol:openai-responses"
|
||||
],
|
||||
"tags": ["prefix:openai-responses-images", "provider:openai", "protocol:openai-responses"],
|
||||
"name": "openai-responses-images/generates-and-edits-an-image-with-the-hosted-tool",
|
||||
"recordedAt": "2026-07-19T14:57:16.284Z"
|
||||
},
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"tags": [
|
||||
"prefix:openai-compatible-chat",
|
||||
"provider:openrouter",
|
||||
"protocol:openai-chat",
|
||||
"reasoning"
|
||||
],
|
||||
"tags": ["prefix:openai-compatible-chat", "provider:openrouter", "protocol:openai-chat", "reasoning"],
|
||||
"name": "openrouter-reasoning",
|
||||
"recordedAt": "2026-07-18T11:28:39.267Z"
|
||||
},
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:anthropic",
|
||||
"protocol:anthropic-messages",
|
||||
"tool",
|
||||
"tool-result"
|
||||
],
|
||||
"tags": ["prefix:pdf", "pdf", "provider:anthropic", "protocol:anthropic-messages", "tool", "tool-result"],
|
||||
"name": "pdf/anthropic-tool-result",
|
||||
"recordedAt": "2026-07-22T18:15:39.002Z"
|
||||
},
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:anthropic",
|
||||
"protocol:anthropic-messages",
|
||||
"user-input"
|
||||
],
|
||||
"tags": ["prefix:pdf", "pdf", "provider:anthropic", "protocol:anthropic-messages", "user-input"],
|
||||
"name": "pdf/anthropic-user-input",
|
||||
"recordedAt": "2026-07-22T18:15:37.979Z"
|
||||
},
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:amazon-bedrock",
|
||||
"protocol:bedrock-converse",
|
||||
"tool",
|
||||
"tool-result"
|
||||
],
|
||||
"tags": ["prefix:pdf", "pdf", "provider:amazon-bedrock", "protocol:bedrock-converse", "tool", "tool-result"],
|
||||
"name": "pdf/bedrock-tool-result",
|
||||
"recordedAt": "2026-07-22T18:15:52.400Z"
|
||||
},
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:amazon-bedrock",
|
||||
"protocol:bedrock-converse",
|
||||
"user-input"
|
||||
],
|
||||
"tags": ["prefix:pdf", "pdf", "provider:amazon-bedrock", "protocol:bedrock-converse", "user-input"],
|
||||
"name": "pdf/bedrock-user-input",
|
||||
"recordedAt": "2026-07-22T18:15:48.408Z"
|
||||
},
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:google",
|
||||
"protocol:gemini",
|
||||
"tool",
|
||||
"tool-result"
|
||||
],
|
||||
"tags": ["prefix:pdf", "pdf", "provider:google", "protocol:gemini", "tool", "tool-result"],
|
||||
"name": "pdf/gemini-tool-result",
|
||||
"recordedAt": "2026-07-22T18:21:59.606Z"
|
||||
},
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:google",
|
||||
"protocol:gemini",
|
||||
"user-input"
|
||||
],
|
||||
"tags": ["prefix:pdf", "pdf", "provider:google", "protocol:gemini", "user-input"],
|
||||
"name": "pdf/gemini-user-input",
|
||||
"recordedAt": "2026-07-22T18:20:55.140Z"
|
||||
},
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:openai",
|
||||
"protocol:openai-responses",
|
||||
"tool",
|
||||
"tool-result"
|
||||
],
|
||||
"tags": ["prefix:pdf", "pdf", "provider:openai", "protocol:openai-responses", "tool", "tool-result"],
|
||||
"name": "pdf/openai-tool-result",
|
||||
"recordedAt": "2026-07-22T18:15:36.438Z"
|
||||
},
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:openai",
|
||||
"protocol:openai-responses",
|
||||
"user-input"
|
||||
],
|
||||
"tags": ["prefix:pdf", "pdf", "provider:openai", "protocol:openai-responses", "user-input"],
|
||||
"name": "pdf/openai-user-input",
|
||||
"recordedAt": "2026-07-22T18:15:34.867Z"
|
||||
},
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:xai",
|
||||
"protocol:openai-responses",
|
||||
"tool",
|
||||
"tool-result"
|
||||
],
|
||||
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:openai-responses", "tool", "tool-result"],
|
||||
"name": "pdf/xai-tool-result",
|
||||
"recordedAt": "2026-07-22T18:15:43.608Z"
|
||||
},
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:pdf",
|
||||
"pdf",
|
||||
"provider:xai",
|
||||
"protocol:openai-responses",
|
||||
"user-input"
|
||||
],
|
||||
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:openai-responses", "user-input"],
|
||||
"name": "pdf/xai-user-input",
|
||||
"recordedAt": "2026-07-22T18:15:42.429Z"
|
||||
},
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"tags": [
|
||||
"prefix:openai-compatible-chat",
|
||||
"provider:vercel-ai-gateway",
|
||||
"protocol:openai-chat",
|
||||
"reasoning"
|
||||
],
|
||||
"tags": ["prefix:openai-compatible-chat", "provider:vercel-ai-gateway", "protocol:openai-chat", "reasoning"],
|
||||
"name": "vercel-ai-gateway-reasoning",
|
||||
"recordedAt": "2026-07-18T11:28:42.077Z"
|
||||
},
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:xai-images",
|
||||
"provider:xai",
|
||||
"protocol:xai-images"
|
||||
],
|
||||
"tags": ["prefix:xai-images", "provider:xai", "protocol:xai-images"],
|
||||
"name": "xai-images/generates-an-image",
|
||||
"recordedAt": "2026-07-19T15:56:20.098Z"
|
||||
},
|
||||
|
||||
@@ -3,11 +3,11 @@ import { CloudflareWorkersAI } from "../../src/providers"
|
||||
|
||||
const model = CloudflareWorkersAI.configure({ accountId: "account", apiKey: "test" }).model("model")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { promptCacheKey: "cache" } } })
|
||||
LLM.request({ model, prompt: "Hello", promptCacheKey: "cache" })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Cloudflare's OpenAI-compatible prompt cache key must be a string.
|
||||
providerOptions: { openai: { promptCacheKey: 1 } },
|
||||
// @ts-expect-error Prompt cache keys must be strings.
|
||||
promptCacheKey: 1,
|
||||
})
|
||||
|
||||
@@ -519,14 +519,8 @@ describe("Bedrock Converse route", () => {
|
||||
fixedBytes(
|
||||
eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
[
|
||||
"contentBlockDelta",
|
||||
{ contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } },
|
||||
],
|
||||
[
|
||||
"contentBlockDelta",
|
||||
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
|
||||
],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
|
||||
["messageStop", { stopReason: "end_turn" }],
|
||||
),
|
||||
),
|
||||
@@ -561,10 +555,7 @@ describe("Bedrock Converse route", () => {
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
[
|
||||
"contentBlockDelta",
|
||||
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
|
||||
],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
|
||||
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||
["messageStop", { stopReason: "end_turn" }],
|
||||
)
|
||||
|
||||
@@ -16,6 +16,13 @@ const model = Gemini.route
|
||||
})
|
||||
.model({ id: "gemini-2.5-flash" })
|
||||
|
||||
const gemini3 = Gemini.route
|
||||
.with({
|
||||
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
|
||||
auth: Auth.header("x-goog-api-key", "test"),
|
||||
})
|
||||
.model({ id: "gemini-3-flash-preview" })
|
||||
|
||||
const request = LLM.request({
|
||||
id: "req_1",
|
||||
model,
|
||||
@@ -86,6 +93,39 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forwards standard Gemini generation options", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Say hello.",
|
||||
generation: {
|
||||
maxTokens: 40,
|
||||
temperature: 0.2,
|
||||
topP: 0.8,
|
||||
topK: 12,
|
||||
frequencyPenalty: 0.3,
|
||||
presencePenalty: 0.4,
|
||||
seed: 42,
|
||||
stop: ["done"],
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.generationConfig).toEqual({
|
||||
maxOutputTokens: 40,
|
||||
temperature: 0.2,
|
||||
topP: 0.8,
|
||||
topK: 12,
|
||||
frequencyPenalty: 0.3,
|
||||
presencePenalty: 0.4,
|
||||
seed: 42,
|
||||
stopSequences: ["done"],
|
||||
thinkingConfig: undefined,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates to wrapped user text in order", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -350,6 +390,100 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves nested empty object tool schemas", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Use the tool.",
|
||||
tools: [
|
||||
{
|
||||
name: "configure",
|
||||
description: "Configure the operation",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["options"],
|
||||
properties: {
|
||||
options: { type: "object", description: "Optional provider settings", properties: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.tools).toEqual([
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: "configure",
|
||||
description: "Configure the operation",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["options"],
|
||||
properties: {
|
||||
options: { type: "object", description: "Optional provider settings", properties: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects Gemini type arrays without narrowing their allowed values", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Use the tool.",
|
||||
tools: [
|
||||
{
|
||||
name: "filter",
|
||||
description: "Filter values",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: ["number", "string"], description: "Status filter" },
|
||||
maybe: { type: ["string", "null"] },
|
||||
nothing: { type: ["null"] },
|
||||
explicit: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
choice: { anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.tools?.[0]?.functionDeclarations[0]?.parameters).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
status: {
|
||||
description: "Status filter",
|
||||
anyOf: [{ type: "number" }, { type: "string" }],
|
||||
},
|
||||
maybe: {
|
||||
nullable: true,
|
||||
anyOf: [{ type: "string" }],
|
||||
},
|
||||
nothing: {
|
||||
type: "null",
|
||||
},
|
||||
explicit: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
},
|
||||
choice: {
|
||||
anyOf: [{ type: "string" }, { type: "number" }],
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses text, reasoning, and usage stream fixtures", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
@@ -536,6 +670,44 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays unsigned Gemini 3 tool calls with the validator bypass sentinel", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: gemini3,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "tool_0", name: "lookup", input: { query: "weather" } })]),
|
||||
Message.tool({ id: "tool_0", name: "lookup", result: "done", resultType: "text" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "done" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits streamed tool calls and maps finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents({
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from "../../src"
|
||||
import * as Azure from "../../src/providers/azure"
|
||||
import * as OpenAI from "../../src/providers/openai"
|
||||
import * as OpenAICompatible from "../../src/providers/openai-compatible"
|
||||
import * as XAI from "../../src/providers/xai"
|
||||
import * as OpenAIChat from "../../src/protocols/openai-chat"
|
||||
import { ProviderShared } from "../../src/protocols/shared"
|
||||
import { Auth, LLMClient } from "../../src/route"
|
||||
@@ -100,6 +102,24 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("concatenates assistant text parts without adding separators", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "text", text: " world" },
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([{ role: "assistant", content: "Hello world" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("writes reasoning to a configured custom field on every assistant message", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -154,6 +174,47 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps the request prompt cache key", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAICompatible.configure({
|
||||
baseURL: "https://api.compatible.test/v1",
|
||||
apiKey: "test",
|
||||
}).model("compatible-model"),
|
||||
prompt: "Hello",
|
||||
promptCacheKey: "session_123",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.prompt_cache_key).toBe("session_123")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps the xAI Chat prompt cache key to conversation affinity", () =>
|
||||
LLMClient.generate(
|
||||
LLM.request({
|
||||
model: XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).chat("grok-4.5"),
|
||||
prompt: "Hello",
|
||||
promptCacheKey: "session_123",
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(web.headers.get("x-grok-conv-id")).toBe("session_123")
|
||||
const body = decodeJson(yield* Effect.promise(() => web.text()))
|
||||
expect(ProviderShared.isRecord(body) ? body.prompt_cache_key : undefined).toBe("session_123")
|
||||
return input.respond(sseEvents(deltaChunk({}, "stop")), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("passes through custom OpenAI-compatible reasoning effort strings", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -535,7 +596,7 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([{ role: "assistant", content: null, reasoning_content: "hidden" }])
|
||||
expect(prepared.body.messages).toEqual([{ role: "assistant", content: "", reasoning_content: "hidden" }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -784,7 +845,7 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores scalar reasoning after content starts", () =>
|
||||
it.effect("preserves scalar reasoning after content starts", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -800,11 +861,11 @@ describe("OpenAI Chat route", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("detail")
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
expect(response.reasoning).toBe("detailscalar")
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(2)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(2)
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningDetails: details },
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: details },
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -904,7 +965,7 @@ describe("OpenAI Chat route", () => {
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }])
|
||||
expect(replay.body.messages).toEqual([{ role: "assistant", content: "", reasoning_details: details }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -954,7 +1015,7 @@ describe("OpenAI Chat route", () => {
|
||||
)
|
||||
|
||||
expect(replay.body.messages).toEqual([
|
||||
{ role: "assistant", content: null, reasoning: "firstsecond", reasoning_details: [first, second] },
|
||||
{ role: "assistant", content: "", reasoning: "firstsecond", reasoning_details: [first, second] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -979,7 +1040,7 @@ describe("OpenAI Chat route", () => {
|
||||
)
|
||||
|
||||
expect(replay.body.messages).toEqual([
|
||||
{ role: "assistant", content: null, reasoning_content: "AB", reasoning_details: [detail] },
|
||||
{ role: "assistant", content: "", reasoning_content: "AB", reasoning_details: [detail] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1001,7 +1062,7 @@ describe("OpenAI Chat route", () => {
|
||||
)
|
||||
|
||||
expect(replay.body.messages).toEqual([
|
||||
{ role: "assistant", content: null, reasoning_content: "thinking", reasoning_details: details },
|
||||
{ role: "assistant", content: "", reasoning_content: "thinking", reasoning_details: details },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1109,7 +1170,7 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails a streamed tool call when the provider ends without a finish reason", () =>
|
||||
it.effect("finalizes a streamed tool call when the provider ends without a finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
deltaChunk({
|
||||
@@ -1121,27 +1182,31 @@ describe("OpenAI Chat route", () => {
|
||||
const input = LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
})
|
||||
const events: LLMEvent[] = []
|
||||
const streamError = yield* LLMClient.stream(input).pipe(
|
||||
Stream.runForEach((event) => Effect.sync(() => events.push(event))),
|
||||
Effect.flip,
|
||||
Effect.provide(fixedResponse(body)),
|
||||
)
|
||||
const error = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
|
||||
const response = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(events).toEqual([
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "tool-calls" },
|
||||
usage: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "finish", reason: { normalized: "tool-calls" }, usage: undefined },
|
||||
])
|
||||
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
|
||||
expect(streamError.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
classification: "incomplete-stream",
|
||||
})
|
||||
expect(streamError.message).toContain("The provider response ended unexpectedly.")
|
||||
expect(error.message).toContain("The provider response ended unexpectedly.")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -300,7 +300,9 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "weather", description: "Get weather", inputSchema: { type: "object" } })],
|
||||
tools: [
|
||||
ToolDefinition.make({ name: "weather", description: "Get weather", inputSchema: { type: "object" } }),
|
||||
],
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
|
||||
@@ -20,7 +20,7 @@ const cacheRequest = LLM.request({
|
||||
system: LARGE_CACHEABLE_SYSTEM,
|
||||
prompt: "Say hi.",
|
||||
generation: { maxTokens: 16, temperature: 0 },
|
||||
providerOptions: { openai: { promptCacheKey: "recorded-cache-test" } },
|
||||
promptCacheKey: "recorded-cache-test",
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
|
||||
@@ -682,9 +682,9 @@ describe("OpenAI Responses route", () => {
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
|
||||
prompt: "think",
|
||||
promptCacheKey: "session_123",
|
||||
providerOptions: {
|
||||
openai: {
|
||||
promptCacheKey: "session_123",
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
@@ -803,17 +803,16 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("request OpenAI provider options override route defaults", () =>
|
||||
it.effect("maps the request prompt cache key", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({
|
||||
baseURL: "https://api.openai.test/v1/",
|
||||
apiKey: "test",
|
||||
providerOptions: { openai: { promptCacheKey: "model_cache" } },
|
||||
}).model("gpt-4.1-mini"),
|
||||
prompt: "no cache",
|
||||
providerOptions: { openai: { promptCacheKey: "request_cache" } },
|
||||
promptCacheKey: "request_cache",
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -162,7 +162,6 @@ describe("OpenRouter", () => {
|
||||
openrouter: {
|
||||
usage: true,
|
||||
reasoning: { effort: "high" },
|
||||
promptCacheKey: "session_123",
|
||||
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
|
||||
provider: { order: ["anthropic", "google"], require_parameters: true },
|
||||
plugins: [{ id: "response-healing" }],
|
||||
@@ -174,6 +173,7 @@ describe("OpenRouter", () => {
|
||||
},
|
||||
}).model("anthropic/claude-3.7-sonnet:thinking"),
|
||||
prompt: "Think briefly.",
|
||||
promptCacheKey: "session_123",
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -177,8 +177,8 @@ test("shows all and expands historical diff summary without overlap", async ({ p
|
||||
const firstUser = userMessage(undefined, {
|
||||
summary: {
|
||||
diffs: Array.from({ length: 12 }, (_, index) => ({
|
||||
file: `src/diff-${index}.ts`,
|
||||
status: "modified",
|
||||
file: `src/diff-${index}.ts`,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
|
||||
|
||||
@@ -85,8 +85,7 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/api/event")
|
||||
return sse(route)
|
||||
if (url.pathname === "/api/event") return sse(route)
|
||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
|
||||
@@ -181,12 +181,17 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
return json(route, true)
|
||||
}
|
||||
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/api/event")
|
||||
return sse(route)
|
||||
if (url.pathname === "/api/event") return sse(route)
|
||||
if (url.pathname === "/api/provider")
|
||||
return json(route, {
|
||||
location: { directory },
|
||||
data: [{ id: remote ? "server-b" : "server-a", name: remote ? "Server B Provider" : "Server A Provider", package: "test" }],
|
||||
data: [
|
||||
{
|
||||
id: remote ? "server-b" : "server-a",
|
||||
name: remote ? "Server B Provider" : "Server A Provider",
|
||||
package: "test",
|
||||
},
|
||||
],
|
||||
})
|
||||
if (url.pathname === "/api/model") return json(route, { location: { directory }, data: [model(remote)] })
|
||||
if (url.pathname === "/api/model/default") return json(route, { location: { directory }, data: model(remote) })
|
||||
|
||||
@@ -58,8 +58,7 @@ async function mockServers(page: Page) {
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/api/event")
|
||||
return sse(route, url.pathname === "/api/event")
|
||||
if (url.pathname === "/api/event") return sse(route, url.pathname === "/api/event")
|
||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||
if (url.pathname === "/api/session/active")
|
||||
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
|
||||
|
||||
+8
-10
@@ -1,19 +1,18 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const draftID = "draft_legacy_new_session"
|
||||
const directory = "C:/OpenCode/LegacyNewSession"
|
||||
const draftID = "draft_removed_layout_preference"
|
||||
const directory = "C:/OpenCode/RemovedLayoutPreference"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
||||
test("ignores persisted old layout preferences when opening drafts", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_legacy_new_session",
|
||||
id: "proj_removed_layout_preference",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "legacy-new-session",
|
||||
name: "removed-layout-preference",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
@@ -24,7 +23,6 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, server }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
||||
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||
@@ -35,7 +33,7 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
|
||||
await expect(page).toHaveURL(`/${base64Encode(directory)}/session`)
|
||||
await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible()
|
||||
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
|
||||
await expect(page).toHaveURL(`/new-session?draftId=${draftID}`)
|
||||
await expect(page.locator("body")).toHaveAttribute("data-new-layout", "")
|
||||
await expect(page.getByRole("textbox", { name: "Prompt" })).toBeVisible()
|
||||
})
|
||||
@@ -171,8 +171,8 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
expect(bottomGap).toBeLessThanOrEqual(16)
|
||||
const lazyDiff = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url())
|
||||
return (
|
||||
url.pathname === "/api/vcs/diff" &&
|
||||
return (
|
||||
url.pathname === "/api/vcs/diff" &&
|
||||
url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||
)
|
||||
})
|
||||
|
||||
@@ -86,15 +86,10 @@ test.describe("session timeline projection", () => {
|
||||
],
|
||||
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
|
||||
)
|
||||
const aborted = assistantMessage(
|
||||
[
|
||||
{ id: "prt_before_abort", type: "text", text: "Before interruption" },
|
||||
],
|
||||
{
|
||||
id: "msg_1001_assistant_aborted",
|
||||
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
||||
},
|
||||
)
|
||||
const aborted = assistantMessage([{ id: "prt_before_abort", type: "text", text: "Before interruption" }], {
|
||||
id: "msg_1001_assistant_aborted",
|
||||
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
||||
})
|
||||
const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], {
|
||||
id: "msg_1002_assistant_failed",
|
||||
error: {
|
||||
|
||||
@@ -89,8 +89,7 @@ async function mockServer(page: Page) {
|
||||
if (url.origin !== server) return route.fallback()
|
||||
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
||||
return new Promise(() => {})
|
||||
if (url.pathname === "/api/event")
|
||||
return sse(route)
|
||||
if (url.pathname === "/api/event") return sse(route)
|
||||
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
|
||||
|
||||
@@ -300,9 +300,8 @@ export const fixture = {
|
||||
.filter((message) => message.info.role === "user")
|
||||
.map((message) => message.info.id),
|
||||
targetPartIDs: targetMessages.flatMap(currentPartIDs),
|
||||
expandedShellPartID: targetMessages
|
||||
.flatMap((message) => message.parts)
|
||||
.find((part) => part.tool === "bash")!.callID,
|
||||
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!
|
||||
.callID,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
location: location(config),
|
||||
data: currentProviders(providerConfig(config)),
|
||||
})
|
||||
if (path === "/api/model") return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
|
||||
if (path === "/api/model")
|
||||
return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
|
||||
if (path === "/api/model/default")
|
||||
return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
|
||||
if (path === "/api/integration") return json(route, { location: location(config), data: [] })
|
||||
@@ -206,7 +207,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1]
|
||||
if (fileRead && config.fileContent) {
|
||||
const value = await config.fileContent(decodeURIComponent(fileRead))
|
||||
const content = value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
|
||||
const content =
|
||||
value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
|
||||
return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } })
|
||||
}
|
||||
if (path === "/api/fs/find" && config.findFiles) {
|
||||
@@ -281,7 +283,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") return json(route, true)
|
||||
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST")
|
||||
return json(route, true)
|
||||
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST")
|
||||
return json(route, true)
|
||||
if (
|
||||
@@ -386,11 +389,13 @@ function providerConfig(config: MockServerConfig) {
|
||||
|
||||
function currentProviders(value: unknown) {
|
||||
if (!record(value) || !Array.isArray(value.all)) return Array.isArray(value) ? value : []
|
||||
return value.all.filter(record).flatMap((provider) =>
|
||||
typeof provider.id === "string" && typeof provider.name === "string"
|
||||
? [{ id: provider.id, name: provider.name, package: provider.id }]
|
||||
: [],
|
||||
)
|
||||
return value.all
|
||||
.filter(record)
|
||||
.flatMap((provider) =>
|
||||
typeof provider.id === "string" && typeof provider.name === "string"
|
||||
? [{ id: provider.id, name: provider.name, package: provider.id }]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
function currentModels(value: unknown) {
|
||||
@@ -440,9 +445,7 @@ function currentDefaultModel(value: unknown) {
|
||||
if (!record(value) || !record(value.default)) return null
|
||||
const selected = value.default
|
||||
const models = currentModels(value)
|
||||
return models.find(
|
||||
(model) => model.providerID === selected.providerID && model.id === selected.modelID,
|
||||
) ?? null
|
||||
return models.find((model) => model.providerID === selected.providerID && model.id === selected.modelID) ?? null
|
||||
}
|
||||
|
||||
function currentPermission(value: unknown) {
|
||||
@@ -562,22 +565,16 @@ function legacyAgent(part: Record<string, unknown>): PromptAgentAttachment[] {
|
||||
}
|
||||
|
||||
function mentionFrom(value: Record<string, unknown> | undefined) {
|
||||
if (
|
||||
!value ||
|
||||
typeof value.value !== "string" ||
|
||||
typeof value.start !== "number" ||
|
||||
typeof value.end !== "number"
|
||||
)
|
||||
if (!value || typeof value.value !== "string" || typeof value.start !== "number" || typeof value.end !== "number")
|
||||
return
|
||||
return { text: value.value, start: value.start, end: value.end }
|
||||
}
|
||||
|
||||
function legacyAssistantContent(
|
||||
part: Record<string, unknown>,
|
||||
created: number,
|
||||
): SessionMessageAssistant["content"] {
|
||||
function legacyAssistantContent(part: Record<string, unknown>, created: number): SessionMessageAssistant["content"] {
|
||||
if (part.type === "text" && typeof part.text === "string")
|
||||
return [{ type: "text", text: part.text, ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}) }]
|
||||
return [
|
||||
{ type: "text", text: part.text, ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}) },
|
||||
]
|
||||
if (part.type === "reasoning" && typeof part.text === "string") {
|
||||
const time = record(part.time) ? part.time : undefined
|
||||
return [
|
||||
@@ -618,7 +615,12 @@ function legacyAssistantContent(
|
||||
...(jsonRecord(part.providerResultState) ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
|
||||
}
|
||||
if (state.status === "pending")
|
||||
return [{ ...base, state: { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(input) } }]
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
state: { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(input) },
|
||||
},
|
||||
]
|
||||
if (state.status === "completed")
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -174,8 +174,7 @@ export async function installSseTransport<T>(
|
||||
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init)
|
||||
const url = new URL(request.url)
|
||||
if (url.origin !== server || url.pathname !== "/api/event")
|
||||
return originalFetch(request)
|
||||
if (url.origin !== server || url.pathname !== "/api/event") return originalFetch(request)
|
||||
|
||||
const id = ++nextConnectionID
|
||||
const record = {
|
||||
|
||||
@@ -20,7 +20,10 @@
|
||||
<meta property="twitter:image" content="/social-share.png" />
|
||||
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
||||
</head>
|
||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden bg-v2-background-bg-deep">
|
||||
<body
|
||||
data-new-layout
|
||||
class="antialiased overscroll-none font-(family-name:--font-family-text) text-[13px] font-[440] overflow-hidden bg-v2-background-bg-deep"
|
||||
>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
|
||||
<script src="/src/entry.tsx" type="module"></script>
|
||||
|
||||
+47
-169
@@ -9,19 +9,9 @@ import { Font } from "@opencode-ai/ui/font"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import {
|
||||
type BaseRouterProps,
|
||||
Navigate,
|
||||
Route,
|
||||
Router,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from "@solidjs/router"
|
||||
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { Effect } from "effect"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import {
|
||||
type Component,
|
||||
createEffect,
|
||||
@@ -43,7 +33,7 @@ import { CommandProvider, useCommand, type CommandOption } from "@/context/comma
|
||||
import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
|
||||
import { ServerSyncProvider } from "@/context/server-sync"
|
||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||
@@ -54,58 +44,36 @@ import { PermissionProvider } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
import { WslServersProvider } from "@/wsl/context"
|
||||
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||
import LegacyLayout from "@/pages/layout"
|
||||
import NewLayout from "@/pages/layout-new"
|
||||
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||
import { createSessionLineage } from "@/pages/session/session-lineage"
|
||||
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
||||
import { NewHome } from "@/pages/home"
|
||||
import { LegacyHome } from "@/pages/home/legacy-home"
|
||||
import { TargetSessionRouteContent } from "@/pages/session"
|
||||
import { Home } from "@/pages/home"
|
||||
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
|
||||
const SessionRoute = () => {
|
||||
const settings = useSettings()
|
||||
const DirectoryDraftRedirect = () => {
|
||||
const params = useParams()
|
||||
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const sdk = useSDK()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
|
||||
if (params.id && settings.general.newLayoutDesigns()) {
|
||||
const sessionID = params.id
|
||||
return (
|
||||
<Show when={tabs.ready()}>
|
||||
{(_) => {
|
||||
const persisted = tabs.store.filter((item) => item.type === "session")
|
||||
return <Navigate href={sessionHref(legacySessionServer(persisted, sessionID, server.key), sessionID)} />
|
||||
}}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
|
||||
// is replaced by a draft at /new-session?draftId=…
|
||||
createEffect(() => {
|
||||
if (!settings.general.newLayoutDesigns()) return
|
||||
if (params.id || search.draftId) return
|
||||
if (!tabs.ready() || !sdk().directory) return
|
||||
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
||||
if (search.draftId || !tabs.ready()) return
|
||||
const directory = decode64(params.dir)
|
||||
if (!directory) return
|
||||
tabs.newDraft({ server: server.key, directory }, search.prompt)
|
||||
})
|
||||
|
||||
return (
|
||||
<SessionRouteErrorBoundary sessionID={params.id}>
|
||||
<SessionPage />
|
||||
</SessionRouteErrorBoundary>
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
function TargetServerRoute(props: ParentProps) {
|
||||
@@ -117,9 +85,7 @@ function TargetServerRoute(props: ParentProps) {
|
||||
})
|
||||
|
||||
return (
|
||||
// Owns the server-identity remount. Session changes must NOT remount this
|
||||
// subtree (SessionRouteErrorBoundary resets and createSessionLineage
|
||||
// re-resolves reactively instead); both rely on this key for server changes.
|
||||
// Owns the server-identity remount. Session changes must not remount this subtree.
|
||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
|
||||
@@ -134,35 +100,6 @@ const TargetSessionRoute = () => (
|
||||
</TargetServerRoute>
|
||||
)
|
||||
|
||||
function LegacyTargetSessionRoute() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
return (
|
||||
<TargetServerRoute>
|
||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
|
||||
<LegacyTargetSessionRedirect />
|
||||
</SessionRouteErrorBoundary>
|
||||
</TargetServerRoute>
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyTargetSessionRedirect() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const sync = useServerSync()
|
||||
const current = createSessionLineage(
|
||||
() => params.id,
|
||||
() => sync().session.lineage,
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const directory = current()?.session.location.directory
|
||||
if (!directory) return
|
||||
navigate(legacySessionHref(directory, params.id), { replace: true })
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||
// server via ServerKey, then provide the server-scoped shell for that server.
|
||||
function SelectedServerProviders(props: ParentProps) {
|
||||
@@ -175,17 +112,8 @@ function SelectedServerProviders(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
||||
return (
|
||||
<SelectedServerProviders>
|
||||
<LegacyServerScopedShell serverScoped={props.serverScoped}>{props.children}</LegacyServerScopedShell>
|
||||
</SelectedServerProviders>
|
||||
)
|
||||
}
|
||||
|
||||
function DraftRoute() {
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
return (
|
||||
<Show when={tabs.ready()}>
|
||||
@@ -194,14 +122,7 @@ function DraftRoute() {
|
||||
keyed
|
||||
fallback={<Navigate href="/" />}
|
||||
>
|
||||
{(draft) => (
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={<Navigate href={`/${base64Encode(draft.directory)}/session`} />}
|
||||
>
|
||||
<ResolvedDraftRoute draft={draft} />
|
||||
</Show>
|
||||
)}
|
||||
{(draft) => <ResolvedDraftRoute draft={draft} />}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
@@ -237,10 +158,6 @@ function UiI18nBridge(props: ParentProps) {
|
||||
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
||||
}
|
||||
|
||||
function LayoutCompatibility(props: ParentProps) {
|
||||
return <>{props.children}</>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCODE__?: {
|
||||
@@ -267,17 +184,11 @@ function QueryProvider(props: ParentProps) {
|
||||
}
|
||||
|
||||
function BodyDesignClass() {
|
||||
const settings = useSettings()
|
||||
|
||||
createRenderEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const enabled = settings.general.newLayoutDesigns()
|
||||
document.body.toggleAttribute("data-new-layout", enabled)
|
||||
document.body.classList.toggle("text-12-regular", !enabled)
|
||||
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
|
||||
document.body.classList.toggle("text-[13px]", enabled)
|
||||
document.body.classList.toggle("font-[440]", enabled)
|
||||
document.body.toggleAttribute("data-new-layout", true)
|
||||
document.body.classList.remove("text-12-regular")
|
||||
document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]")
|
||||
})
|
||||
|
||||
return null
|
||||
@@ -320,7 +231,6 @@ function DesktopCommands() {
|
||||
return null
|
||||
}
|
||||
|
||||
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
||||
type ServerScopedShellProps = ParentProps<{
|
||||
directory?: () => string | undefined
|
||||
serverScoped?: JSX.Element
|
||||
@@ -335,19 +245,11 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
|
||||
<LegacyLayout>{props.children}</LegacyLayout>
|
||||
</ServerScopedProviders>
|
||||
)
|
||||
}
|
||||
|
||||
function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
||||
function AppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
||||
return (
|
||||
<SelectedServerProviders>
|
||||
<ServerScopedProviders serverScoped={props.serverScoped}>
|
||||
<NewLayout>{props.children}</NewLayout>
|
||||
<Layout>{props.children}</Layout>
|
||||
</ServerScopedProviders>
|
||||
</SelectedServerProviders>
|
||||
)
|
||||
@@ -536,7 +438,7 @@ export function AppInterface(props: {
|
||||
startup?: Promise<void>
|
||||
serverScoped?: JSX.Element
|
||||
}) {
|
||||
// The visual new layout lives in the router root so it remains mounted across
|
||||
// The visual layout lives in the router root so it remains mounted across
|
||||
// route changes. Draft and session routes override only their server-bound data
|
||||
// providers beneath it.
|
||||
const ServerShell = (shellProps: ParentProps) => (
|
||||
@@ -557,26 +459,22 @@ export function AppInterface(props: {
|
||||
<GlobalProvider>
|
||||
<SettingsProvider>
|
||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
|
||||
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
|
||||
<Dynamic
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => (
|
||||
<TabsProvider>
|
||||
<PermissionProvider>
|
||||
<NotificationProvider>
|
||||
<ServerShell>
|
||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
||||
</Show>
|
||||
</ServerShell>
|
||||
</NotificationProvider>
|
||||
</PermissionProvider>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
<Routes serverScoped={props.serverScoped} />
|
||||
</Dynamic>
|
||||
</Show>
|
||||
<Dynamic
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => (
|
||||
<TabsProvider>
|
||||
<PermissionProvider>
|
||||
<NotificationProvider>
|
||||
<ServerShell>
|
||||
<AppLayout serverScoped={props.serverScoped}>{routerProps.children}</AppLayout>
|
||||
</ServerShell>
|
||||
</NotificationProvider>
|
||||
</PermissionProvider>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
<Routes />
|
||||
</Dynamic>
|
||||
</ConnectionGate>
|
||||
</SettingsProvider>
|
||||
</GlobalProvider>
|
||||
@@ -584,40 +482,20 @@ export function AppInterface(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function Routes(props: { serverScoped?: JSX.Element }) {
|
||||
const settings = useSettings()
|
||||
|
||||
function Routes() {
|
||||
return (
|
||||
<>
|
||||
<Route
|
||||
component={(routeProps) => (
|
||||
<LegacyServerLayout serverScoped={props.serverScoped}>{routeProps.children}</LegacyServerLayout>
|
||||
)}
|
||||
>
|
||||
<Show when={!settings.general.newLayoutDesigns()}>
|
||||
{
|
||||
<>
|
||||
<Route path="/" component={LegacyHome} />
|
||||
<Route path="/server/:serverKey/session/:id" component={LegacyTargetSessionRoute} />
|
||||
</>
|
||||
}
|
||||
</Show>
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Show when={settings.general.newLayoutDesigns()}>
|
||||
<Route path="/" component={NewHome} />
|
||||
<Route path="/:dir/session/:id" component={NewLayoutLegacySessionRedirect} />
|
||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||
</Show>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/:dir" component={DirectoryDraftRedirect} />
|
||||
<Route path="/:dir/session" component={DirectoryDraftRedirect} />
|
||||
<Route path="/:dir/session/:id" component={LegacySessionRedirect} />
|
||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NewLayoutLegacySessionRedirect() {
|
||||
function LegacySessionRedirect() {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const params = useParams<{ id: string }>()
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 187 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 163 KiB |
@@ -1,170 +0,0 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { For, Show } from "solid-js"
|
||||
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { createEditProjectModel } from "./edit-project"
|
||||
|
||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||
|
||||
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||
const language = useLanguage()
|
||||
const model = createEditProjectModel(props)
|
||||
|
||||
return (
|
||||
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
|
||||
<form onSubmit={model.submit} class="flex flex-col gap-6 p-6 pt-0">
|
||||
<div class="flex flex-col gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
type="text"
|
||||
label={language.t("dialog.project.edit.name")}
|
||||
placeholder={model.folderName()}
|
||||
value={model.store.name}
|
||||
onChange={(v) => model.setStore("name", v)}
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.icon")}</label>
|
||||
<div class="flex gap-3 items-start">
|
||||
<div
|
||||
class="relative"
|
||||
onMouseEnter={() => model.setStore("iconHover", true)}
|
||||
onMouseLeave={() => model.setStore("iconHover", false)}
|
||||
>
|
||||
<div
|
||||
class="relative size-16 rounded-md transition-colors cursor-pointer"
|
||||
classList={{
|
||||
"border-text-interactive-base bg-surface-info-base/20": model.store.dragOver,
|
||||
"border-border-base hover:border-border-strong": !model.store.dragOver,
|
||||
"overflow-hidden": !!model.store.iconOverride,
|
||||
}}
|
||||
onDrop={model.drop}
|
||||
onDragOver={model.dragOver}
|
||||
onDragLeave={model.dragLeave}
|
||||
onClick={model.iconClick}
|
||||
>
|
||||
<Show
|
||||
when={getProjectAvatarSource(props.project.id, {
|
||||
color: model.store.color,
|
||||
url: props.project.icon?.url,
|
||||
override: model.store.iconOverride,
|
||||
})}
|
||||
fallback={
|
||||
<div class="size-full flex items-center justify-center">
|
||||
<Avatar
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
{...getAvatarColors(model.store.color)}
|
||||
class="size-full text-[32px]"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(src) => (
|
||||
<img
|
||||
src={src()}
|
||||
alt={language.t("dialog.project.edit.icon.alt")}
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
||||
classList={{
|
||||
"opacity-100": model.store.iconHover && !model.store.iconOverride,
|
||||
"opacity-0": !(model.store.iconHover && !model.store.iconOverride),
|
||||
}}
|
||||
>
|
||||
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
||||
classList={{
|
||||
"opacity-100": model.store.iconHover && !!model.store.iconOverride,
|
||||
"opacity-0": !(model.store.iconHover && !!model.store.iconOverride),
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
id="icon-upload"
|
||||
ref={(el) => {
|
||||
model.setIconInput(el)
|
||||
}}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
onChange={model.inputChange}
|
||||
/>
|
||||
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!model.store.iconOverride}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
|
||||
<div class="flex gap-1.5">
|
||||
<For each={AVATAR_COLOR_KEYS}>
|
||||
{(color) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
||||
aria-pressed={model.store.color === color}
|
||||
classList={{
|
||||
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
||||
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
|
||||
model.store.color === color,
|
||||
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
||||
model.store.color !== color,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (model.store.color === color && !props.project.icon?.url) return
|
||||
model.setStore("color", model.store.color === color ? undefined : color)
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
{...getAvatarColors(color)}
|
||||
class="size-full rounded"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<TextField
|
||||
multiline
|
||||
label={language.t("dialog.project.edit.worktree.startup")}
|
||||
description={language.t("dialog.project.edit.worktree.startup.description")}
|
||||
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||
value={model.store.startup}
|
||||
onChange={(v) => model.setStore("startup", v)}
|
||||
spellcheck={false}
|
||||
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="large" onClick={model.close}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" size="large" disabled={!model.supported || model.save.isPending}>
|
||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { Component, createSignal, startTransition } from "solid-js"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { SettingsGeneral } from "./settings-general"
|
||||
import { SettingsKeybinds } from "./settings-keybinds"
|
||||
import { SettingsProviders } from "./settings-providers"
|
||||
import { SettingsModels } from "./settings-models"
|
||||
import { SettingsServers } from "./settings-servers"
|
||||
|
||||
export const DialogSettings: Component<{ defaultValue?: string }> = (props) => {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
||||
|
||||
const showProviders = () => {
|
||||
void dialog.show(() => <DialogSettings defaultValue="providers" />)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog size="x-large" transition>
|
||||
<Tabs
|
||||
orientation="vertical"
|
||||
variant="settings"
|
||||
value={tab()}
|
||||
onChange={(value) => void startTransition(() => setTab(value))}
|
||||
class="h-full settings-dialog"
|
||||
>
|
||||
<Tabs.List>
|
||||
<div class="flex flex-col justify-between h-full w-full gap-4">
|
||||
<div class="flex flex-col gap-3 w-full pt-3">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Tabs.SectionTitle>{language.t("settings.section.desktop")}</Tabs.SectionTitle>
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<Tabs.Trigger value="general">
|
||||
<Icon name="sliders" />
|
||||
{language.t("settings.tab.general")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="shortcuts">
|
||||
<Icon name="keyboard" />
|
||||
{language.t("settings.tab.shortcuts")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="servers">
|
||||
<Icon name="server" />
|
||||
{language.t("status.popover.tab.servers")}
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Tabs.SectionTitle>{language.t("settings.section.server")}</Tabs.SectionTitle>
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<Tabs.Trigger value="providers">
|
||||
<Icon name="providers" />
|
||||
{language.t("settings.providers.title")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="models">
|
||||
<Icon name="models" />
|
||||
{language.t("settings.models.title")}
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 pl-1 py-1 text-12-medium text-text-weak">
|
||||
<span>{language.t("app.name.desktop")}</span>
|
||||
<span class="text-11-regular">v{platform.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="general" class="no-scrollbar">
|
||||
<SettingsGeneral />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="shortcuts" class="no-scrollbar">
|
||||
<SettingsKeybinds />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="servers" class="no-scrollbar">
|
||||
<SettingsServers />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="providers" class="no-scrollbar">
|
||||
<SettingsProviders onBack={showProviders} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="models" class="no-scrollbar">
|
||||
<SettingsModels />
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
||||
import homeImage from "@/assets/help/home.png"
|
||||
import tabsImage from "@/assets/help/tabs.png"
|
||||
|
||||
// TODO: wire to changelog / seen-state when available
|
||||
const showPopover = () => true
|
||||
|
||||
// can remove this after the tabs rollout has been out for a while
|
||||
export function TabsInfoPopup() {
|
||||
const settings = useSettings()
|
||||
const platform = usePlatform()
|
||||
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
||||
const windows = () => platform.platform === "desktop" && platform.os === "windows"
|
||||
|
||||
return (
|
||||
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
||||
<Show when={settings.general.shouldDisplayTabsToast()}>
|
||||
<div
|
||||
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
||||
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss Tabs information"
|
||||
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
|
||||
onClick={settings.general.dismissTabsToast}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="white" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
|
||||
onClick={() => {
|
||||
settings.general.dismissTabsToast()
|
||||
setDrawerOpen(true)
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={introducingTabsVideo}
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
loop
|
||||
muted
|
||||
autoplay
|
||||
playsinline
|
||||
aria-hidden="true"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
/>
|
||||
<div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5">
|
||||
<p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
|
||||
Introducing Tabs
|
||||
</p>
|
||||
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
|
||||
Organize your work and active sessions with tabs
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<DrawerContent
|
||||
style={
|
||||
windows()
|
||||
? {
|
||||
inset: "0 0 0 auto",
|
||||
"max-height": "100vh",
|
||||
"max-width": "100vw",
|
||||
"border-radius": "0",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Show when={windows()}>
|
||||
<DrawerClose
|
||||
as={IconButtonV2}
|
||||
type="button"
|
||||
size="small"
|
||||
variant="neutral"
|
||||
aria-label="Close"
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
class="absolute top-[10px] left-[-36px]"
|
||||
/>
|
||||
</Show>
|
||||
<div
|
||||
class="flex w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted"
|
||||
classList={{
|
||||
"h-[40px] px-4": windows(),
|
||||
"h-[52px] p-4": !windows(),
|
||||
}}
|
||||
>
|
||||
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
|
||||
July 14
|
||||
</p>
|
||||
<Show when={!windows()}>
|
||||
<DrawerClose
|
||||
as={IconButtonV2}
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
aria-label="Close"
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
|
||||
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
|
||||
Introducing Tabs
|
||||
</p>
|
||||
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
||||
<p>OpenCode Desktop is now built around tabs.</p>
|
||||
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
||||
<p>
|
||||
Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when
|
||||
you're starting something new, and close it when you're done.
|
||||
</p>
|
||||
<p>
|
||||
Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something
|
||||
memorable if you plan to keep them around.
|
||||
</p>
|
||||
<p>
|
||||
You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.
|
||||
</p>
|
||||
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
||||
<p>When you reopen the app, your tabs are still open.</p>
|
||||
<p>
|
||||
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
|
||||
the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout
|
||||
will become permanent in a few weeks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@@ -1,793 +0,0 @@
|
||||
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useUpdaterAction } from "./updater-action"
|
||||
import {
|
||||
monoDefault,
|
||||
monoFontFamily,
|
||||
monoInput,
|
||||
sansDefault,
|
||||
sansFontFamily,
|
||||
sansInput,
|
||||
terminalDefault,
|
||||
terminalFontFamily,
|
||||
terminalInput,
|
||||
useSettings,
|
||||
} from "@/context/settings"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "./link"
|
||||
import { SettingsList } from "./settings-list"
|
||||
|
||||
let demoSoundState = {
|
||||
cleanup: undefined as (() => void) | undefined,
|
||||
timeout: undefined as NodeJS.Timeout | undefined,
|
||||
run: 0,
|
||||
}
|
||||
|
||||
type ThemeOption = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type ShellOption = {
|
||||
path: string
|
||||
name: string
|
||||
acceptable: boolean
|
||||
}
|
||||
|
||||
type ShellSelectOption = {
|
||||
id: string
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
||||
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
||||
const stopDemoSound = () => {
|
||||
demoSoundState.run += 1
|
||||
if (demoSoundState.cleanup) {
|
||||
demoSoundState.cleanup()
|
||||
}
|
||||
clearTimeout(demoSoundState.timeout)
|
||||
demoSoundState.cleanup = undefined
|
||||
}
|
||||
|
||||
const playDemoSound = (id: string | undefined) => {
|
||||
stopDemoSound()
|
||||
if (!id) return
|
||||
|
||||
const run = ++demoSoundState.run
|
||||
demoSoundState.timeout = setTimeout(() => {
|
||||
void playSoundById(id).then((cleanup) => {
|
||||
if (demoSoundState.run !== run) {
|
||||
cleanup?.()
|
||||
return
|
||||
}
|
||||
demoSoundState.cleanup = cleanup
|
||||
})
|
||||
}, 100)
|
||||
}
|
||||
|
||||
export const SettingsGeneral: Component = () => {
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
const permission = usePermission()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const params = useParams()
|
||||
const settings = useSettings()
|
||||
|
||||
const updater = useUpdaterAction()
|
||||
|
||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||
const dir = createMemo(() => decode64(params.dir))
|
||||
const accepting = createMemo(() => {
|
||||
const value = dir()
|
||||
if (!value) return false
|
||||
if (!params.id) return permission.isAutoAcceptingDirectory(value)
|
||||
return permission.isAutoAccepting(params.id, value)
|
||||
})
|
||||
|
||||
const toggleAccept = (checked: boolean) => {
|
||||
const value = dir()
|
||||
if (!value) return
|
||||
|
||||
if (!params.id) {
|
||||
if (permission.isAutoAcceptingDirectory(value) === checked) return
|
||||
permission.toggleAutoAcceptDirectory(value)
|
||||
return
|
||||
}
|
||||
|
||||
if (checked) {
|
||||
permission.enableAutoAccept(params.id, value)
|
||||
return
|
||||
}
|
||||
|
||||
permission.disableAutoAccept(params.id, value)
|
||||
}
|
||||
const desktop = createMemo(() => platform.platform === "desktop")
|
||||
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const serverSync = useServerSync()
|
||||
const serverSdk = useServerSDK()
|
||||
|
||||
const [shells] = createResource(
|
||||
async () => {
|
||||
// TODO: Restore executable shell discovery; V2 shell.list only lists shell processes.
|
||||
return [] as ShellOption[]
|
||||
},
|
||||
{ initialValue: [] as ShellOption[] },
|
||||
)
|
||||
|
||||
const [displayBackend, { refetch: refetchDisplayBackend }] = createResource(
|
||||
() => (linux() && platform.getDisplayBackend ? true : false),
|
||||
() => Promise.resolve(platform.getDisplayBackend?.() ?? null).catch(() => null as DisplayBackend | null),
|
||||
{ initialValue: null as DisplayBackend | null },
|
||||
)
|
||||
|
||||
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
||||
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
|
||||
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
|
||||
{ initialValue: false },
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
void theme.loadThemes()
|
||||
})
|
||||
|
||||
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
|
||||
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
|
||||
|
||||
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
||||
const list = shells.latest
|
||||
const current = serverSync().data.config.shell
|
||||
|
||||
const nameCounts = new Map<string, number>()
|
||||
for (const s of list) {
|
||||
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
|
||||
}
|
||||
|
||||
const options = [
|
||||
autoOption,
|
||||
...list.map((s) => {
|
||||
const ambiguousName = (nameCounts.get(s.name) || 0) > 1
|
||||
const text = ambiguousName ? s.path : s.name
|
||||
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
|
||||
return {
|
||||
id: s.path,
|
||||
// Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH.
|
||||
value: ambiguousName ? s.path : s.name,
|
||||
label,
|
||||
}
|
||||
}),
|
||||
]
|
||||
|
||||
if (current && !options.some((o) => o.value === current)) {
|
||||
options.push({ id: current, value: current, label: current })
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
const onDisplayBackendChange = (checked: boolean) => {
|
||||
const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto")
|
||||
if (!update) return
|
||||
void update.finally(() => {
|
||||
void refetchDisplayBackend()
|
||||
})
|
||||
}
|
||||
|
||||
const onPinchZoomChange = (checked: boolean) => {
|
||||
setPinchZoom(checked)
|
||||
const update = platform.setPinchZoomEnabled?.(checked)
|
||||
if (!update) return
|
||||
void update.catch(() => setPinchZoom(!checked))
|
||||
}
|
||||
|
||||
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
||||
{ value: "system", label: language.t("theme.scheme.system") },
|
||||
{ value: "light", label: language.t("theme.scheme.light") },
|
||||
{ value: "dark", label: language.t("theme.scheme.dark") },
|
||||
])
|
||||
|
||||
const languageOptions = createMemo(() =>
|
||||
language.locales.map((locale) => ({
|
||||
value: locale,
|
||||
label: language.label(locale),
|
||||
})),
|
||||
)
|
||||
|
||||
const noneSound = { id: "none", label: "sound.option.none" } as const
|
||||
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
||||
const mono = () => monoInput(settings.appearance.font())
|
||||
const sans = () => sansInput(settings.appearance.uiFont())
|
||||
const terminal = () => terminalInput(settings.appearance.terminalFont())
|
||||
|
||||
const soundSelectProps = (
|
||||
enabled: () => boolean,
|
||||
current: () => string,
|
||||
setEnabled: (value: boolean) => void,
|
||||
set: (id: string) => void,
|
||||
) => ({
|
||||
options: soundOptions,
|
||||
current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound,
|
||||
value: (o: (typeof soundOptions)[number]) => o.id,
|
||||
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
|
||||
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
|
||||
if (!option) return
|
||||
playDemoSound(option.id === "none" ? undefined : option.id)
|
||||
},
|
||||
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
|
||||
if (!option) return
|
||||
if (option.id === "none") {
|
||||
setEnabled(false)
|
||||
stopDemoSound()
|
||||
return
|
||||
}
|
||||
setEnabled(true)
|
||||
set(option.id)
|
||||
playDemoSound(option.id)
|
||||
},
|
||||
variant: "secondary" as const,
|
||||
size: "small" as const,
|
||||
triggerVariant: "settings" as const,
|
||||
})
|
||||
|
||||
const InterfaceSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={
|
||||
<span class="flex items-center gap-2">
|
||||
{language.t("settings.general.row.newInterface.title")}
|
||||
<Tag variant="accent">{language.t("settings.general.row.newInterface.badge")}</Tag>
|
||||
</span>
|
||||
}
|
||||
description={language.t("settings.general.row.newInterface.description")}
|
||||
>
|
||||
<div data-action="settings-new-layout-designs">
|
||||
<Switch
|
||||
checked={settings.general.newLayoutDesigns()}
|
||||
onChange={(checked) => {
|
||||
settings.general.setNewLayoutDesigns(checked)
|
||||
if (!checked) return
|
||||
void import("@/components/settings-v2").then((module) => {
|
||||
void dialog.show(() => <module.DialogSettings />)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
const InterfaceNoticeSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
||||
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
||||
>
|
||||
<Button size="small" variant="ghost" onClick={settings.general.dismissNewInterfaceNotice}>
|
||||
{language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
const GeneralSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.language.title")}
|
||||
description={language.t("settings.general.row.language.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-language"
|
||||
options={languageOptions()}
|
||||
current={languageOptions().find((o) => o.value === language.locale())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => option && language.setLocale(option.value)}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("command.permissions.autoaccept.enable")}
|
||||
description={language.t("toast.permissions.autoaccept.on.description")}
|
||||
>
|
||||
<div data-action="settings-auto-accept-permissions">
|
||||
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.shell.title")}
|
||||
description={language.t("settings.general.row.shell.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-shell"
|
||||
disabled
|
||||
options={shellOptions()}
|
||||
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
|
||||
value={(o) => o.id}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
if (option.value === currentShell()) return
|
||||
// TODO: Restore config writes when the V2 client exposes a config API.
|
||||
// void serverSync().updateConfig({ shell: option.value })
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
triggerStyle={{ "min-width": "180px" }}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||
description={language.t("settings.general.row.reasoningSummaries.description")}
|
||||
>
|
||||
<div data-action="settings-feed-reasoning-summaries">
|
||||
<Switch
|
||||
checked={settings.general.showReasoningSummaries()}
|
||||
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-shell-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.shellToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.editToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.editToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-edit-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.editToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
const AdvancedSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.advanced")}</h3>
|
||||
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showFileTree.title")}
|
||||
description={language.t("settings.general.row.showFileTree.description")}
|
||||
>
|
||||
<div data-action="settings-show-file-tree">
|
||||
<Switch
|
||||
checked={settings.general.showFileTree()}
|
||||
onChange={(checked) => settings.general.setShowFileTree(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showNavigation.title")}
|
||||
description={language.t("settings.general.row.showNavigation.description")}
|
||||
>
|
||||
<div data-action="settings-show-navigation">
|
||||
<Switch
|
||||
checked={settings.general.showNavigation()}
|
||||
onChange={(checked) => settings.general.setShowNavigation(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showSearch.title")}
|
||||
description={language.t("settings.general.row.showSearch.description")}
|
||||
>
|
||||
<div data-action="settings-show-search">
|
||||
<Switch
|
||||
checked={settings.general.showSearch()}
|
||||
onChange={(checked) => settings.general.setShowSearch(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showStatus.title")}
|
||||
description={language.t("settings.general.row.showStatus.description")}
|
||||
>
|
||||
<div data-action="settings-show-status">
|
||||
<Switch
|
||||
checked={settings.general.showStatus()}
|
||||
onChange={(checked) => settings.general.setShowStatus(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showCustomAgents.title")}
|
||||
description={language.t("settings.general.row.showCustomAgents.description")}
|
||||
>
|
||||
<div data-action="settings-show-custom-agents">
|
||||
<Switch
|
||||
checked={settings.general.showCustomAgents()}
|
||||
onChange={(checked) => settings.general.setShowCustomAgents(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
const AppearanceSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
|
||||
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.colorScheme.title")}
|
||||
description={language.t("settings.general.row.colorScheme.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-color-scheme"
|
||||
options={colorSchemeOptions()}
|
||||
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => option && theme.setColorScheme(option.value)}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
triggerStyle={{ "min-width": "220px" }}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.theme.title")}
|
||||
description={
|
||||
<>
|
||||
{language.t("settings.general.row.theme.description")}{" "}
|
||||
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-theme"
|
||||
options={themeOptions()}
|
||||
current={themeOptions().find((o) => o.id === theme.themeId())}
|
||||
value={(o) => o.id}
|
||||
label={(o) => o.name}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
theme.setTheme(option.id)
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.uiFont.title")}
|
||||
description={language.t("settings.general.row.uiFont.description")}
|
||||
>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextField
|
||||
data-action="settings-ui-font"
|
||||
label={language.t("settings.general.row.uiFont.title")}
|
||||
hideLabel
|
||||
type="text"
|
||||
value={sans()}
|
||||
onChange={(value) => settings.appearance.setUIFont(value)}
|
||||
placeholder={sansDefault}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
class="text-12-regular"
|
||||
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.font.title")}
|
||||
description={language.t("settings.general.row.font.description")}
|
||||
>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextField
|
||||
data-action="settings-code-font"
|
||||
label={language.t("settings.general.row.font.title")}
|
||||
hideLabel
|
||||
type="text"
|
||||
value={mono()}
|
||||
onChange={(value) => settings.appearance.setFont(value)}
|
||||
placeholder={monoDefault}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
class="text-12-regular"
|
||||
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.terminalFont.title")}
|
||||
description={language.t("settings.general.row.terminalFont.description")}
|
||||
>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextField
|
||||
data-action="settings-terminal-font"
|
||||
label={language.t("settings.general.row.terminalFont.title")}
|
||||
hideLabel
|
||||
type="text"
|
||||
value={terminal()}
|
||||
onChange={(value) => settings.appearance.setTerminalFont(value)}
|
||||
placeholder={terminalDefault}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
class="text-12-regular"
|
||||
style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
const NotificationsSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
|
||||
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.notifications.agent.title")}
|
||||
description={language.t("settings.general.notifications.agent.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-agent">
|
||||
<Switch
|
||||
checked={settings.notifications.agent()}
|
||||
onChange={(checked) => settings.notifications.setAgent(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.notifications.permissions.title")}
|
||||
description={language.t("settings.general.notifications.permissions.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-permissions">
|
||||
<Switch
|
||||
checked={settings.notifications.permissions()}
|
||||
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.notifications.errors.title")}
|
||||
description={language.t("settings.general.notifications.errors.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-errors">
|
||||
<Switch
|
||||
checked={settings.notifications.errors()}
|
||||
onChange={(checked) => settings.notifications.setErrors(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
const SoundsSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
|
||||
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.sounds.agent.title")}
|
||||
description={language.t("settings.general.sounds.agent.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-sounds-agent"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.agentEnabled(),
|
||||
() => settings.sounds.agent(),
|
||||
(value) => settings.sounds.setAgentEnabled(value),
|
||||
(id) => settings.sounds.setAgent(id),
|
||||
)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.sounds.permissions.title")}
|
||||
description={language.t("settings.general.sounds.permissions.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-sounds-permissions"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.permissionsEnabled(),
|
||||
() => settings.sounds.permissions(),
|
||||
(value) => settings.sounds.setPermissionsEnabled(value),
|
||||
(id) => settings.sounds.setPermissions(id),
|
||||
)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.sounds.errors.title")}
|
||||
description={language.t("settings.general.sounds.errors.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-sounds-errors"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.errorsEnabled(),
|
||||
() => settings.sounds.errors(),
|
||||
(value) => settings.sounds.setErrorsEnabled(value),
|
||||
(id) => settings.sounds.setErrors(id),
|
||||
)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
const UpdatesSection = () => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
|
||||
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.releaseNotes.title")}
|
||||
description={language.t("settings.general.row.releaseNotes.description")}
|
||||
>
|
||||
<div data-action="settings-release-notes">
|
||||
<Switch
|
||||
checked={settings.general.releaseNotes()}
|
||||
onChange={(checked) => settings.general.setReleaseNotes(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.updates.row.check.title")}
|
||||
description={language.t("settings.updates.row.check.description")}
|
||||
>
|
||||
<Button size="small" variant="secondary" disabled={!updater.action().run} onClick={updater.run}>
|
||||
{language.t(updater.action().label)}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
const DisplaySection = () => (
|
||||
<Show when={desktop()}>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
|
||||
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.pinchZoom.title")}
|
||||
description={language.t("settings.general.row.pinchZoom.description")}
|
||||
>
|
||||
<div data-action="settings-pinch-zoom">
|
||||
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<Show when={linux()}>
|
||||
<SettingsRow
|
||||
title={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("settings.general.row.wayland.title")}</span>
|
||||
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
|
||||
<span class="text-text-weak">
|
||||
<Icon name="help" size="small" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
description={language.t("settings.general.row.wayland.description")}
|
||||
>
|
||||
<div data-action="settings-wayland">
|
||||
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-1 pt-6 pb-8">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-8 w-full">
|
||||
<Show when={settings.general.layoutTransitionAvailable()}>
|
||||
<InterfaceSection />
|
||||
</Show>
|
||||
|
||||
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
||||
<InterfaceNoticeSection />
|
||||
</Show>
|
||||
|
||||
<GeneralSection />
|
||||
|
||||
<AppearanceSection />
|
||||
|
||||
<NotificationsSection />
|
||||
|
||||
<SoundsSection />
|
||||
|
||||
<UpdatesSection />
|
||||
|
||||
<DisplaySection />
|
||||
|
||||
<Show when={desktop()}>
|
||||
<AdvancedSection />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SettingsRowProps {
|
||||
title: string | JSX.Element
|
||||
description: string | JSX.Element
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
const SettingsRow: Component<SettingsRowProps> = (props) => {
|
||||
return (
|
||||
<div class="flex flex-wrap items-center gap-4 py-3 border-b border-border-weak-base last:border-none sm:flex-nowrap">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span class="text-14-medium text-text-strong">{props.title}</span>
|
||||
<span class="text-12-regular text-text-weak">{props.description}</span>
|
||||
</div>
|
||||
<div class="flex w-full justify-end sm:w-auto sm:shrink-0">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useModels } from "@/context/models"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { SettingsList } from "./settings-list"
|
||||
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
|
||||
|
||||
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
|
||||
|
||||
const ListLoadingState: Component<{ label: string }> = (props) => {
|
||||
return (
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<span class="text-14-regular text-text-weak">{props.label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ListEmptyState: Component<{ message: string; filter: string }> = (props) => {
|
||||
return (
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<span class="text-14-regular text-text-weak">{props.message}</span>
|
||||
<Show when={props.filter}>
|
||||
<span class="text-14-regular text-text-strong mt-1">"{props.filter}"</span>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsModels: Component = () => {
|
||||
return (
|
||||
<SettingsServerScope>
|
||||
<SettingsModelsContent />
|
||||
</SettingsServerScope>
|
||||
)
|
||||
}
|
||||
|
||||
const SettingsModelsContent: Component = () => {
|
||||
const language = useLanguage()
|
||||
const models = useModels()
|
||||
|
||||
const list = useFilteredList<ModelItem>({
|
||||
items: (_filter) => models.list(),
|
||||
key: (x) => `${x.provider.id}:${x.id}`,
|
||||
filterKeys: ["provider.name", "name", "id"],
|
||||
sortBy: (a, b) => a.name.localeCompare(b.name),
|
||||
groupBy: (x) => x.provider.id,
|
||||
sortGroupsBy: (a, b) => {
|
||||
const aIndex = popularProviders.indexOf(a.category)
|
||||
const bIndex = popularProviders.indexOf(b.category)
|
||||
const aPopular = aIndex >= 0
|
||||
const bPopular = bIndex >= 0
|
||||
|
||||
if (aPopular && !bPopular) return -1
|
||||
if (!aPopular && bPopular) return 1
|
||||
if (aPopular && bPopular) return aIndex - bIndex
|
||||
|
||||
const aName = a.items[0].provider.name
|
||||
const bName = b.items[0].provider.name
|
||||
return aName.localeCompare(bName)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
|
||||
<SettingsServerPicker />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
||||
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
||||
<TextField
|
||||
variant="ghost"
|
||||
type="text"
|
||||
value={list.filter()}
|
||||
onChange={list.onInput}
|
||||
placeholder={language.t("dialog.model.search.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Show when={list.filter()}>
|
||||
<IconButton icon="circle-x" variant="ghost" onClick={list.clear} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-8 max-w-[720px]">
|
||||
<Show
|
||||
when={!list.grouped.loading}
|
||||
fallback={
|
||||
<ListLoadingState label={`${language.t("common.loading")}${language.t("common.loading.ellipsis")}`} />
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={list.flat().length > 0}
|
||||
fallback={<ListEmptyState message={language.t("dialog.model.empty")} filter={list.filter()} />}
|
||||
>
|
||||
<For each={list.grouped.latest}>
|
||||
{(group) => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2 pb-2">
|
||||
<ProviderIcon id={group.category} class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
|
||||
</div>
|
||||
<SettingsList>
|
||||
<For each={group.items}>
|
||||
{(item) => {
|
||||
const key = { providerID: item.provider.id, modelID: item.id }
|
||||
return (
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
<div class="min-w-0">
|
||||
<span class="text-14-regular text-text-strong truncate block">{item.name}</span>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<Switch
|
||||
checked={models.visible(key)}
|
||||
onChange={(checked) => {
|
||||
models.setVisibility(key, checked)
|
||||
}}
|
||||
hideLabel
|
||||
>
|
||||
{item.name}
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
|
||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||
import { SettingsList } from "./settings-list"
|
||||
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
|
||||
|
||||
type ProviderSource = "env" | "api" | "config" | "custom"
|
||||
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
|
||||
|
||||
const PROVIDER_NOTES = [
|
||||
{ match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" },
|
||||
{ match: (id: string) => id === "opencode-go", key: "dialog.provider.opencodeGo.tagline" },
|
||||
{ match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" },
|
||||
{ match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" },
|
||||
{ match: (id: string) => id === "openai", key: "dialog.provider.openai.note" },
|
||||
{ match: (id: string) => id === "google", key: "dialog.provider.google.note" },
|
||||
{ match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" },
|
||||
{ match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" },
|
||||
] as const
|
||||
|
||||
export const SettingsProviders: Component<{ onBack?: () => void }> = (props) => {
|
||||
return (
|
||||
<SettingsServerScope>
|
||||
<SettingsProvidersContent onBack={props.onBack} />
|
||||
</SettingsServerScope>
|
||||
)
|
||||
}
|
||||
|
||||
const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const providers = useProviders(() => undefined)
|
||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||
|
||||
const connect = (provider?: string) => {
|
||||
providerConnect.select(provider)
|
||||
void dialog.show(() => <DialogConnectProvider controller={providerConnect} />)
|
||||
}
|
||||
|
||||
const connected = createMemo(() => {
|
||||
return providers
|
||||
.connected()
|
||||
.filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
|
||||
})
|
||||
|
||||
const popular = createMemo(() => {
|
||||
const connectedIDs = new Set(connected().map((p) => p.id))
|
||||
const items = providers
|
||||
.popular()
|
||||
.filter((p) => !connectedIDs.has(p.id))
|
||||
.slice()
|
||||
items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id))
|
||||
return items
|
||||
})
|
||||
|
||||
const source = (item: ProviderItem): ProviderSource | undefined => {
|
||||
if (!("source" in item)) return
|
||||
const value = item.source
|
||||
if (value === "env" || value === "api" || value === "config" || value === "custom") return value
|
||||
return
|
||||
}
|
||||
|
||||
const type = (item: ProviderItem) => {
|
||||
const current = source(item)
|
||||
if (current === "env") return language.t("settings.providers.tag.environment")
|
||||
if (current === "api") return language.t("provider.connect.method.apiKey")
|
||||
if (current === "config") {
|
||||
if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom")
|
||||
return language.t("settings.providers.tag.config")
|
||||
}
|
||||
if (current === "custom") return language.t("settings.providers.tag.custom")
|
||||
return language.t("settings.providers.tag.other")
|
||||
}
|
||||
|
||||
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
|
||||
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
const isConfigCustom = (providerID: string) => {
|
||||
const provider = serverSync().data.config.provider?.[providerID]
|
||||
if (!provider) return false
|
||||
if (provider.npm !== "@ai-sdk/openai-compatible") return false
|
||||
if (!provider.models || Object.keys(provider.models).length === 0) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const disableProvider = async (providerID: string, name: string) => {
|
||||
return
|
||||
const before = serverSync().data.config.disabled_providers ?? []
|
||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||
serverSync().set("config", "disabled_providers", next)
|
||||
|
||||
await serverSync()
|
||||
.updateConfig({ disabled_providers: next })
|
||||
.then(() => {
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
|
||||
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
serverSync().set("config", "disabled_providers", before)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
}
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
await serverSDK()
|
||||
.api.integration.get({ integrationID: providerID })
|
||||
.then(async (integration) => {
|
||||
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
|
||||
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
|
||||
await Promise.all(
|
||||
credentials.map((credential) => serverSDK().api.credential.remove({ credentialID: credential.id })),
|
||||
)
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
|
||||
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex items-center justify-between gap-4 pt-6 pb-8 max-w-[720px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
|
||||
<SettingsServerPicker />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-8 max-w-[720px]">
|
||||
<div class="flex flex-col gap-1" data-component="connected-providers-section">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.connected")}</h3>
|
||||
<SettingsList>
|
||||
<Show
|
||||
when={connected().length > 0}
|
||||
fallback={
|
||||
<div class="py-4 text-14-regular text-text-weak">
|
||||
{language.t("settings.providers.connected.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={connected()}>
|
||||
{(item) => (
|
||||
<div class="group flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong truncate">{item.name}</span>
|
||||
<Tag>{type(item)}</Tag>
|
||||
</div>
|
||||
<Show
|
||||
when={canDisconnect(item)}
|
||||
fallback={
|
||||
<span class="text-14-regular text-text-base opacity-0 group-hover:opacity-100 transition-opacity duration-200 pr-3 cursor-default">
|
||||
{language.t("settings.providers.connected.environmentDescription")}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Button size="large" variant="ghost" onClick={() => void disconnect(item.id, item.name)}>
|
||||
{language.t("common.disconnect")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</SettingsList>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.popular")}</h3>
|
||||
<SettingsList>
|
||||
<For each={popular()}>
|
||||
{(item) => (
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong">{item.name}</span>
|
||||
<Show when={item.id === "opencode"}>
|
||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
<Show when={item.id === "opencode-go"}>
|
||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={note(item.id)}>
|
||||
{(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>}
|
||||
</Show>
|
||||
</div>
|
||||
<Button size="large" variant="secondary" icon="plus-small" onClick={() => connect(item.id)}>
|
||||
{language.t("common.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={false}>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
|
||||
data-component="custom-provider-section"
|
||||
>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
|
||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||
</div>
|
||||
<span class="text-12-regular text-text-weak pl-8">
|
||||
{language.t("settings.providers.custom.description")}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
||||
}}
|
||||
>
|
||||
{language.t("common.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</SettingsList>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="px-0 py-0 mt-5 text-14-medium text-text-interactive-base text-left justify-start hover:bg-transparent active:bg-transparent"
|
||||
onClick={() => connect()}
|
||||
>
|
||||
{language.t("dialog.provider.viewAll")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { createMemo, For, type ParentProps, Show } from "solid-js"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider } from "@/context/server-sync"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useSettings } from "@/context/settings"
|
||||
|
||||
export function SettingsServerScope(props: ParentProps) {
|
||||
const global = useGlobal()
|
||||
const settings = useSettings()
|
||||
|
||||
return (
|
||||
<Show when={settings.general.newLayoutDesigns()} fallback={props.children}>
|
||||
<Show when={global.settings.server.selected()}>
|
||||
{(server) => <SettingsServerDataProviders server={server()}>{props.children}</SettingsServerDataProviders>}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnection.Any }>) {
|
||||
const global = useGlobal()
|
||||
const serverCtx = () => global.ensureServerCtx(props.server)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={serverCtx().queryClient}>
|
||||
<ServerSDKProvider server={() => props.server}>
|
||||
<ServerSyncProvider>
|
||||
<ModelsProvider>{props.children}</ModelsProvider>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsServerPicker() {
|
||||
const global = useGlobal()
|
||||
const settings = useSettings()
|
||||
const selected = createMemo(() =>
|
||||
settings.general.newLayoutDesigns() ? global.settings.server.selected() : undefined,
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={selected()}>
|
||||
{(conn) => (
|
||||
<DropdownMenu gutter={4} placement="bottom-end">
|
||||
<DropdownMenu.Trigger
|
||||
as={Button}
|
||||
variant="secondary"
|
||||
size="large"
|
||||
class="h-8 max-w-[260px] gap-2 px-2 py-1.5 data-[expanded]:bg-surface-base-active"
|
||||
>
|
||||
<ServerHealthIndicator health={global.servers.health[ServerConnection.key(conn())]} />
|
||||
<ServerRow
|
||||
conn={conn()}
|
||||
status={global.servers.health[ServerConnection.key(conn())]}
|
||||
class="flex items-center gap-2 min-w-0 flex-1"
|
||||
nameClass="text-14-regular text-text-base truncate"
|
||||
versionClass="hidden"
|
||||
/>
|
||||
<Icon name="chevron-down" size="small" class="text-icon-weak shrink-0" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="w-[320px] mt-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-2 [&_[data-slot=dropdown-menu-radio-item]]:pr-2">
|
||||
<DropdownMenu.RadioGroup
|
||||
value={global.settings.server.key}
|
||||
onChange={(key) => {
|
||||
if (typeof key === "string") global.settings.server.set(ServerConnection.Key.make(key))
|
||||
}}
|
||||
>
|
||||
<For each={global.servers.list()}>
|
||||
{(item) => {
|
||||
const key = ServerConnection.key(item)
|
||||
const blocked = () => global.servers.health[key]?.healthy === false
|
||||
return (
|
||||
<DropdownMenu.RadioItem value={key} disabled={blocked()}>
|
||||
<ServerHealthIndicator health={global.servers.health[key]} />
|
||||
<ServerRow
|
||||
conn={item}
|
||||
dimmed={blocked()}
|
||||
status={global.servers.health[key]}
|
||||
class="flex items-center gap-2 min-w-0 flex-1"
|
||||
nameClass="text-14-regular text-text-base truncate"
|
||||
versionClass="text-12-regular text-text-weak truncate"
|
||||
/>
|
||||
<DropdownMenu.ItemIndicator>
|
||||
<Icon name="check-small" size="small" class="text-icon-weak" />
|
||||
</DropdownMenu.ItemIndicator>
|
||||
</DropdownMenu.RadioItem>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Show, type Component } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnectionForm, ServerConnectionList, useServerManagementController } from "./dialog-select-server"
|
||||
|
||||
export const SettingsServers: Component = () => {
|
||||
const language = useLanguage()
|
||||
const controller = useServerManagementController()
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="flex flex-col flex-1 min-h-0 max-w-[720px]">
|
||||
<Show
|
||||
when={controller.isFormMode()}
|
||||
fallback={
|
||||
<>
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-1 pt-6 pb-8">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("status.popover.tab.servers")}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<ServerConnectionList controller={controller} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-1 min-h-0 flex-col gap-4 pt-6">
|
||||
<div class="text-16-medium text-text-strong">{controller.formTitle()}</div>
|
||||
<ServerConnectionForm controller={controller} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
@@ -27,7 +26,6 @@ import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "../link"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||
import "./settings-v2.css"
|
||||
|
||||
let demoSoundState = {
|
||||
@@ -87,7 +85,6 @@ export const SettingsGeneralV2: Component<{
|
||||
const language = useLanguage()
|
||||
const permission = usePermission()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const settings = useSettings()
|
||||
const serverSync = useServerSync()
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
@@ -225,31 +222,6 @@ export const SettingsGeneralV2: Component<{
|
||||
},
|
||||
})
|
||||
|
||||
const InterfaceSection = () => (
|
||||
<LayoutTransitionToggle
|
||||
title={language.t("settings.general.row.newInterface.title")}
|
||||
badge={language.t("settings.general.row.newInterface.badge")}
|
||||
description={language.t("settings.general.row.newInterface.description")}
|
||||
checked={settings.general.newLayoutDesigns()}
|
||||
onChange={(checked) => {
|
||||
settings.general.setNewLayoutDesigns(checked)
|
||||
if (checked) return
|
||||
void import("@/components/dialog-settings").then((module) => {
|
||||
void dialog.show(() => <module.DialogSettings />)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const InterfaceNoticeSection = () => (
|
||||
<LayoutRetirementNotice
|
||||
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
||||
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
||||
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
||||
onDismiss={settings.general.dismissNewInterfaceNotice}
|
||||
/>
|
||||
)
|
||||
|
||||
const GeneralSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<SettingsListV2>
|
||||
@@ -689,14 +661,6 @@ export const SettingsGeneralV2: Component<{
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body">
|
||||
<Show when={settings.general.layoutTransitionAvailable()}>
|
||||
<InterfaceSection />
|
||||
</Show>
|
||||
|
||||
<Show when={settings.general.newInterfaceNoticeVisible()}>
|
||||
<InterfaceNoticeSection />
|
||||
</Show>
|
||||
|
||||
<GeneralSection />
|
||||
|
||||
<AppearanceSection />
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import { Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||
|
||||
const copy = {
|
||||
title: "New layout",
|
||||
badge: "New",
|
||||
description: "Use the new tabs and home layout. Switch between layouts for a limited time.",
|
||||
noticeTitle: "You're now using new layout",
|
||||
noticeDescription: "The previous layout is no longer available",
|
||||
dismiss: "Dismiss",
|
||||
}
|
||||
|
||||
function Frame(props) {
|
||||
return <div class="w-[640px] max-w-full">{props.children}</div>
|
||||
}
|
||||
|
||||
function ToggleExample(props) {
|
||||
const [state, setState] = createStore({ checked: props.checked })
|
||||
return (
|
||||
<Frame>
|
||||
<LayoutTransitionToggle
|
||||
title={copy.title}
|
||||
badge={copy.badge}
|
||||
description={copy.description}
|
||||
checked={state.checked}
|
||||
onChange={(checked) => setState("checked", checked)}
|
||||
/>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function NoticeExample() {
|
||||
const [state, setState] = createStore({ dismissed: false })
|
||||
return (
|
||||
<Frame>
|
||||
<Show when={!state.dismissed} fallback={<span class="text-v2-text-text-muted">Notice dismissed</span>}>
|
||||
<LayoutRetirementNotice
|
||||
title={copy.noticeTitle}
|
||||
description={copy.noticeDescription}
|
||||
dismiss={copy.dismiss}
|
||||
onDismiss={() => setState("dismissed", true)}
|
||||
/>
|
||||
</Show>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "App/Settings/Layout transition",
|
||||
id: "app-settings-layout-transition",
|
||||
component: LayoutTransitionToggle,
|
||||
}
|
||||
|
||||
export const NewLayoutEnabled = {
|
||||
render: () => <ToggleExample checked />,
|
||||
}
|
||||
|
||||
export const PreviousLayoutEnabled = {
|
||||
render: () => <ToggleExample checked={false} />,
|
||||
}
|
||||
|
||||
export const PreviousLayoutRetired = {
|
||||
render: () => <NoticeExample />,
|
||||
}
|
||||
|
||||
export const AllStates = {
|
||||
render: () => (
|
||||
<div class="flex flex-col gap-8">
|
||||
<ToggleExample checked />
|
||||
<ToggleExample checked={false} />
|
||||
<NoticeExample />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
|
||||
export function LayoutTransitionToggle(props: {
|
||||
title: string
|
||||
badge: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div class="settings-v2-section">
|
||||
<div class="settings-v2-interface-feature">
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={
|
||||
<span class="flex items-center gap-2">
|
||||
{props.title}
|
||||
<Tag variant="accent">{props.badge}</Tag>
|
||||
</span>
|
||||
}
|
||||
description={props.description}
|
||||
>
|
||||
<div data-action="settings-new-layout-designs">
|
||||
<Switch checked={props.checked} onChange={props.onChange} />
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LayoutRetirementNotice(props: {
|
||||
title: string
|
||||
description: string
|
||||
dismiss: string
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
return (
|
||||
<div class="settings-v2-section">
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2 title={props.title} description={props.description}>
|
||||
<ButtonV2 size="small" variant="ghost-muted" onClick={props.onDismiss}>
|
||||
{props.dismiss}
|
||||
</ButtonV2>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -123,9 +123,7 @@ export const SettingsProvidersV2: Component<{
|
||||
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
|
||||
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
|
||||
await Promise.all(
|
||||
credentials.map((credential) =>
|
||||
serverSdk().api.credential.remove({ credentialID: credential.id, location }),
|
||||
),
|
||||
credentials.map((credential) => serverSdk().api.credential.remove({ credentialID: credential.id, location })),
|
||||
)
|
||||
showToast({
|
||||
variant: "success",
|
||||
|
||||
@@ -5,7 +5,16 @@ import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { type Accessor, createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
import {
|
||||
type Accessor,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
For,
|
||||
type JSXElement,
|
||||
onCleanup,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -291,7 +300,10 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
const [pluginList] = createResource(
|
||||
() => (props.shown() ? sdk().directory : undefined),
|
||||
(directory) => sdk().api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||
(directory) =>
|
||||
sdk()
|
||||
.api.plugin.list({ location: { directory } })
|
||||
.then((result) => result.data),
|
||||
)
|
||||
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
|
||||
@@ -190,10 +190,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
.find((item) => ServerConnection.key(item) === (route.server ?? server.key))
|
||||
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
|
||||
},
|
||||
({ route, sdk }) =>
|
||||
sdk.api.session
|
||||
.get({ sessionID: route.sessionId })
|
||||
.catch(() => {}),
|
||||
({ route, sdk }) => sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}),
|
||||
)
|
||||
|
||||
const matchRoute = (route: LayoutRoute) => {
|
||||
|
||||
@@ -124,9 +124,7 @@ export const createDirSyncContext = (
|
||||
const [store, setStore] = current()
|
||||
setStore("limit", (value) => value + count)
|
||||
const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
|
||||
const sessions = response.data
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
.slice(0, store.limit)
|
||||
const sessions = response.data.sort((a, b) => cmp(a.id, b.id)).slice(0, store.limit)
|
||||
sessions.forEach(serverSync.session.remember)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
},
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import type {
|
||||
Config,
|
||||
Path,
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
} from "@/types"
|
||||
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
|
||||
import type {
|
||||
AgentListInput,
|
||||
AgentListOutput,
|
||||
@@ -31,12 +26,7 @@ import { batch } from "solid-js"
|
||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import type { ServerSession } from "../server-session"
|
||||
import {
|
||||
cmp,
|
||||
normalizeAgentList,
|
||||
normalizeProjectInfo,
|
||||
normalizeProviderList,
|
||||
} from "./utils"
|
||||
import { cmp, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
||||
@@ -148,10 +138,7 @@ export async function bootstrapGlobal(input: {
|
||||
}) {
|
||||
const slow = [
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadProvidersQuery(input.scope, null, input.serverAPI),
|
||||
),
|
||||
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverAPI)),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
|
||||
() =>
|
||||
input.queryClient
|
||||
@@ -211,11 +198,7 @@ function warmSessions(input: {
|
||||
).then(() => undefined)
|
||||
}
|
||||
|
||||
export const loadProvidersQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string | null,
|
||||
sdk: CatalogApi,
|
||||
) =>
|
||||
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: CatalogApi) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "providers"],
|
||||
queryFn: () =>
|
||||
@@ -242,28 +225,16 @@ type ReferenceListApi = {
|
||||
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
|
||||
}
|
||||
|
||||
export const loadAgentsQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
sdk: AgentListApi,
|
||||
) =>
|
||||
export const loadAgentsQuery = (scope: ServerScope, directory: string, sdk: AgentListApi) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "agents"],
|
||||
queryFn: () =>
|
||||
retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
|
||||
queryFn: () => retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
|
||||
})
|
||||
|
||||
export const loadCommands = (
|
||||
directory: string,
|
||||
api: CommandListApi,
|
||||
): Promise<CommandInfo[]> =>
|
||||
export const loadCommands = (directory: string, api: CommandListApi): Promise<CommandInfo[]> =>
|
||||
retry(() => api.list({ location: { directory } }).then((result) => result.data))
|
||||
|
||||
export const loadPathQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string | null,
|
||||
api: LocationApi,
|
||||
) =>
|
||||
export const loadPathQuery = (scope: ServerScope, directory: string | null, api: LocationApi) =>
|
||||
queryOptions<Path>({
|
||||
queryKey: [scope, directory, "path"],
|
||||
queryFn: () =>
|
||||
@@ -276,15 +247,10 @@ export const loadPathQuery = (
|
||||
})),
|
||||
})
|
||||
|
||||
export const loadReferencesQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
api: ReferenceListApi,
|
||||
) =>
|
||||
export const loadReferencesQuery = (scope: ServerScope, directory: string, api: ReferenceListApi) =>
|
||||
queryOptions<ReferenceInfo[]>({
|
||||
queryKey: [scope, directory, "references"] as const,
|
||||
queryFn: () =>
|
||||
retry(() => api.list({ location: { directory } }).then((result) => result.data)).catch(() => []),
|
||||
queryFn: () => retry(() => api.list({ location: { directory } }).then((result) => result.data)).catch(() => []),
|
||||
placeholderData: [],
|
||||
})
|
||||
|
||||
@@ -353,46 +319,41 @@ export async function bootstrapDirectory(input: {
|
||||
})),
|
||||
input.mcp &&
|
||||
(() =>
|
||||
loadCommands(input.directory, input.api.command).then((commands) =>
|
||||
input.setStore("command", commands),
|
||||
)),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadReferencesQuery(input.scope, input.directory, input.api.reference),
|
||||
),
|
||||
loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))),
|
||||
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.api.reference)),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.api.permission.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data)
|
||||
.then((permissions) => {
|
||||
const ids = permissions.map((permission) => permission.sessionID)
|
||||
const grouped = groupBySession(
|
||||
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
|
||||
)
|
||||
const warm = input.session
|
||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||
return warm.then(() =>
|
||||
batch(() => {
|
||||
const current = input.session?.data.permission ?? input.store.permission
|
||||
for (const sessionID of Object.keys(current)) {
|
||||
if (grouped[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
||||
if (input.session) input.session.set("permission", sessionID, [])
|
||||
if (!input.session) input.setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
const value = reconcile(
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
)
|
||||
if (input.session) input.session.set("permission", sessionID, value)
|
||||
if (!input.session) input.setStore("permission", sessionID, value)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
const ids = permissions.map((permission) => permission.sessionID)
|
||||
const grouped = groupBySession(
|
||||
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
|
||||
)
|
||||
const warm = input.session
|
||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||
return warm.then(() =>
|
||||
batch(() => {
|
||||
const current = input.session?.data.permission ?? input.store.permission
|
||||
for (const sessionID of Object.keys(current)) {
|
||||
if (grouped[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
||||
if (input.session) input.session.set("permission", sessionID, [])
|
||||
if (!input.session) input.setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
const value = reconcile(
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
)
|
||||
if (input.session) input.session.set("permission", sessionID, value)
|
||||
if (!input.session) input.setStore("permission", sessionID, value)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
@@ -400,56 +361,47 @@ export async function bootstrapDirectory(input: {
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data)
|
||||
.then((questions) => {
|
||||
const ids = questions.map((question) => question.sessionID)
|
||||
const grouped = groupBySession(
|
||||
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
|
||||
)
|
||||
const warm = input.session
|
||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||
return warm.then(() =>
|
||||
batch(() => {
|
||||
const current = input.session?.data.question ?? input.store.question
|
||||
for (const sessionID of Object.keys(current)) {
|
||||
if (grouped[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
||||
if (input.session) input.session.set("question", sessionID, [])
|
||||
if (!input.session) input.setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
const value = reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
)
|
||||
if (input.session) input.session.set("question", sessionID, value)
|
||||
if (!input.session) input.setStore("question", sessionID, value)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
const ids = questions.map((question) => question.sessionID)
|
||||
const grouped = groupBySession(
|
||||
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
|
||||
)
|
||||
const warm = input.session
|
||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||
return warm.then(() =>
|
||||
batch(() => {
|
||||
const current = input.session?.data.question ?? input.store.question
|
||||
for (const sessionID of Object.keys(current)) {
|
||||
if (grouped[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
||||
if (input.session) input.session.set("question", sessionID, [])
|
||||
if (!input.session) input.setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
const value = reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
)
|
||||
if (input.session) input.session.set("question", sessionID, value)
|
||||
if (!input.session) input.setStore("question", sessionID, value)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.api.mcp))),
|
||||
input.mcp &&
|
||||
(() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadMcpQuery(input.scope, input.directory, input.api.mcp),
|
||||
)),
|
||||
input.mcp &&
|
||||
(() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp),
|
||||
)),
|
||||
(() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp))),
|
||||
() =>
|
||||
input.queryClient
|
||||
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api))
|
||||
.catch((err) => {
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
})
|
||||
}),
|
||||
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api)).catch((err) => {
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
})
|
||||
}),
|
||||
].filter(Boolean) as (() => Promise<any>)[]
|
||||
|
||||
await waitForPaint()
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { Message, Part, Project, Todo } from "@/types"
|
||||
import type {
|
||||
Message,
|
||||
Part,
|
||||
Project,
|
||||
Todo,
|
||||
} from "@/types"
|
||||
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||
FileDiffInfo,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionInfo,
|
||||
SessionStatus,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { dropSessionCaches } from "./session-cache"
|
||||
|
||||
@@ -81,9 +81,9 @@ describe("Home V2 session index", () => {
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "root",
|
||||
location: { directory: "/project" },
|
||||
expect.objectContaining({
|
||||
id: "root",
|
||||
location: { directory: "/project" },
|
||||
projectID: "project",
|
||||
title: "root",
|
||||
time: { created: 1, updated: 30 },
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import type {
|
||||
Agent,
|
||||
Config,
|
||||
LspStatus,
|
||||
Message,
|
||||
Part,
|
||||
Path,
|
||||
Todo,
|
||||
VcsInfo,
|
||||
} from "@/types"
|
||||
import type { Agent, Config, LspStatus, Message, Part, Path, Todo, VcsInfo } from "@/types"
|
||||
import type {
|
||||
FileDiffInfo,
|
||||
PermissionRequest,
|
||||
|
||||
@@ -153,4 +153,88 @@ describe("v2 session reducer", () => {
|
||||
|
||||
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
|
||||
})
|
||||
|
||||
test("removes cancelled input from the pending promotion fold", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_admitted",
|
||||
type: "session.input.admitted",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inputID: "msg_user",
|
||||
input: { type: "user", delivery: "queue", data: { text: "cancel me" } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_cancelled",
|
||||
type: "session.input.cancelled",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
const result = reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ missing: "msg_user" })
|
||||
})
|
||||
|
||||
test("keeps steered input available to the promotion fold", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_admitted",
|
||||
type: "session.input.admitted",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inputID: "msg_user",
|
||||
input: { type: "user", delivery: "queue", data: { text: "steer me" } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_steered",
|
||||
type: "session.input.steered",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_queued",
|
||||
type: "session.input.queued",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
const result = reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result?.messages).toMatchObject([{ id: "msg_user", type: "user", text: "steer me" }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,9 @@ export function createV2SessionReducer() {
|
||||
case "session.input.admitted":
|
||||
pending.set(key(sessionID, event.data.inputID), event.data.input)
|
||||
return result([...source])
|
||||
case "session.input.cancelled":
|
||||
pending.delete(key(sessionID, event.data.inputID))
|
||||
return
|
||||
case "session.input.promoted": {
|
||||
const input = pending.get(key(sessionID, event.data.inputID))
|
||||
pending.delete(key(sessionID, event.data.inputID))
|
||||
|
||||
@@ -67,64 +67,74 @@ function currentMessages(data: MessageResponse["data"]): SessionMessageInfo[] {
|
||||
},
|
||||
]
|
||||
}
|
||||
return [{
|
||||
id: item.info.id,
|
||||
type: "assistant",
|
||||
agent: item.info.agent,
|
||||
model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant },
|
||||
content: item.parts.flatMap((part): SessionMessageAssistant["content"] => {
|
||||
if (part.type === "text") return [{ type: "text", text: part.text }]
|
||||
if (part.type === "reasoning")
|
||||
return [
|
||||
{
|
||||
id: item.info.id,
|
||||
type: "assistant",
|
||||
agent: item.info.agent,
|
||||
model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant },
|
||||
content: item.parts.flatMap((part): SessionMessageAssistant["content"] => {
|
||||
if (part.type === "text") return [{ type: "text", text: part.text }]
|
||||
if (part.type === "reasoning")
|
||||
return [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: part.text,
|
||||
time: part.time ? { created: part.time.start, completed: part.time.end } : undefined,
|
||||
},
|
||||
]
|
||||
if (part.type !== "tool") return []
|
||||
const state: SessionMessageAssistantTool["state"] = (() => {
|
||||
if (part.state.status === "pending")
|
||||
return { status: "streaming" as const, input: JSON.stringify(part.state.input) }
|
||||
if (part.state.status === "running")
|
||||
return {
|
||||
status: "running" as const,
|
||||
input: part.state.input as CurrentToolObject,
|
||||
metadata: (part.state.metadata ?? {}) as CurrentToolObject,
|
||||
}
|
||||
if (part.state.status === "error")
|
||||
return {
|
||||
status: "error" as const,
|
||||
input: part.state.input as CurrentToolObject,
|
||||
error: { type: "tool_error", message: part.state.error },
|
||||
metadata: part.state.metadata as CurrentToolObject | undefined,
|
||||
}
|
||||
return {
|
||||
status: "completed" as const,
|
||||
input: part.state.input as CurrentToolObject,
|
||||
content: [{ type: "text" as const, text: part.state.output }],
|
||||
metadata: part.state.metadata as CurrentToolObject,
|
||||
}
|
||||
})()
|
||||
return [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: part.text,
|
||||
time: part.time ? { created: part.time.start, completed: part.time.end } : undefined,
|
||||
id: part.id,
|
||||
type: "tool" as const,
|
||||
name: part.tool,
|
||||
state,
|
||||
time: {
|
||||
created: part.state.status === "pending" ? item.info.time.created : part.state.time.start,
|
||||
ran: part.state.status === "pending" ? undefined : part.state.time.start,
|
||||
completed:
|
||||
part.state.status === "completed" || part.state.status === "error" ? part.state.time.end : undefined,
|
||||
},
|
||||
},
|
||||
]
|
||||
if (part.type !== "tool") return []
|
||||
const state: SessionMessageAssistantTool["state"] = (() => {
|
||||
if (part.state.status === "pending") return { status: "streaming" as const, input: JSON.stringify(part.state.input) }
|
||||
if (part.state.status === "running")
|
||||
return {
|
||||
status: "running" as const,
|
||||
input: part.state.input as CurrentToolObject,
|
||||
metadata: (part.state.metadata ?? {}) as CurrentToolObject,
|
||||
}
|
||||
if (part.state.status === "error")
|
||||
return {
|
||||
status: "error" as const,
|
||||
input: part.state.input as CurrentToolObject,
|
||||
error: { type: "tool_error", message: part.state.error },
|
||||
metadata: part.state.metadata as CurrentToolObject | undefined,
|
||||
}
|
||||
return {
|
||||
status: "completed" as const,
|
||||
input: part.state.input as CurrentToolObject,
|
||||
content: [{ type: "text" as const, text: part.state.output }],
|
||||
metadata: part.state.metadata as CurrentToolObject,
|
||||
}
|
||||
})()
|
||||
return [
|
||||
{
|
||||
id: part.id,
|
||||
type: "tool" as const,
|
||||
name: part.tool,
|
||||
state,
|
||||
time: {
|
||||
created: part.state.status === "pending" ? item.info.time.created : part.state.time.start,
|
||||
ran: part.state.status === "pending" ? undefined : part.state.time.start,
|
||||
completed:
|
||||
part.state.status === "completed" || part.state.status === "error" ? part.state.time.end : undefined,
|
||||
},
|
||||
},
|
||||
]
|
||||
}),
|
||||
time: item.info.time,
|
||||
cost: item.info.cost,
|
||||
tokens: item.info.tokens,
|
||||
finish: item.info.finish as "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | undefined,
|
||||
}]
|
||||
}),
|
||||
time: item.info.time,
|
||||
cost: item.info.cost,
|
||||
tokens: item.info.tokens,
|
||||
finish: item.info.finish as
|
||||
| "stop"
|
||||
| "length"
|
||||
| "tool-calls"
|
||||
| "content-filter"
|
||||
| "error"
|
||||
| "unknown"
|
||||
| undefined,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -428,270 +438,259 @@ describe("server session", () => {
|
||||
|
||||
// V2 messages are ordered projections and do not expose V1 assistant parent IDs.
|
||||
describe.skip("V1 assistant parent projections", () => {
|
||||
test("backfills an assistant-only initial page through its user root", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[
|
||||
response(assistants.map((info) => ({ info, parts: [] }))),
|
||||
],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
test("backfills an assistant-only initial page through its user root", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[response(assistants.map((info) => ({ info, parts: [] })))],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
|
||||
await store.sync("child")
|
||||
await store.sync("child")
|
||||
|
||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, order: "desc" }])
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
|
||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||
expect(store.history.more("child")).toBe(false)
|
||||
})
|
||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, order: "desc" }])
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
|
||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||
expect(store.history.more("child")).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps assistant history when its deleted parent cannot be backfilled", async () => {
|
||||
const missing = Promise.withResolvers<SingleMessageResponse>()
|
||||
const assistant = assistantMessage("message-2", "message-missing")
|
||||
const client = rootMessageClient([response([{ info: assistant, parts: [] }])], [missing.promise])
|
||||
const store = createServerSession(client)
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
test("keeps assistant history when its deleted parent cannot be backfilled", async () => {
|
||||
const missing = Promise.withResolvers<SingleMessageResponse>()
|
||||
const assistant = assistantMessage("message-2", "message-missing")
|
||||
const client = rootMessageClient([response([{ info: assistant, parts: [] }])], [missing.promise])
|
||||
const store = createServerSession(client)
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
|
||||
missing.reject(new Error("Message not found: message-missing", { cause: { status: 404 } }))
|
||||
await loading
|
||||
missing.reject(new Error("Message not found: message-missing", { cause: { status: 404 } }))
|
||||
await loading
|
||||
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }])
|
||||
expect(store.data.message.child).toEqual([assistant])
|
||||
expect(store.history.more("child")).toBe(false)
|
||||
})
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }])
|
||||
expect(store.data.message.child).toEqual([assistant])
|
||||
expect(store.history.more("child")).toBe(false)
|
||||
})
|
||||
|
||||
test("drops a cached parent when a forced refresh confirms it was deleted", async () => {
|
||||
const missing = Promise.withResolvers<SingleMessageResponse>()
|
||||
const parent = userMessage("message-1")
|
||||
const part = textPart(parent.id)
|
||||
const assistant = assistantMessage("message-2", parent.id)
|
||||
const client = rootMessageClient(
|
||||
[
|
||||
response([
|
||||
{ info: parent, parts: [part] },
|
||||
{ info: assistant, parts: [] },
|
||||
]),
|
||||
response([{ info: assistant, parts: [] }]),
|
||||
],
|
||||
[missing.promise],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
const loading = store.sync("child", { force: true })
|
||||
await client.rootRequested(1)
|
||||
test("drops a cached parent when a forced refresh confirms it was deleted", async () => {
|
||||
const missing = Promise.withResolvers<SingleMessageResponse>()
|
||||
const parent = userMessage("message-1")
|
||||
const part = textPart(parent.id)
|
||||
const assistant = assistantMessage("message-2", parent.id)
|
||||
const client = rootMessageClient(
|
||||
[
|
||||
response([
|
||||
{ info: parent, parts: [part] },
|
||||
{ info: assistant, parts: [] },
|
||||
]),
|
||||
response([{ info: assistant, parts: [] }]),
|
||||
],
|
||||
[missing.promise],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
const loading = store.sync("child", { force: true })
|
||||
await client.rootRequested(1)
|
||||
|
||||
missing.reject(new Error(`Message not found: ${parent.id}`, { cause: { status: 404 } }))
|
||||
await loading
|
||||
missing.reject(new Error(`Message not found: ${parent.id}`, { cause: { status: 404 } }))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([assistant])
|
||||
expect(store.data.part[parent.id]).toBeUndefined()
|
||||
})
|
||||
expect(store.data.message.child).toEqual([assistant])
|
||||
expect(store.data.part[parent.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not let an optimistic user suppress initial root backfill", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const part = textPart(user.id)
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[
|
||||
response(assistants.map((info) => ({ info, parts: [] }))),
|
||||
],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
store.optimistic.add({ sessionID: "child", message: user, parts: [part] })
|
||||
test("does not let an optimistic user suppress initial root backfill", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const part = textPart(user.id)
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[response(assistants.map((info) => ({ info, parts: [] })))],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
store.optimistic.add({ sessionID: "child", message: user, parts: [part] })
|
||||
|
||||
await store.sync("child")
|
||||
store.optimistic.remove({ sessionID: "child", messageID: user.id })
|
||||
await store.sync("child")
|
||||
store.optimistic.remove({ sessionID: "child", messageID: user.id })
|
||||
|
||||
expect(client.requests).toHaveLength(1)
|
||||
expect(client.rootRequests).toHaveLength(1)
|
||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||
})
|
||||
expect(client.requests).toHaveLength(1)
|
||||
expect(client.rootRequests).toHaveLength(1)
|
||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||
})
|
||||
|
||||
test("backfills the parent of fetched assistants when another user is cached", async () => {
|
||||
const unrelated = userMessage("message-0", { time: { created: 0 } })
|
||||
const user = userMessage("message-1")
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[
|
||||
response([{ info: unrelated, parts: [] }]),
|
||||
response(assistants.map((info) => ({ info, parts: [] }))),
|
||||
],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
test("backfills the parent of fetched assistants when another user is cached", async () => {
|
||||
const unrelated = userMessage("message-0", { time: { created: 0 } })
|
||||
const user = userMessage("message-1")
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: unrelated, parts: [] }]), response(assistants.map((info) => ({ info, parts: [] })))],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(client.requests).toHaveLength(2)
|
||||
expect(client.rootRequests).toHaveLength(1)
|
||||
expect(store.data.message.child).toEqual([unrelated, user, ...assistants])
|
||||
})
|
||||
expect(client.requests).toHaveLength(2)
|
||||
expect(client.rootRequests).toHaveLength(1)
|
||||
expect(store.data.message.child).toEqual([unrelated, user, ...assistants])
|
||||
})
|
||||
|
||||
test("preserves cached history between an injected parent and the page boundary", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const cached = userMessage("message-3", { time: { created: 3 } })
|
||||
const assistant = assistantMessage("message-4", user.id)
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }])],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
test("preserves cached history between an injected parent and the page boundary", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const cached = userMessage("message-3", { time: { created: 3 } })
|
||||
const assistant = assistantMessage("message-4", user.id)
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }])],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.message.child).toEqual([user, cached, assistant])
|
||||
})
|
||||
expect(store.data.message.child).toEqual([user, cached, assistant])
|
||||
})
|
||||
|
||||
test("refreshes a cached parent omitted by an assistant-only replacement page", async () => {
|
||||
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
|
||||
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
|
||||
const stalePart = textPart(stale.id, { text: "stale" })
|
||||
const freshPart = { ...stalePart, text: "fresh" }
|
||||
const assistant = assistantMessage("message-2", stale.id)
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }])],
|
||||
[singleResponse(fresh, [freshPart])],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
test("refreshes a cached parent omitted by an assistant-only replacement page", async () => {
|
||||
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
|
||||
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
|
||||
const stalePart = textPart(stale.id, { text: "stale" })
|
||||
const freshPart = { ...stalePart, text: "fresh" }
|
||||
const assistant = assistantMessage("message-2", stale.id)
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }])],
|
||||
[singleResponse(fresh, [freshPart])],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
|
||||
expect(store.data.message.child).toEqual([fresh, assistant])
|
||||
expect(store.data.part[stale.id]).toEqual([freshPart])
|
||||
})
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
|
||||
expect(store.data.message.child).toEqual([fresh, assistant])
|
||||
expect(store.data.part[stale.id]).toEqual([freshPart])
|
||||
})
|
||||
|
||||
test("refreshes a confirmed optimistic parent while preserving pending parts", async () => {
|
||||
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
|
||||
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
|
||||
const confirmed = textPart(stale.id, { id: "confirmed", text: "stale" })
|
||||
const refreshed = { ...confirmed, text: "fresh" }
|
||||
const pending = textPart(stale.id, { id: "pending", text: "pending" })
|
||||
const assistant = assistantMessage("message-2", stale.id)
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }])],
|
||||
[singleResponse(fresh, [refreshed])],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
store.optimistic.add({ sessionID: "child", message: stale, parts: [confirmed, pending] })
|
||||
await store.sync("child")
|
||||
test("refreshes a confirmed optimistic parent while preserving pending parts", async () => {
|
||||
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
|
||||
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
|
||||
const confirmed = textPart(stale.id, { id: "confirmed", text: "stale" })
|
||||
const refreshed = { ...confirmed, text: "fresh" }
|
||||
const pending = textPart(stale.id, { id: "pending", text: "pending" })
|
||||
const assistant = assistantMessage("message-2", stale.id)
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }])],
|
||||
[singleResponse(fresh, [refreshed])],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
store.optimistic.add({ sessionID: "child", message: stale, parts: [confirmed, pending] })
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
|
||||
expect(store.data.message.child).toEqual([fresh, assistant])
|
||||
expect(store.data.part[stale.id]).toEqual([refreshed, pending])
|
||||
})
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
|
||||
expect(store.data.message.child).toEqual([fresh, assistant])
|
||||
expect(store.data.part[stale.id]).toEqual([refreshed, pending])
|
||||
})
|
||||
|
||||
test("uses a parent received by SSE during the replacement load", async () => {
|
||||
const pending = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const assistant = assistantMessage("message-2", user.id)
|
||||
const client = rootMessageClient([pending.promise], [])
|
||||
const store = createServerSession(client)
|
||||
const loading = store.sync("child")
|
||||
test("uses a parent received by SSE during the replacement load", async () => {
|
||||
const pending = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const assistant = assistantMessage("message-2", user.id)
|
||||
const client = rootMessageClient([pending.promise], [])
|
||||
const store = createServerSession(client)
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: user } })
|
||||
pending.resolve(response([{ info: assistant, parts: [] }]))
|
||||
await loading
|
||||
store.apply({ type: "message.updated", properties: { info: user } })
|
||||
pending.resolve(response([{ info: assistant, parts: [] }]))
|
||||
await loading
|
||||
|
||||
expect(client.rootRequests).toEqual([])
|
||||
expect(store.data.message.child).toEqual([user, assistant])
|
||||
})
|
||||
expect(client.rootRequests).toEqual([])
|
||||
expect(store.data.message.child).toEqual([user, assistant])
|
||||
})
|
||||
|
||||
test("uses a successful retry over events received by a failed backfill attempt", async () => {
|
||||
const failed = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const live = { ...user, agent: "stale" }
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[
|
||||
response(
|
||||
assistants.map((info) => ({ info, parts: [] })),
|
||||
),
|
||||
],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
test("uses a successful retry over events received by a failed backfill attempt", async () => {
|
||||
const failed = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const live = { ...user, agent: "stale" }
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[response(assistants.map((info) => ({ info, parts: [] })))],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: live } })
|
||||
failed.reject(new Error("retry"))
|
||||
await loading
|
||||
store.apply({ type: "message.updated", properties: { info: live } })
|
||||
failed.reject(new Error("retry"))
|
||||
await loading
|
||||
|
||||
expect(client.requests).toHaveLength(1)
|
||||
expect(client.rootRequests).toHaveLength(2)
|
||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||
})
|
||||
expect(client.requests).toHaveLength(1)
|
||||
expect(client.rootRequests).toHaveLength(2)
|
||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||
})
|
||||
|
||||
test("preserves newer-page events across a failed parent retry", async () => {
|
||||
const failed = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const assistant = assistantMessage("message-2", user.id)
|
||||
const live = { ...assistant, cost: 1 }
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: assistant, parts: [] }])],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
test("preserves newer-page events across a failed parent retry", async () => {
|
||||
const failed = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const assistant = assistantMessage("message-2", user.id)
|
||||
const live = { ...assistant, cost: 1 }
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: assistant, parts: [] }])],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: live } })
|
||||
failed.reject(new Error("retry"))
|
||||
await loading
|
||||
store.apply({ type: "message.updated", properties: { info: live } })
|
||||
failed.reject(new Error("retry"))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([user, live])
|
||||
})
|
||||
expect(store.data.message.child).toEqual([user, live])
|
||||
})
|
||||
|
||||
test("preserves unrelated message events across a failed parent retry", async () => {
|
||||
const failed = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const assistant = assistantMessage("message-2", user.id)
|
||||
const live = userMessage("message-4", { time: { created: 4 } })
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: assistant, parts: [] }])],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
test("preserves unrelated message events across a failed parent retry", async () => {
|
||||
const failed = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const assistant = assistantMessage("message-2", user.id)
|
||||
const live = userMessage("message-4", { time: { created: 4 } })
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: assistant, parts: [] }])],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: live } })
|
||||
failed.reject(new Error("retry"))
|
||||
await loading
|
||||
store.apply({ type: "message.updated", properties: { info: live } })
|
||||
failed.reject(new Error("retry"))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([user, assistant, live])
|
||||
})
|
||||
expect(store.data.message.child).toEqual([user, assistant, live])
|
||||
})
|
||||
|
||||
test("preserves newer-page part events across a failed parent retry", async () => {
|
||||
const failed = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const assistant = assistantMessage("message-2", user.id)
|
||||
const stale = textPart(assistant.id, { text: "stale" })
|
||||
const live = { ...stale, text: "live" }
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: assistant, parts: [stale] }])],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
test("preserves newer-page part events across a failed parent retry", async () => {
|
||||
const failed = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const assistant = assistantMessage("message-2", user.id)
|
||||
const stale = textPart(assistant.id, { text: "stale" })
|
||||
const live = { ...stale, text: "live" }
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: assistant, parts: [stale] }])],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } })
|
||||
failed.reject(new Error("retry"))
|
||||
await loading
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } })
|
||||
failed.reject(new Error("retry"))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[assistant.id]).toEqual([live])
|
||||
})
|
||||
expect(store.data.part[assistant.id]).toEqual([live])
|
||||
})
|
||||
})
|
||||
|
||||
test("merges live events into the initial page", async () => {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
import type { OpenCodeEvent, SessionApi, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type {
|
||||
Message,
|
||||
Part,
|
||||
Todo,
|
||||
} from "@/types"
|
||||
import type { Message, Part, Todo } from "@/types"
|
||||
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import { batch } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
@@ -38,14 +34,16 @@ function projectMessageSource(message: Message): SessionMessageInfo[] {
|
||||
{ id: message.id, type: "user", text: "", time: message.time },
|
||||
]
|
||||
}
|
||||
return [{
|
||||
id: message.id,
|
||||
type: "assistant",
|
||||
agent: message.agent ?? message.mode,
|
||||
model: { id: message.modelID, providerID: message.providerID, variant: message.variant },
|
||||
content: [],
|
||||
time: message.time,
|
||||
}]
|
||||
return [
|
||||
{
|
||||
id: message.id,
|
||||
type: "assistant",
|
||||
agent: message.agent ?? message.mode,
|
||||
model: { id: message.modelID, providerID: message.providerID, variant: message.variant },
|
||||
content: [],
|
||||
time: message.time,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
|
||||
@@ -243,11 +241,7 @@ export function createServerSession(
|
||||
const indexProjectedMessage = (message: Message) => {
|
||||
const current = data.session_message[message.sessionID] ?? []
|
||||
if (current.some((item) => item.id === message.id)) return
|
||||
setData(
|
||||
"session_message",
|
||||
message.sessionID,
|
||||
reconcile([...current, ...projectMessageSource(message)]),
|
||||
)
|
||||
setData("session_message", message.sessionID, reconcile([...current, ...projectMessageSource(message)]))
|
||||
}
|
||||
|
||||
const remember = (session: SessionInfo) => {
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import type {
|
||||
Config,
|
||||
Path,
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
} from "@/types"
|
||||
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||
@@ -164,15 +159,11 @@ export function seedActiveSessionStatuses(
|
||||
}
|
||||
}
|
||||
|
||||
function makeQueryOptionsApi(
|
||||
scope: ServerScope,
|
||||
serverAPI: ServerApi,
|
||||
) {
|
||||
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
|
||||
return {
|
||||
globalConfig: () => loadGlobalConfigQuery(scope),
|
||||
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
||||
providers: (directory: PathKey | null) =>
|
||||
loadProvidersQuery(scope, directory, serverAPI),
|
||||
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
|
||||
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
|
||||
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
|
||||
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
hasExistingWebState,
|
||||
isAppUpgrade,
|
||||
layoutTransitionState,
|
||||
maximumSunsetTimeout,
|
||||
newLayoutDesignsDefault,
|
||||
nextSunsetCheckDelay,
|
||||
resolveNewLayoutDesigns,
|
||||
shouldDisplayTabsToast,
|
||||
shouldEnableNewLayout,
|
||||
} from "./settings"
|
||||
|
||||
describe("layout transition", () => {
|
||||
test("blank profiles default to the new layout", () => {
|
||||
expect(newLayoutDesignsDefault).toBe(true)
|
||||
})
|
||||
|
||||
test("hides the transition until a sunset is scheduled", () => {
|
||||
expect(layoutTransitionState(false, true, false, false)).toEqual({ available: false, notice: false })
|
||||
})
|
||||
|
||||
test("existing profiles can switch before sunset", () => {
|
||||
expect(layoutTransitionState(true, true, false, false)).toEqual({ available: true, notice: false })
|
||||
})
|
||||
|
||||
test("classifies web profiles from existing settings or a recorded version", () => {
|
||||
expect(hasExistingWebState("{}", undefined)).toBe(true)
|
||||
expect(hasExistingWebState(null, "1.17.19")).toBe(true)
|
||||
expect(hasExistingWebState(null, undefined)).toBe(false)
|
||||
})
|
||||
|
||||
test("preserves explicit and default layout preferences", () => {
|
||||
expect(resolveNewLayoutDesigns(false, false, true)).toBe(false)
|
||||
expect(resolveNewLayoutDesigns(false, undefined, false)).toBe(false)
|
||||
expect(resolveNewLayoutDesigns(false, undefined, true)).toBe(true)
|
||||
})
|
||||
|
||||
test("sunset replaces the toggle with a dismissible notice", () => {
|
||||
expect(layoutTransitionState(true, true, true, false)).toEqual({ available: false, notice: true })
|
||||
expect(layoutTransitionState(true, true, true, true)).toEqual({ available: false, notice: false })
|
||||
expect(resolveNewLayoutDesigns(true, false)).toBe(true)
|
||||
})
|
||||
|
||||
test("caps checks for sunsets beyond the browser timeout limit", () => {
|
||||
expect(nextSunsetCheckDelay(maximumSunsetTimeout + 1_000, 0)).toBe(maximumSunsetTimeout)
|
||||
expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000)
|
||||
expect(nextSunsetCheckDelay(9_000, 10_000)).toBe(0)
|
||||
})
|
||||
|
||||
test("enables the new layout when upgrading from 1.17.19 or earlier", () => {
|
||||
expect(shouldEnableNewLayout("v1.17.19", "1.17.20")).toBe(true)
|
||||
expect(shouldEnableNewLayout("1.16.9", "2.0.0")).toBe(true)
|
||||
})
|
||||
|
||||
test("enables the new layout when no previous version was recorded", () => {
|
||||
expect(shouldEnableNewLayout(undefined, "1.17.20")).toBe(true)
|
||||
})
|
||||
|
||||
test("detects upgrades only when a previous version is older", () => {
|
||||
expect(isAppUpgrade("1.17.19", "1.17.20")).toBe(true)
|
||||
expect(isAppUpgrade(undefined, "1.17.20")).toBe(false)
|
||||
expect(isAppUpgrade("1.17.20", "1.17.20")).toBe(false)
|
||||
expect(isAppUpgrade("1.17.21", "1.17.20")).toBe(false)
|
||||
})
|
||||
|
||||
test("shows the tabs toast for upgrades and existing installs without a recorded version", () => {
|
||||
expect(shouldDisplayTabsToast("1.17.19", "1.17.20", false)).toBe(true)
|
||||
expect(shouldDisplayTabsToast(undefined, "1.17.20", true)).toBe(true)
|
||||
expect(shouldDisplayTabsToast(undefined, "1.17.20", false)).toBe(false)
|
||||
})
|
||||
|
||||
test("does not enable the new layout without a qualifying upgrade", () => {
|
||||
expect(shouldEnableNewLayout("1.17.19", "1.17.19")).toBe(false)
|
||||
expect(shouldEnableNewLayout("1.17.20", "1.17.21")).toBe(false)
|
||||
expect(shouldEnableNewLayout(undefined, "1.17.19")).toBe(false)
|
||||
expect(shouldEnableNewLayout("dev", "1.17.20")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
|
||||
export interface NotificationSettings {
|
||||
agent: boolean
|
||||
@@ -34,10 +33,6 @@ export interface Settings {
|
||||
editToolPartsExpanded: boolean
|
||||
showCustomAgents: boolean
|
||||
mobileTitlebarPosition: "top" | "bottom"
|
||||
newLayoutDesigns?: boolean
|
||||
layoutTransitionEligible?: boolean
|
||||
newInterfaceNoticeDismissed?: boolean
|
||||
shouldDisplayTabsToast?: boolean
|
||||
}
|
||||
appearance: {
|
||||
fontSize: number
|
||||
@@ -56,75 +51,6 @@ export interface Settings {
|
||||
export const monoDefault = "System Mono"
|
||||
export const sansDefault = "System Sans"
|
||||
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
||||
const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
export const newLayoutDesignsDefault = true
|
||||
// Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show.
|
||||
export const oldInterfaceSunset = new Date(2026, 8, 14)
|
||||
const newLayoutDesignsUpgradeCutoff = "1.17.19"
|
||||
|
||||
function compareVersions(a: string, b: string) {
|
||||
const parse = (version: string) => {
|
||||
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i.exec(version.trim())
|
||||
if (!match) return
|
||||
return match.slice(1).map(Number)
|
||||
}
|
||||
const left = parse(a)
|
||||
const right = parse(b)
|
||||
if (!left || !right) return
|
||||
const index = left.findIndex((part, index) => part !== right[index])
|
||||
return index === -1 ? 0 : left[index]! - right[index]!
|
||||
}
|
||||
|
||||
export function isAppUpgrade(previous: string | undefined, current: string | undefined) {
|
||||
if (!previous || !current) return false
|
||||
const comparison = compareVersions(current, previous)
|
||||
return comparison !== undefined && comparison > 0
|
||||
}
|
||||
|
||||
export function shouldDisplayTabsToast(
|
||||
previous: string | undefined,
|
||||
current: string | undefined,
|
||||
existingInstall: boolean,
|
||||
) {
|
||||
return isAppUpgrade(previous, current) || (!previous && existingInstall)
|
||||
}
|
||||
|
||||
export function hasExistingWebState(settings: Promise<string> | string | null, previousVersion: string | undefined) {
|
||||
return settings !== null || previousVersion !== undefined
|
||||
}
|
||||
|
||||
export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
|
||||
if (!current) return false
|
||||
const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
|
||||
if (!previous) return currentComparison !== undefined && currentComparison > 0
|
||||
if (!isAppUpgrade(previous, current)) return false
|
||||
const previousComparison = compareVersions(previous, newLayoutDesignsUpgradeCutoff)
|
||||
return (
|
||||
previousComparison !== undefined &&
|
||||
currentComparison !== undefined &&
|
||||
previousComparison <= 0 &&
|
||||
currentComparison > 0
|
||||
)
|
||||
}
|
||||
|
||||
export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
|
||||
return {
|
||||
available: scheduled && eligible && !retired,
|
||||
notice: scheduled && eligible && retired && !dismissed,
|
||||
}
|
||||
}
|
||||
|
||||
export const maximumSunsetTimeout = 2_147_483_647
|
||||
|
||||
export function nextSunsetCheckDelay(sunset: number, now: number) {
|
||||
return Math.min(Math.max(0, sunset - now), maximumSunsetTimeout)
|
||||
}
|
||||
|
||||
export function resolveNewLayoutDesigns(retired: boolean, preference: boolean | undefined, fallback = true) {
|
||||
if (retired) return true
|
||||
return preference ?? fallback
|
||||
}
|
||||
|
||||
const monoFallback =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
||||
@@ -223,17 +149,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
name: "Settings",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const platform = usePlatform()
|
||||
const [store, setStore, settingsInit, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
||||
const [launch, setLaunch, , launchReady] = persisted(
|
||||
"app-version.v1",
|
||||
createStore<{ version?: string }>({ version: undefined }),
|
||||
)
|
||||
const [launchState, setLaunchState] = createStore({
|
||||
classified: false,
|
||||
migrationApplied: false,
|
||||
previous: undefined as string | undefined,
|
||||
})
|
||||
const [store, setStore, , ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
||||
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
||||
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
||||
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
||||
@@ -241,93 +157,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
() => store.general?.showCustomAgents,
|
||||
defaultSettings.general.showCustomAgents,
|
||||
)
|
||||
const sunset = oldInterfaceSunset
|
||||
const [oldInterfaceRetired, setOldInterfaceRetired] = createSignal(sunset ? Date.now() >= sunset.getTime() : false)
|
||||
const layoutTransitionClassified = createMemo(() => typeof store.general?.layoutTransitionEligible === "boolean")
|
||||
const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false)
|
||||
const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
|
||||
const layoutUpgrade = createMemo(() =>
|
||||
launchState.classified && !launchState.migrationApplied
|
||||
? shouldEnableNewLayout(launchState.previous, platform.version)
|
||||
: false,
|
||||
)
|
||||
const layoutTransition = createMemo(() =>
|
||||
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
|
||||
)
|
||||
const newLayoutDesigns = createMemo(() => {
|
||||
if (layoutUpgrade()) return true
|
||||
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
|
||||
if (!layoutTransitionClassified()) {
|
||||
return resolveNewLayoutDesigns(
|
||||
oldInterfaceRetired(),
|
||||
store.general?.newLayoutDesigns,
|
||||
legacyNewLayoutDesignsDefault,
|
||||
)
|
||||
}
|
||||
return resolveNewLayoutDesigns(
|
||||
oldInterfaceRetired(),
|
||||
store.general?.newLayoutDesigns,
|
||||
layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault,
|
||||
)
|
||||
})
|
||||
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
|
||||
|
||||
if (sunset && !oldInterfaceRetired()) {
|
||||
const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
const checkSunset = () => {
|
||||
if (Date.now() >= sunset.getTime()) {
|
||||
setOldInterfaceRetired(true)
|
||||
return
|
||||
}
|
||||
timeout.current = setTimeout(checkSunset, nextSunsetCheckDelay(sunset.getTime(), Date.now()))
|
||||
}
|
||||
checkSunset()
|
||||
onCleanup(() => {
|
||||
if (timeout.current !== undefined) clearTimeout(timeout.current)
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!launchReady() || launchState.classified) return
|
||||
setLaunchState({
|
||||
classified: true,
|
||||
previous: launch.version,
|
||||
})
|
||||
if (!platform.version || launch.version === platform.version) return
|
||||
setLaunch("version", platform.version)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !launchState.classified || platform.platform !== "web") return
|
||||
if (layoutTransitionClassified()) return
|
||||
setStore("general", "layoutTransitionEligible", hasExistingWebState(settingsInit, launchState.previous))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !launchState.classified || launchState.migrationApplied) return
|
||||
if (layoutUpgrade() && store.general?.newLayoutDesigns !== true) {
|
||||
setStore("general", "newLayoutDesigns", true)
|
||||
}
|
||||
setLaunchState("migrationApplied", true)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !launchState.classified) return
|
||||
if (typeof store.general?.shouldDisplayTabsToast === "boolean") return
|
||||
if (!launchState.previous && !layoutTransitionClassified()) return
|
||||
setStore(
|
||||
"general",
|
||||
"shouldDisplayTabsToast",
|
||||
shouldDisplayTabsToast(launchState.previous, platform.version, layoutTransitionEligible()),
|
||||
)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !oldInterfaceRetired()) return
|
||||
if (store.general?.newLayoutDesigns === true) return
|
||||
setStore("general", "newLayoutDesigns", true)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const root = document.documentElement
|
||||
@@ -413,34 +242,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setMobileTitlebarPosition(value: "top" | "bottom") {
|
||||
setStore("general", "mobileTitlebarPosition", value)
|
||||
},
|
||||
newLayoutDesigns,
|
||||
setNewLayoutDesigns(value: boolean) {
|
||||
const next = oldInterfaceRetired() ? true : value
|
||||
if (newLayoutDesigns() === next) return
|
||||
setStore("general", "newLayoutDesigns", next)
|
||||
if (typeof window !== "undefined") setTimeout(() => window.location.reload())
|
||||
},
|
||||
layoutTransitionClassified,
|
||||
setOldLayoutEligible(eligible: boolean) {
|
||||
const current = store.general?.layoutTransitionEligible
|
||||
if (typeof current === "boolean") return
|
||||
setStore("general", "layoutTransitionEligible", eligible)
|
||||
},
|
||||
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
|
||||
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
|
||||
dismissNewInterfaceNotice() {
|
||||
setStore("general", "newInterfaceNoticeDismissed", true)
|
||||
},
|
||||
shouldDisplayTabsToast: withFallback(() => store.general?.shouldDisplayTabsToast, false),
|
||||
dismissTabsToast() {
|
||||
setStore("general", "shouldDisplayTabsToast", false)
|
||||
},
|
||||
newLayoutDesigns: () => true,
|
||||
},
|
||||
visibility: {
|
||||
fileTree: visible(showFileTree),
|
||||
search: visible(showSearch),
|
||||
status: visible(showStatus),
|
||||
customAgents: visible(showCustomAgents),
|
||||
fileTree: showFileTree,
|
||||
search: showSearch,
|
||||
status: showStatus,
|
||||
customAgents: showCustomAgents,
|
||||
},
|
||||
appearance: {
|
||||
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
|
||||
|
||||
@@ -268,10 +268,13 @@ function createWorkspaceTerminalSession(
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
const pty = store.all[index]
|
||||
if (!pty) return
|
||||
const data = await sdk.api.pty.create({ location, title: pty.title }).then((result) => result.data).catch((error: unknown) => {
|
||||
console.error("Failed to clone terminal", error)
|
||||
return undefined
|
||||
})
|
||||
const data = await sdk.api.pty
|
||||
.create({ location, title: pty.title })
|
||||
.then((result) => result.data)
|
||||
.catch((error: unknown) => {
|
||||
console.error("Failed to clone terminal", error)
|
||||
return undefined
|
||||
})
|
||||
if (!data?.id) return
|
||||
|
||||
const active = store.active === pty.id
|
||||
|
||||
@@ -8,7 +8,7 @@ import { createHomeSessionSearchController } from "./home/home-session-search-co
|
||||
import { createHomeSessionsController } from "./home/home-sessions-controller"
|
||||
import { HomeSessions } from "./home/home-sessions"
|
||||
|
||||
export function NewHome() {
|
||||
export function Home() {
|
||||
const home = createHomeController()
|
||||
const projects = createHomeProjectsController(home)
|
||||
const sessions = createHomeSessionsController(home)
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { type ServerConnection, useServer } from "@/context/server"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { DateTime } from "luxon"
|
||||
import { createMemo, For, Match, Switch } from "solid-js"
|
||||
|
||||
export function LegacyHome() {
|
||||
const sync = useServerSync()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const global = useGlobal()
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const homedir = createMemo(() => sync().data.path.home)
|
||||
const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false)
|
||||
const recent = createMemo(() => {
|
||||
return sync()
|
||||
.data.project.slice()
|
||||
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
const serverDotClass = createMemo(() => {
|
||||
const healthy = global.servers.health[server.key]?.healthy
|
||||
if (healthy === true) return "bg-icon-success-base"
|
||||
if (healthy === false) return "bg-icon-critical-base"
|
||||
return "bg-border-weak-base"
|
||||
})
|
||||
|
||||
function openProject(conn: ServerConnection.Any, directory: string) {
|
||||
const serverCtx = global.ensureServerCtx(conn)
|
||||
serverCtx.projects.open(directory)
|
||||
serverCtx.projects.touch(directory)
|
||||
navigate(`/${base64Encode(directory)}`)
|
||||
}
|
||||
|
||||
function chooseProject() {
|
||||
if (serverUnreachable()) return
|
||||
const conn = server.current
|
||||
if (!conn) return
|
||||
|
||||
const resolve = (result: string | string[] | null) => {
|
||||
if (Array.isArray(result)) {
|
||||
result.forEach((directory) => openProject(conn, directory))
|
||||
return
|
||||
}
|
||||
if (result) openProject(conn, result)
|
||||
}
|
||||
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
onSelect: resolve,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="mx-auto mt-55 w-full md:w-auto px-4">
|
||||
<Logo class="md:w-xl opacity-12" />
|
||||
<Button
|
||||
size="large"
|
||||
variant="ghost"
|
||||
class="mt-4 mx-auto text-14-regular text-text-weak"
|
||||
onClick={() => dialog.show(() => <DialogSelectServer />)}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-2 rounded-full": true,
|
||||
[serverDotClass()]: true,
|
||||
}}
|
||||
/>
|
||||
{server.name}
|
||||
</Button>
|
||||
<Switch>
|
||||
<Match when={sync().data.project.length > 0}>
|
||||
<div class="mt-20 w-full flex flex-col gap-4">
|
||||
<div class="flex gap-2 items-center justify-between pl-3">
|
||||
<div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
|
||||
<Button
|
||||
icon="folder-add-left"
|
||||
size="normal"
|
||||
class="pl-2 pr-3"
|
||||
disabled={serverUnreachable()}
|
||||
onClick={chooseProject}
|
||||
>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
<ul class="flex flex-col gap-2">
|
||||
<For each={recent()}>
|
||||
{(project) => (
|
||||
<Button
|
||||
size="large"
|
||||
variant="ghost"
|
||||
class="text-14-mono text-left justify-between px-3"
|
||||
onClick={() => openProject(server.current!, project.worktree)}
|
||||
>
|
||||
{project.worktree.replace(homedir(), "~")}
|
||||
<div class="text-14-regular text-text-weak">
|
||||
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={!sync().ready}>
|
||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
|
||||
<Button class="px-3" disabled={serverUnreachable()} onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||
<Icon name="folder-add-left" size="large" />
|
||||
<div class="flex flex-col gap-1 items-center justify-center">
|
||||
<div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
|
||||
<div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
|
||||
</div>
|
||||
<Button class="px-3 mt-1" disabled={serverUnreachable()} onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { TabsInfoPopup } from "@/components/help-button"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { setNavigate } from "@/utils/notification-click"
|
||||
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
||||
|
||||
export default function NewLayout(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
const navigate = useNavigate()
|
||||
setNavigate(navigate)
|
||||
const [state, setState] = createStore({ debugTools: true })
|
||||
|
||||
createEffect(() => setV2Toast(true))
|
||||
|
||||
const update: TitlebarUpdate = {
|
||||
version: () => {
|
||||
const state = platform.updater?.state()
|
||||
if (state?.status !== "ready") return
|
||||
return state.version
|
||||
},
|
||||
installing: () => platform.updater?.state().status === "installing",
|
||||
install: () => void platform.updater?.install(),
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
|
||||
style={{
|
||||
"padding-top": "env(safe-area-inset-top, 0px)",
|
||||
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
|
||||
}}
|
||||
>
|
||||
<Titlebar
|
||||
update={update}
|
||||
debugTools={
|
||||
import.meta.env.DEV
|
||||
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</main>
|
||||
{import.meta.env.DEV && state.debugTools && <DebugBar inline />}
|
||||
<TabsInfoPopup />
|
||||
<ToastRegion v2 />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+30
-2396
File diff suppressed because it is too large
Load Diff
@@ -1,126 +0,0 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { onCleanup, Show, type Accessor } from "solid-js"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
|
||||
export function createInlineEditorController() {
|
||||
// This controller intentionally supports one active inline editor at a time.
|
||||
const [editor, setEditor] = createStore({
|
||||
active: "" as string,
|
||||
value: "",
|
||||
})
|
||||
|
||||
const editorOpen = (id: string) => editor.active === id
|
||||
const editorValue = () => editor.value
|
||||
const openEditor = (id: string, value: string) => {
|
||||
if (!id) return
|
||||
setEditor({ active: id, value })
|
||||
}
|
||||
const closeEditor = () => setEditor({ active: "", value: "" })
|
||||
|
||||
const saveEditor = (callback: (next: string) => void) => {
|
||||
const next = editor.value.trim()
|
||||
if (!next) {
|
||||
closeEditor()
|
||||
return
|
||||
}
|
||||
closeEditor()
|
||||
callback(next)
|
||||
}
|
||||
|
||||
const editorKeyDown = (event: KeyboardEvent, callback: (next: string) => void) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
saveEditor(callback)
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
closeEditor()
|
||||
}
|
||||
|
||||
const InlineEditor = (props: {
|
||||
id: string
|
||||
value: Accessor<string>
|
||||
onSave: (next: string) => void
|
||||
class?: string
|
||||
displayClass?: string
|
||||
editing?: boolean
|
||||
stopPropagation?: boolean
|
||||
openOnDblClick?: boolean
|
||||
}) => {
|
||||
let frame: number | undefined
|
||||
|
||||
onCleanup(() => {
|
||||
if (frame === undefined) return
|
||||
cancelAnimationFrame(frame)
|
||||
})
|
||||
|
||||
const isEditing = () => props.editing ?? editorOpen(props.id)
|
||||
const stopEvents = () => props.stopPropagation ?? false
|
||||
const allowDblClick = () => props.openOnDblClick ?? true
|
||||
const stopPropagation = (event: Event) => {
|
||||
if (!stopEvents()) return
|
||||
event.stopPropagation()
|
||||
}
|
||||
const handleDblClick = (event: MouseEvent) => {
|
||||
if (!allowDblClick()) return
|
||||
stopPropagation(event)
|
||||
openEditor(props.id, props.value())
|
||||
}
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={isEditing()}
|
||||
fallback={
|
||||
<span
|
||||
class={props.displayClass ?? props.class}
|
||||
onDblClick={handleDblClick}
|
||||
onPointerDown={stopPropagation}
|
||||
onMouseDown={stopPropagation}
|
||||
onClick={stopPropagation}
|
||||
onTouchStart={stopPropagation}
|
||||
>
|
||||
{props.value()}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
if (!el.isConnected) return
|
||||
el.focus()
|
||||
})
|
||||
}}
|
||||
value={editorValue()}
|
||||
class={props.class}
|
||||
onInput={(event) => setEditor("value", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
editorKeyDown(event, props.onSave)
|
||||
}}
|
||||
onBlur={closeEditor}
|
||||
onPointerDown={stopPropagation}
|
||||
onClick={stopPropagation}
|
||||
onDblClick={stopPropagation}
|
||||
onMouseDown={stopPropagation}
|
||||
onMouseUp={stopPropagation}
|
||||
onTouchStart={stopPropagation}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
editor,
|
||||
editorOpen,
|
||||
editorValue,
|
||||
openEditor,
|
||||
closeEditor,
|
||||
saveEditor,
|
||||
editorKeyDown,
|
||||
setEditor,
|
||||
InlineEditor,
|
||||
}
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { A, useParams } from "@solidjs/router"
|
||||
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionLabel } from "@/utils/session-title"
|
||||
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
||||
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
|
||||
|
||||
export const ProjectIcon = (props: {
|
||||
project: LocalProject
|
||||
class?: string
|
||||
notify?: boolean
|
||||
working?: boolean
|
||||
}): JSX.Element => {
|
||||
const serverSync = useServerSync()
|
||||
const notification = useNotification()
|
||||
const permission = usePermission()
|
||||
const dirs = createMemo(() => [props.project.worktree, ...(props.project.sandboxes ?? [])])
|
||||
const unseenCount = createMemo(() =>
|
||||
dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
|
||||
)
|
||||
const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory)))
|
||||
const hasPermissions = createMemo(() =>
|
||||
dirs().some((directory) => {
|
||||
return hasProjectPermissions(serverSync().session.data.permission, (item) => {
|
||||
if (serverSync().session.get(item.sessionID)?.location.directory !== directory) return false
|
||||
return !permission.autoResponds(item, directory)
|
||||
})
|
||||
}),
|
||||
)
|
||||
const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0))
|
||||
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
||||
|
||||
return (
|
||||
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
|
||||
<div class="size-full rounded overflow-clip">
|
||||
<Avatar
|
||||
fallback={name()}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
{...getAvatarColors(props.project.icon?.color)}
|
||||
class="size-full rounded"
|
||||
classList={{ "badge-mask": notify() }}
|
||||
/>
|
||||
</div>
|
||||
<Show when={notify()}>
|
||||
<div
|
||||
classList={{
|
||||
"absolute top-px right-px size-1.5 rounded-full z-10": true,
|
||||
"bg-surface-warning-strong": hasPermissions(),
|
||||
"bg-icon-critical-base": !hasPermissions() && hasError(),
|
||||
"bg-text-interactive-base": !hasPermissions() && !hasError(),
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={props.working}>
|
||||
<div class="absolute bottom-px right-px size-3 rounded-full bg-background-base z-10 flex items-center justify-center">
|
||||
<Spinner class="size-[9px]" />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type SessionItemProps = {
|
||||
session: SessionInfo
|
||||
list: SessionInfo[]
|
||||
navList?: Accessor<SessionInfo[]>
|
||||
slug: string
|
||||
mobile?: boolean
|
||||
dense?: boolean
|
||||
showTooltip?: boolean
|
||||
showChild?: boolean
|
||||
level?: number
|
||||
sidebarExpanded: Accessor<boolean>
|
||||
clearHoverProjectSoon: () => void
|
||||
prefetchSession: (session: SessionInfo, priority?: "high" | "low") => void
|
||||
archiveSession: (session: SessionInfo) => Promise<void>
|
||||
}
|
||||
|
||||
const SessionRow = (props: {
|
||||
session: SessionInfo
|
||||
slug: string
|
||||
mobile?: boolean
|
||||
dense?: boolean
|
||||
tint: Accessor<string | undefined>
|
||||
isWorking: Accessor<boolean>
|
||||
hasPermissions: Accessor<boolean>
|
||||
hasError: Accessor<boolean>
|
||||
unseenCount: Accessor<number>
|
||||
clearHoverProjectSoon: () => void
|
||||
sidebarOpened: Accessor<boolean>
|
||||
warmPress: () => void
|
||||
warmFocus: () => void
|
||||
}): JSX.Element => {
|
||||
const title = () => sessionLabel(props.session)
|
||||
|
||||
return (
|
||||
<A
|
||||
href={`/${props.slug}/session/${props.session.id}`}
|
||||
class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
onPointerDown={props.warmPress}
|
||||
onFocus={props.warmFocus}
|
||||
onClick={() => {
|
||||
if (props.sidebarOpened()) return
|
||||
props.clearHoverProjectSoon()
|
||||
}}
|
||||
>
|
||||
<Show when={props.isWorking() || props.hasPermissions() || props.hasError() || props.unseenCount() > 0}>
|
||||
<div
|
||||
class="shrink-0 size-6 flex items-center justify-center"
|
||||
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={props.isWorking()}>
|
||||
<Spinner class="size-[15px]" />
|
||||
</Match>
|
||||
<Match when={props.hasPermissions()}>
|
||||
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
||||
</Match>
|
||||
<Match when={props.hasError()}>
|
||||
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
||||
</Match>
|
||||
<Match when={props.unseenCount() > 0}>
|
||||
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</Show>
|
||||
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{title()}</span>
|
||||
</A>
|
||||
)
|
||||
}
|
||||
|
||||
export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
const params = useParams()
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const notification = useNotification()
|
||||
const permission = usePermission()
|
||||
const serverSync = useServerSync()
|
||||
const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
|
||||
const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
|
||||
const [sessionStore] = serverSync().child(props.session.location.directory)
|
||||
const hasPermissions = createMemo(() => {
|
||||
return !!sessionPermissionRequest(
|
||||
sessionStore.session,
|
||||
serverSync().session.data.permission,
|
||||
props.session.id,
|
||||
(item) => {
|
||||
return !permission.autoResponds(item, props.session.location.directory)
|
||||
},
|
||||
)
|
||||
})
|
||||
const isWorking = createMemo(() => {
|
||||
if (hasPermissions()) return false
|
||||
return serverSync().session.data.session_working(props.session.id)
|
||||
})
|
||||
|
||||
const tint = createMemo(() =>
|
||||
messageAgentColor(serverSync().session.data.message[props.session.id], sessionStore.agent),
|
||||
)
|
||||
const tooltip = createMemo(() => props.showTooltip ?? (props.mobile || !props.sidebarExpanded()))
|
||||
const currentChild = createMemo(() => {
|
||||
if (!props.showChild) return
|
||||
return childSessionOnPath(sessionStore.session, props.session.id, params.id)
|
||||
})
|
||||
|
||||
const warm = (span: number, priority: "high" | "low") => {
|
||||
const nav = props.navList?.()
|
||||
const list = nav?.some(
|
||||
(item) => item.id === props.session.id && item.location.directory === props.session.location.directory,
|
||||
)
|
||||
? nav
|
||||
: props.list
|
||||
|
||||
props.prefetchSession(props.session, priority)
|
||||
|
||||
const idx = list.findIndex(
|
||||
(item) => item.id === props.session.id && item.location.directory === props.session.location.directory,
|
||||
)
|
||||
if (idx === -1) return
|
||||
|
||||
for (let step = 1; step <= span; step++) {
|
||||
const next = list[idx + step]
|
||||
if (next) props.prefetchSession(next, step === 1 ? "high" : priority)
|
||||
|
||||
const prev = list[idx - step]
|
||||
if (prev) props.prefetchSession(prev, step === 1 ? "high" : priority)
|
||||
}
|
||||
}
|
||||
|
||||
const item = (
|
||||
<SessionRow
|
||||
session={props.session}
|
||||
slug={props.slug}
|
||||
mobile={props.mobile}
|
||||
dense={props.dense}
|
||||
tint={tint}
|
||||
isWorking={isWorking}
|
||||
hasPermissions={hasPermissions}
|
||||
hasError={hasError}
|
||||
unseenCount={unseenCount}
|
||||
clearHoverProjectSoon={props.clearHoverProjectSoon}
|
||||
sidebarOpened={layout.sidebar.opened}
|
||||
warmPress={() => warm(2, "high")}
|
||||
warmFocus={() => warm(2, "high")}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-session-id={props.session.id}
|
||||
class="group/session relative w-full min-w-0 rounded-md cursor-default pr-3 transition-colors hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
|
||||
style={{ "padding-left": `${8 + (props.level ?? 0) * 16}px` }}
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<div class="min-w-0 flex-1">
|
||||
<Show
|
||||
when={!tooltip()}
|
||||
fallback={
|
||||
<Tooltip
|
||||
placement={props.mobile ? "bottom" : "right"}
|
||||
value={sessionLabel(props.session)}
|
||||
gutter={10}
|
||||
class="min-w-0 w-full"
|
||||
>
|
||||
{item}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* TODO: Restore the archive action when the V2 client exposes session archive. */}
|
||||
<Show when={false}>
|
||||
<div
|
||||
class="shrink-0 overflow-hidden transition-[width,opacity]"
|
||||
classList={{
|
||||
"w-6 opacity-100 pointer-events-auto": !!props.mobile,
|
||||
"w-0 opacity-0 pointer-events-none": !props.mobile,
|
||||
"group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
|
||||
"group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
|
||||
}}
|
||||
>
|
||||
<Tooltip value={language.t("common.archive")} placement="top">
|
||||
<IconButton
|
||||
icon="archive"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md"
|
||||
aria-label={language.t("common.archive")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.archiveSession(props.session)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={currentChild()} keyed>
|
||||
{(child) => (
|
||||
<div class="w-full">
|
||||
<SessionItem {...props} session={child} level={(props.level ?? 0) + 1} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const NewSessionItem = (props: {
|
||||
slug: string
|
||||
mobile?: boolean
|
||||
dense?: boolean
|
||||
sidebarExpanded: Accessor<boolean>
|
||||
clearHoverProjectSoon: () => void
|
||||
}): JSX.Element => {
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const label = language.t("command.session.new")
|
||||
const tooltip = () => props.mobile || !props.sidebarExpanded()
|
||||
const item = (
|
||||
<A
|
||||
href={`/${props.slug}/session`}
|
||||
end
|
||||
class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
onClick={() => {
|
||||
if (layout.sidebar.opened()) return
|
||||
props.clearHoverProjectSoon()
|
||||
}}
|
||||
>
|
||||
<div class="shrink-0 size-6 flex items-center justify-center">
|
||||
<IconV2 name="edit" size="small" class="text-icon-weak" />
|
||||
</div>
|
||||
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{label}</span>
|
||||
</A>
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="group/session relative w-full min-w-0 rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
|
||||
<Show
|
||||
when={!tooltip()}
|
||||
fallback={
|
||||
<Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10} class="min-w-0 w-full">
|
||||
{item}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SessionSkeleton = (props: { count?: number }): JSX.Element => {
|
||||
const items = Array.from({ length: props.count ?? 4 }, (_, index) => index)
|
||||
return (
|
||||
<div class="flex flex-col gap-1">
|
||||
<For each={items}>
|
||||
{() => <div class="h-8 w-full rounded-md bg-surface-raised-base opacity-60 animate-pulse" />}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { ContextMenu } from "@opencode-ai/ui/context-menu"
|
||||
import { HoverCard } from "@opencode-ai/ui/hover-card"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { createSortable } from "@thisbeyond/solid-dnd"
|
||||
import { useLayout, type LocalProject } from "@/context/layout"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
|
||||
import { displayName, sortedRootSessions } from "./helpers"
|
||||
|
||||
export type ProjectSidebarContext = {
|
||||
currentDir: Accessor<string>
|
||||
currentProject: Accessor<LocalProject | undefined>
|
||||
sidebarOpened: Accessor<boolean>
|
||||
sidebarHovering: Accessor<boolean>
|
||||
hoverProject: Accessor<string | undefined>
|
||||
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
|
||||
onProjectMouseLeave: (worktree: string) => void
|
||||
onProjectFocus: (worktree: string) => void
|
||||
onHoverOpenChanged: (worktree: string, hovered: boolean) => void
|
||||
navigateToProject: (directory: string) => void
|
||||
openSidebar: () => void
|
||||
closeProject: (directory: string) => void
|
||||
showEditProjectDialog: (project: LocalProject) => void
|
||||
toggleProjectWorkspaces: (project: LocalProject) => void
|
||||
workspacesEnabled: (project: LocalProject) => boolean
|
||||
workspaceIds: (project: LocalProject) => string[]
|
||||
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
|
||||
sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "mobile" | "dense">
|
||||
}
|
||||
|
||||
export const ProjectDragOverlay = (props: {
|
||||
projects: Accessor<LocalProject[]>
|
||||
activeProject: Accessor<string | undefined>
|
||||
}): JSX.Element => {
|
||||
const project = createMemo(() => props.projects().find((p) => p.worktree === props.activeProject()))
|
||||
return (
|
||||
<Show when={project()}>
|
||||
{(p) => (
|
||||
<div class="bg-background-base rounded-xl p-1">
|
||||
<ProjectIcon project={p()} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const ProjectTile = (props: {
|
||||
project: LocalProject
|
||||
mobile?: boolean
|
||||
sidebarHovering: Accessor<boolean>
|
||||
selected: Accessor<boolean>
|
||||
active: Accessor<boolean>
|
||||
isWorking: Accessor<boolean>
|
||||
overlay: Accessor<boolean>
|
||||
suppressHover: Accessor<boolean>
|
||||
dirs: Accessor<string[]>
|
||||
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
|
||||
onProjectMouseLeave: (worktree: string) => void
|
||||
onProjectFocus: (worktree: string) => void
|
||||
navigateToProject: (directory: string) => void
|
||||
showEditProjectDialog: (project: LocalProject) => void
|
||||
toggleProjectWorkspaces: (project: LocalProject) => void
|
||||
workspacesEnabled: (project: LocalProject) => boolean
|
||||
closeProject: (directory: string) => void
|
||||
setMenu: (value: boolean) => void
|
||||
setOpen: (value: boolean) => void
|
||||
setSuppressHover: (value: boolean) => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}): JSX.Element => {
|
||||
const notification = useNotification()
|
||||
const layout = useLayout()
|
||||
const unseenCount = createMemo(() =>
|
||||
props.dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
|
||||
)
|
||||
|
||||
const clear = () =>
|
||||
props
|
||||
.dirs()
|
||||
.filter((directory) => notification.project.unseenCount(directory) > 0)
|
||||
.forEach((directory) => notification.project.markViewed(directory))
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
modal={!props.sidebarHovering()}
|
||||
onOpenChange={(value) => {
|
||||
props.setMenu(value)
|
||||
props.setSuppressHover(value)
|
||||
if (value) props.setOpen(false)
|
||||
}}
|
||||
>
|
||||
<ContextMenu.Trigger
|
||||
as="button"
|
||||
type="button"
|
||||
aria-label={displayName(props.project)}
|
||||
data-action="project-switch"
|
||||
data-project={base64Encode(props.project.worktree)}
|
||||
classList={{
|
||||
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
||||
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": props.selected(),
|
||||
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
||||
!props.selected() && !props.active(),
|
||||
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button === 0 && !event.ctrlKey) {
|
||||
props.setOpen(false)
|
||||
props.setSuppressHover(true)
|
||||
return
|
||||
}
|
||||
if (!props.overlay()) return
|
||||
if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return
|
||||
props.setOpen(false)
|
||||
props.setSuppressHover(true)
|
||||
event.preventDefault()
|
||||
}}
|
||||
onMouseEnter={(event: MouseEvent) => {
|
||||
if (!props.overlay()) return
|
||||
if (props.suppressHover()) return
|
||||
props.onProjectMouseEnter(props.project.worktree, event)
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (props.suppressHover()) props.setSuppressHover(false)
|
||||
if (!props.overlay()) return
|
||||
props.onProjectMouseLeave(props.project.worktree)
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!props.overlay()) return
|
||||
if (props.suppressHover()) return
|
||||
props.onProjectFocus(props.project.worktree)
|
||||
}}
|
||||
onClick={() => {
|
||||
props.setOpen(false)
|
||||
if (props.selected()) {
|
||||
layout.sidebar.toggle()
|
||||
return
|
||||
}
|
||||
props.navigateToProject(props.project.worktree)
|
||||
}}
|
||||
onBlur={() => props.setOpen(false)}
|
||||
>
|
||||
<ProjectIcon project={props.project} notify working={props.isWorking()} />
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
|
||||
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
data-action="project-workspaces-toggle"
|
||||
data-project={base64Encode(props.project.worktree)}
|
||||
disabled={props.project.vcs !== "git" && !props.workspacesEnabled(props.project)}
|
||||
onSelect={() => props.toggleProjectWorkspaces(props.project)}
|
||||
>
|
||||
<ContextMenu.ItemLabel>
|
||||
{props.workspacesEnabled(props.project)
|
||||
? props.language.t("sidebar.workspaces.disable")
|
||||
: props.language.t("sidebar.workspaces.enable")}
|
||||
</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
data-action="project-clear-notifications"
|
||||
data-project={base64Encode(props.project.worktree)}
|
||||
disabled={unseenCount() === 0}
|
||||
onSelect={clear}
|
||||
>
|
||||
<ContextMenu.ItemLabel>{props.language.t("sidebar.project.clearNotifications")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item
|
||||
data-action="project-close-menu"
|
||||
data-project={base64Encode(props.project.worktree)}
|
||||
onSelect={() => props.closeProject(props.project.worktree)}
|
||||
>
|
||||
<ContextMenu.ItemLabel>{props.language.t("common.close")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const ProjectPreviewPanel = (props: {
|
||||
project: LocalProject
|
||||
mobile?: boolean
|
||||
selected: Accessor<boolean>
|
||||
workspaceEnabled: Accessor<boolean>
|
||||
workspaces: Accessor<string[]>
|
||||
label: (directory: string) => string
|
||||
projectSessions: Accessor<ReturnType<typeof sortedRootSessions>>
|
||||
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
|
||||
ctx: ProjectSidebarContext
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}): JSX.Element => (
|
||||
<div class="-m-3 p-2 flex flex-col w-72">
|
||||
<div class="px-4 pt-2 pb-1 flex items-center gap-2">
|
||||
<div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div>
|
||||
</div>
|
||||
<div class="px-4 pb-2 text-12-medium text-text-weak">{props.language.t("sidebar.project.recentSessions")}</div>
|
||||
<div class="px-2 pb-2 flex flex-col gap-2">
|
||||
<Show
|
||||
when={props.workspaceEnabled()}
|
||||
fallback={
|
||||
<For each={props.projectSessions().slice(0, 2)}>
|
||||
{(session) => (
|
||||
<SessionItem
|
||||
{...props.ctx.sessionProps}
|
||||
session={session}
|
||||
list={props.projectSessions()}
|
||||
slug={base64Encode(props.project.worktree)}
|
||||
dense
|
||||
showTooltip
|
||||
mobile={props.mobile}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
}
|
||||
>
|
||||
<For each={props.workspaces()}>
|
||||
{(directory) => {
|
||||
const sessions = createMemo(() => props.workspaceSessions(directory))
|
||||
return (
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
|
||||
<div class="shrink-0 size-6 flex items-center justify-center">
|
||||
<Icon name="branch" size="small" class="text-icon-base" />
|
||||
</div>
|
||||
<span class="truncate text-14-medium text-text-base">{props.label(directory)}</span>
|
||||
</div>
|
||||
<For each={sessions().slice(0, 2)}>
|
||||
{(session) => (
|
||||
<SessionItem
|
||||
{...props.ctx.sessionProps}
|
||||
session={session}
|
||||
list={sessions()}
|
||||
slug={base64Encode(directory)}
|
||||
dense
|
||||
showTooltip
|
||||
mobile={props.mobile}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="px-2 py-2 border-t border-border-weak-base">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
|
||||
onClick={() => {
|
||||
props.ctx.openSidebar()
|
||||
props.ctx.onHoverOpenChanged(props.project.worktree, false)
|
||||
if (props.selected()) return
|
||||
props.ctx.navigateToProject(props.project.worktree)
|
||||
}}
|
||||
>
|
||||
{props.language.t("sidebar.project.viewAllSessions")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const SortableProject = (props: {
|
||||
project: LocalProject
|
||||
mobile?: boolean
|
||||
ctx: ProjectSidebarContext
|
||||
sortNow: Accessor<number>
|
||||
}): JSX.Element => {
|
||||
const serverSync = useServerSync()
|
||||
const language = useLanguage()
|
||||
const sortable = createSortable(props.project.worktree)
|
||||
const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree)
|
||||
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
|
||||
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
|
||||
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
|
||||
const [state, setState] = createStore({
|
||||
menu: false,
|
||||
suppressHover: false,
|
||||
})
|
||||
|
||||
const isHoverProject = () => props.ctx.hoverProject() === props.project.worktree
|
||||
const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
|
||||
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
|
||||
const active = createMemo(() => state.menu || (preview() ? isHoverProject() : overlay() && isHoverProject()))
|
||||
|
||||
const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
|
||||
|
||||
const label = (directory: string) => {
|
||||
const [data] = serverSync().child(directory, { bootstrap: false })
|
||||
const kind =
|
||||
directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
|
||||
const name = props.ctx.workspaceLabel(directory, data.vcs?.branch, props.project.id)
|
||||
return `${kind} : ${name}`
|
||||
}
|
||||
|
||||
const projectStore = createMemo(() => serverSync().child(props.project.worktree, { bootstrap: false })[0])
|
||||
const isWorking = createMemo(() =>
|
||||
dirs().some((directory) => {
|
||||
return Object.keys(serverSync().session.data.session_status).some((id) => {
|
||||
if (serverSync().session.get(id)?.location.directory !== directory) return false
|
||||
return serverSync().session.data.session_working(id)
|
||||
})
|
||||
}),
|
||||
)
|
||||
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
|
||||
const workspaceSessions = (directory: string) => {
|
||||
const [data] = serverSync().child(directory, { bootstrap: false })
|
||||
return sortedRootSessions(data, props.sortNow())
|
||||
}
|
||||
const tile = () => (
|
||||
<ProjectTile
|
||||
project={props.project}
|
||||
mobile={props.mobile}
|
||||
sidebarHovering={props.ctx.sidebarHovering}
|
||||
selected={selected}
|
||||
active={active}
|
||||
isWorking={isWorking}
|
||||
overlay={overlay}
|
||||
suppressHover={() => state.suppressHover}
|
||||
dirs={dirs}
|
||||
onProjectMouseEnter={props.ctx.onProjectMouseEnter}
|
||||
onProjectMouseLeave={props.ctx.onProjectMouseLeave}
|
||||
onProjectFocus={props.ctx.onProjectFocus}
|
||||
navigateToProject={props.ctx.navigateToProject}
|
||||
showEditProjectDialog={props.ctx.showEditProjectDialog}
|
||||
toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces}
|
||||
workspacesEnabled={props.ctx.workspacesEnabled}
|
||||
closeProject={props.ctx.closeProject}
|
||||
setMenu={(value) => setState("menu", value)}
|
||||
setOpen={(value) => props.ctx.onHoverOpenChanged(props.project.worktree, value)}
|
||||
setSuppressHover={(value) => setState("suppressHover", value)}
|
||||
language={language}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||
<Show when={preview() && !selected()} fallback={tile()}>
|
||||
<HoverCard
|
||||
open={!state.suppressHover && hoverOpen() && !state.menu}
|
||||
openDelay={0}
|
||||
closeDelay={0}
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
trigger={tile()}
|
||||
onOpenChange={(value) => {
|
||||
if (state.menu) return
|
||||
if (value && state.suppressHover) return
|
||||
props.ctx.onHoverOpenChanged(props.project.worktree, value)
|
||||
}}
|
||||
>
|
||||
<ProjectPreviewPanel
|
||||
project={props.project}
|
||||
mobile={props.mobile}
|
||||
selected={selected}
|
||||
workspaceEnabled={workspaceEnabled}
|
||||
workspaces={workspaces}
|
||||
label={label}
|
||||
projectSessions={projectSessions}
|
||||
workspaceSessions={workspaceSessions}
|
||||
ctx={props.ctx}
|
||||
language={language}
|
||||
/>
|
||||
</HoverCard>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
DragOverlay,
|
||||
SortableProvider,
|
||||
closestCenter,
|
||||
type DragEvent,
|
||||
} from "@thisbeyond/solid-dnd"
|
||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { type LocalProject } from "@/context/layout"
|
||||
|
||||
export const SidebarContent = (props: {
|
||||
mobile?: boolean
|
||||
opened: Accessor<boolean>
|
||||
aimMove: (event: MouseEvent) => void
|
||||
projects: Accessor<LocalProject[]>
|
||||
renderProject: (project: LocalProject) => JSX.Element
|
||||
handleDragStart: (event: unknown) => void
|
||||
handleDragEnd: () => void
|
||||
handleDragOver: (event: DragEvent) => void
|
||||
openProjectLabel: JSX.Element
|
||||
openProjectKeybind: Accessor<string | undefined>
|
||||
onOpenProject: () => void
|
||||
renderProjectOverlay: () => JSX.Element
|
||||
settingsLabel: Accessor<string>
|
||||
settingsKeybind: Accessor<string | undefined>
|
||||
onOpenSettings: () => void
|
||||
helpLabel: Accessor<string>
|
||||
onOpenHelp: () => void
|
||||
renderPanel: () => JSX.Element
|
||||
}): JSX.Element => {
|
||||
const expanded = createMemo(() => !!props.mobile || props.opened())
|
||||
const placement = () => (props.mobile ? "bottom" : "right")
|
||||
let panel: HTMLDivElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const el = panel
|
||||
if (!el) return
|
||||
if (expanded()) {
|
||||
el.removeAttribute("inert")
|
||||
return
|
||||
}
|
||||
el.setAttribute("inert", "")
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex h-full w-full min-w-0 overflow-hidden">
|
||||
<div
|
||||
data-component="sidebar-rail"
|
||||
class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden"
|
||||
onMouseMove={props.aimMove}
|
||||
>
|
||||
<div class="flex-1 min-h-0 w-full">
|
||||
<DragDropProvider
|
||||
onDragStart={props.handleDragStart}
|
||||
onDragEnd={props.handleDragEnd}
|
||||
onDragOver={props.handleDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragXAxis />
|
||||
<div class="h-full w-full flex flex-col items-center gap-3 px-3 py-3 overflow-y-auto no-scrollbar">
|
||||
<SortableProvider ids={props.projects().map((p) => p.worktree)}>
|
||||
<For each={props.projects()}>{(project) => props.renderProject(project)}</For>
|
||||
</SortableProvider>
|
||||
<Tooltip
|
||||
placement={placement()}
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{props.openProjectLabel}</span>
|
||||
<Show when={!props.mobile && !!props.openProjectKeybind()}>
|
||||
<span class="text-icon-base text-12-medium">{props.openProjectKeybind()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
icon="plus"
|
||||
variant="ghost"
|
||||
size="large"
|
||||
onClick={props.onOpenProject}
|
||||
aria-label={typeof props.openProjectLabel === "string" ? props.openProjectLabel : undefined}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<DragOverlay>{props.renderProjectOverlay()}</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
<div class="shrink-0 w-full pt-3 pb-6 flex flex-col items-center gap-2">
|
||||
<TooltipKeybind placement={placement()} title={props.settingsLabel()} keybind={props.settingsKeybind() ?? ""}>
|
||||
<IconButton
|
||||
icon="settings-gear"
|
||||
variant="ghost"
|
||||
size="large"
|
||||
onClick={props.onOpenSettings}
|
||||
aria-label={props.settingsLabel()}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
<Tooltip placement={placement()} value={props.helpLabel()}>
|
||||
<IconButton
|
||||
icon="help"
|
||||
variant="ghost"
|
||||
size="large"
|
||||
onClick={props.onOpenHelp}
|
||||
aria-label={props.helpLabel()}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={(el) => {
|
||||
panel = el
|
||||
}}
|
||||
classList={{ "flex-1 flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }}
|
||||
aria-hidden={!expanded()}
|
||||
>
|
||||
{props.renderPanel()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,489 +0,0 @@
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSortable } from "@thisbeyond/solid-dnd"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { type LocalProject } from "@/context/layout"
|
||||
import { useServerSync, useQueryOptions } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
|
||||
import { sortedRootSessions } from "./helpers"
|
||||
import { useIsFetching } from "@tanstack/solid-query"
|
||||
|
||||
type InlineEditorComponent = (props: {
|
||||
id: string
|
||||
value: Accessor<string>
|
||||
onSave: (next: string) => void
|
||||
class?: string
|
||||
displayClass?: string
|
||||
editing?: boolean
|
||||
stopPropagation?: boolean
|
||||
openOnDblClick?: boolean
|
||||
}) => JSX.Element
|
||||
|
||||
export type WorkspaceSidebarContext = {
|
||||
currentDir: Accessor<string>
|
||||
navList: Accessor<SessionInfo[]>
|
||||
sidebarExpanded: Accessor<boolean>
|
||||
sidebarHovering: Accessor<boolean>
|
||||
clearHoverProjectSoon: () => void
|
||||
prefetchSession: (session: SessionInfo, priority?: "high" | "low") => void
|
||||
archiveSession: (session: SessionInfo) => Promise<void>
|
||||
workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
|
||||
renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
|
||||
editorOpen: (id: string) => boolean
|
||||
openEditor: (id: string, value: string) => void
|
||||
closeEditor: () => void
|
||||
setEditor: (key: "value", value: string) => void
|
||||
InlineEditor: InlineEditorComponent
|
||||
isBusy: (directory: string) => boolean
|
||||
workspaceExpanded: (directory: string, local: boolean) => boolean
|
||||
setWorkspaceExpanded: (directory: string, value: boolean) => void
|
||||
showResetWorkspaceDialog: (root: string, directory: string) => void
|
||||
showDeleteWorkspaceDialog: (root: string, directory: string) => void
|
||||
setScrollContainerRef: (el: HTMLDivElement | undefined, mobile?: boolean) => void
|
||||
}
|
||||
|
||||
export const WorkspaceDragOverlay = (props: {
|
||||
sidebarProject: Accessor<LocalProject | undefined>
|
||||
activeWorkspace: Accessor<string | undefined>
|
||||
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
|
||||
}): JSX.Element => {
|
||||
const serverSync = useServerSync()
|
||||
const language = useLanguage()
|
||||
const label = createMemo(() => {
|
||||
const project = props.sidebarProject()
|
||||
if (!project) return
|
||||
const directory = props.activeWorkspace()
|
||||
if (!directory) return
|
||||
|
||||
const [workspaceStore] = serverSync().child(directory, { bootstrap: false })
|
||||
const kind =
|
||||
directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
|
||||
const name = props.workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
|
||||
return `${kind} : ${name}`
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={label()}>
|
||||
{(value) => <div class="bg-background-base rounded-md px-2 py-1 text-14-medium text-text-strong">{value()}</div>}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const WorkspaceHeader = (props: {
|
||||
local: Accessor<boolean>
|
||||
busy: Accessor<boolean>
|
||||
open: Accessor<boolean>
|
||||
directory: string
|
||||
language: ReturnType<typeof useLanguage>
|
||||
branch: Accessor<string | undefined>
|
||||
workspaceValue: Accessor<string>
|
||||
workspaceEditActive: Accessor<boolean>
|
||||
InlineEditor: WorkspaceSidebarContext["InlineEditor"]
|
||||
renameWorkspace: WorkspaceSidebarContext["renameWorkspace"]
|
||||
setEditor: WorkspaceSidebarContext["setEditor"]
|
||||
projectId?: string
|
||||
}): JSX.Element => (
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<div class="flex items-center justify-center shrink-0 size-6">
|
||||
<Show when={props.busy()} fallback={<Icon name="branch" size="small" />}>
|
||||
<Spinner class="size-[15px]" />
|
||||
</Show>
|
||||
</div>
|
||||
<span class="text-14-medium text-text-base shrink-0">
|
||||
{props.local() ? props.language.t("workspace.type.local") : props.language.t("workspace.type.sandbox")} :
|
||||
</span>
|
||||
<Show
|
||||
when={!props.local()}
|
||||
fallback={
|
||||
<span class="text-14-medium text-text-base min-w-0 truncate">
|
||||
{props.branch() ?? getFilename(props.directory)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<props.InlineEditor
|
||||
id={`workspace:${props.directory}`}
|
||||
value={props.workspaceValue}
|
||||
onSave={(next) => {
|
||||
const trimmed = next.trim()
|
||||
if (!trimmed) return
|
||||
props.renameWorkspace(props.directory, trimmed, props.projectId, props.branch())
|
||||
props.setEditor("value", props.workspaceValue())
|
||||
}}
|
||||
class="text-14-medium text-text-base min-w-0 truncate"
|
||||
displayClass="text-14-medium text-text-base min-w-0 truncate"
|
||||
editing={props.workspaceEditActive()}
|
||||
stopPropagation={false}
|
||||
openOnDblClick={false}
|
||||
/>
|
||||
</Show>
|
||||
<div class="flex items-center justify-center shrink-0 overflow-hidden w-0 opacity-0 transition-all duration-200 group-hover/workspace:w-3.5 group-hover/workspace:opacity-100 group-focus-within/workspace:w-3.5 group-focus-within/workspace:opacity-100">
|
||||
<Icon name={props.open() ? "chevron-down" : "chevron-right"} size="small" class="text-icon-base" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const WorkspaceActions = (props: {
|
||||
directory: string
|
||||
local: Accessor<boolean>
|
||||
busy: Accessor<boolean>
|
||||
menuOpen: Accessor<boolean>
|
||||
pendingRename: Accessor<boolean>
|
||||
setMenuOpen: (open: boolean) => void
|
||||
setPendingRename: (value: boolean) => void
|
||||
sidebarHovering: Accessor<boolean>
|
||||
touch: Accessor<boolean>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
workspaceValue: Accessor<string>
|
||||
openEditor: WorkspaceSidebarContext["openEditor"]
|
||||
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
|
||||
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
|
||||
root: string
|
||||
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
|
||||
navigateToNewSession: () => void
|
||||
}): JSX.Element => (
|
||||
<div
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
|
||||
classList={{
|
||||
"opacity-100 pointer-events-auto": props.menuOpen(),
|
||||
"opacity-0 pointer-events-none": !props.menuOpen(),
|
||||
"group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true,
|
||||
"group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu
|
||||
modal={!props.sidebarHovering()}
|
||||
open={props.menuOpen()}
|
||||
onOpenChange={(open) => props.setMenuOpen(open)}
|
||||
>
|
||||
<Tooltip value={props.language.t("common.moreOptions")} placement="top">
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md"
|
||||
data-action="workspace-menu"
|
||||
data-workspace={base64Encode(props.directory)}
|
||||
aria-label={props.language.t("common.moreOptions")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!props.pendingRename()) return
|
||||
event.preventDefault()
|
||||
props.setPendingRename(false)
|
||||
props.openEditor(`workspace:${props.directory}`, props.workspaceValue())
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
disabled={props.local()}
|
||||
onSelect={() => {
|
||||
props.setPendingRename(true)
|
||||
props.setMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{props.language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
// TODO: Restore reset when V2 exposes project-copy reset and instance disposal.
|
||||
// onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
|
||||
disabled
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled={props.local() || props.busy()}
|
||||
onSelect={() => props.showDeleteWorkspaceDialog(props.root, props.directory)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{props.language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
<Show when={!props.touch()}>
|
||||
<Tooltip value={props.language.t("command.session.new")} placement="top">
|
||||
<IconButtonV2
|
||||
icon={<IconV2 name="edit" size="small" />}
|
||||
variant="ghost"
|
||||
size="small"
|
||||
class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto"
|
||||
data-action="workspace-new-session"
|
||||
data-workspace={base64Encode(props.directory)}
|
||||
aria-label={props.language.t("command.session.new")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.clearHoverProjectSoon()
|
||||
props.navigateToNewSession()
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
const WorkspaceSessionList = (props: {
|
||||
slug: Accessor<string>
|
||||
mobile?: boolean
|
||||
ctx: WorkspaceSidebarContext
|
||||
showNew: Accessor<boolean>
|
||||
loading: Accessor<boolean>
|
||||
sessions: Accessor<SessionInfo[]>
|
||||
hasMore: Accessor<boolean>
|
||||
loadMore: () => Promise<void>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}): JSX.Element => (
|
||||
<nav class="flex flex-col gap-1">
|
||||
<Show when={props.showNew()}>
|
||||
<NewSessionItem
|
||||
slug={props.slug()}
|
||||
mobile={props.mobile}
|
||||
sidebarExpanded={props.ctx.sidebarExpanded}
|
||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={props.loading()}>
|
||||
<SessionSkeleton />
|
||||
</Show>
|
||||
<For each={props.sessions()}>
|
||||
{(session) => (
|
||||
<SessionItem
|
||||
session={session}
|
||||
list={props.sessions()}
|
||||
navList={props.ctx.navList}
|
||||
slug={props.slug()}
|
||||
mobile={props.mobile}
|
||||
showChild
|
||||
sidebarExpanded={props.ctx.sidebarExpanded}
|
||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||
prefetchSession={props.ctx.prefetchSession}
|
||||
archiveSession={props.ctx.archiveSession}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<Show when={props.hasMore()}>
|
||||
<div class="relative w-full py-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-2 pr-10"
|
||||
size="large"
|
||||
onClick={(e: MouseEvent) => {
|
||||
void props.loadMore()
|
||||
;(e.currentTarget as HTMLButtonElement).blur()
|
||||
}}
|
||||
>
|
||||
{props.language.t("common.loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</nav>
|
||||
)
|
||||
|
||||
export const SortableWorkspace = (props: {
|
||||
ctx: WorkspaceSidebarContext
|
||||
directory: string
|
||||
project: LocalProject
|
||||
sortNow: Accessor<number>
|
||||
mobile?: boolean
|
||||
}): JSX.Element => {
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const serverSync = useServerSync()
|
||||
const queryOptions = useQueryOptions()
|
||||
const language = useLanguage()
|
||||
const sortable = createSortable(props.directory)
|
||||
const [workspaceStore, setWorkspaceStore] = serverSync().child(props.directory, { bootstrap: false })
|
||||
const [menu, setMenu] = createStore({
|
||||
open: false,
|
||||
pendingRename: false,
|
||||
})
|
||||
const slug = createMemo(() => base64Encode(props.directory))
|
||||
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
|
||||
const local = createMemo(() => props.directory === props.project.worktree)
|
||||
const active = createMemo(() => pathKey(props.ctx.currentDir()) === pathKey(props.directory))
|
||||
const workspaceValue = createMemo(() => {
|
||||
const branch = workspaceStore.vcs?.branch
|
||||
const name = branch ?? getFilename(props.directory)
|
||||
return props.ctx.workspaceName(props.directory, props.project.id, branch) ?? name
|
||||
})
|
||||
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
|
||||
const boot = createMemo(() => open() || active())
|
||||
const count = createMemo(() => sessions()?.length ?? 0)
|
||||
const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
|
||||
const fetching = useIsFetching(() => queryOptions().sessions(pathKey(props.directory)))
|
||||
const busy = createMemo(() => props.ctx.isBusy(props.directory))
|
||||
const loading = () => fetching() > 0 && count() === 0
|
||||
const touch = createMediaQuery("(hover: none)")
|
||||
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
|
||||
const loadMore = async () => {
|
||||
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
|
||||
await serverSync().project.loadSessions(props.directory)
|
||||
}
|
||||
|
||||
const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`))
|
||||
const header = () => (
|
||||
<WorkspaceHeader
|
||||
local={local}
|
||||
busy={busy}
|
||||
open={open}
|
||||
directory={props.directory}
|
||||
language={language}
|
||||
branch={() => workspaceStore.vcs?.branch}
|
||||
workspaceValue={workspaceValue}
|
||||
workspaceEditActive={workspaceEditActive}
|
||||
InlineEditor={props.ctx.InlineEditor}
|
||||
renameWorkspace={props.ctx.renameWorkspace}
|
||||
setEditor={props.ctx.setEditor}
|
||||
projectId={props.project.id}
|
||||
/>
|
||||
)
|
||||
|
||||
const openWrapper = (value: boolean) => {
|
||||
props.ctx.setWorkspaceExpanded(props.directory, value)
|
||||
if (value) return
|
||||
if (props.ctx.editorOpen(`workspace:${props.directory}`)) props.ctx.closeEditor()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!boot()) return
|
||||
serverSync().child(props.directory, { bootstrap: true })
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
// @ts-ignore
|
||||
use:sortable
|
||||
classList={{
|
||||
"opacity-30": sortable.isActiveDraggable,
|
||||
"opacity-50 pointer-events-none": busy(),
|
||||
}}
|
||||
>
|
||||
<Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
|
||||
<div class="py-1">
|
||||
<div
|
||||
class="group/workspace relative"
|
||||
data-component="workspace-item"
|
||||
data-workspace={base64Encode(props.directory)}
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<Show
|
||||
when={workspaceEditActive()}
|
||||
fallback={
|
||||
<Collapsible.Trigger
|
||||
class={`flex items-center justify-between w-full pl-2 py-1.5 rounded-md hover:bg-surface-raised-base-hover transition-[padding] duration-200 ${
|
||||
menu.open ? "pr-16" : "pr-2"
|
||||
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
|
||||
data-action="workspace-toggle"
|
||||
data-workspace={base64Encode(props.directory)}
|
||||
>
|
||||
{header()}
|
||||
</Collapsible.Trigger>
|
||||
}
|
||||
>
|
||||
<div
|
||||
class={`flex items-center justify-between w-full pl-2 py-1.5 rounded-md transition-[padding] duration-200 ${
|
||||
menu.open ? "pr-16" : "pr-2"
|
||||
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
|
||||
>
|
||||
{header()}
|
||||
</div>
|
||||
</Show>
|
||||
<WorkspaceActions
|
||||
directory={props.directory}
|
||||
local={local}
|
||||
busy={busy}
|
||||
menuOpen={() => menu.open}
|
||||
pendingRename={() => menu.pendingRename}
|
||||
setMenuOpen={(open) => setMenu("open", open)}
|
||||
setPendingRename={(value) => setMenu("pendingRename", value)}
|
||||
sidebarHovering={props.ctx.sidebarHovering}
|
||||
touch={touch}
|
||||
language={language}
|
||||
workspaceValue={workspaceValue}
|
||||
openEditor={props.ctx.openEditor}
|
||||
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
|
||||
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
|
||||
root={props.project.worktree}
|
||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||
navigateToNewSession={() => navigate(`/${slug()}/session`)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapsible.Content>
|
||||
<WorkspaceSessionList
|
||||
slug={slug}
|
||||
mobile={props.mobile}
|
||||
ctx={props.ctx}
|
||||
showNew={showNew}
|
||||
loading={loading}
|
||||
sessions={sessions}
|
||||
hasMore={hasMore}
|
||||
loadMore={loadMore}
|
||||
language={language}
|
||||
/>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const LocalWorkspace = (props: {
|
||||
ctx: WorkspaceSidebarContext
|
||||
project: LocalProject
|
||||
sortNow: Accessor<number>
|
||||
mobile?: boolean
|
||||
}): JSX.Element => {
|
||||
const serverSync = useServerSync()
|
||||
const queryOptions = useQueryOptions()
|
||||
const language = useLanguage()
|
||||
const workspace = createMemo(() => {
|
||||
const [store, setStore] = serverSync().child(props.project.worktree)
|
||||
return { store, setStore }
|
||||
})
|
||||
const slug = createMemo(() => base64Encode(props.project.worktree))
|
||||
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
|
||||
const count = createMemo(() => sessions()?.length ?? 0)
|
||||
const fetching = useIsFetching(() => queryOptions().sessions(pathKey(props.project.worktree)))
|
||||
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
|
||||
const loading = () => fetching() > 0 && count() === 0
|
||||
const loadMore = async () => {
|
||||
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
|
||||
await serverSync().project.loadSessions(props.project.worktree)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
|
||||
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
|
||||
>
|
||||
<WorkspaceSessionList
|
||||
slug={slug}
|
||||
mobile={props.mobile}
|
||||
ctx={props.ctx}
|
||||
showNew={() => false}
|
||||
loading={loading}
|
||||
sessions={sessions}
|
||||
hasMore={hasMore}
|
||||
loadMore={loadMore}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -688,6 +688,8 @@ export default function Page() {
|
||||
return {
|
||||
queryKey: [...vcsKey(), mode] as const,
|
||||
enabled,
|
||||
refetchOnMount: "always" as const,
|
||||
refetchOnWindowFocus: true,
|
||||
queryFn: mode
|
||||
? () =>
|
||||
sdk()
|
||||
@@ -701,6 +703,16 @@ export default function Page() {
|
||||
}
|
||||
})
|
||||
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
|
||||
createEffect(
|
||||
on(
|
||||
() => desktopReviewOpen() || mobileChanges(),
|
||||
(open, previous) => {
|
||||
if (!open || previous || !desktopFileTreeOpen() || vcsQuery.isFetching) return
|
||||
refreshVcs()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const reviewDiffs = () => {
|
||||
if (reviewMode() === "git" || reviewMode() === "branch")
|
||||
// avoids suspense
|
||||
@@ -947,19 +959,6 @@ export default function Page() {
|
||||
),
|
||||
)
|
||||
|
||||
const stopVcs = sdk().event.listen((evt) => {
|
||||
const details = evt.details as { type: string; properties?: unknown }
|
||||
if (details.type !== "file.watcher.updated" && details.type !== "filesystem.changed") return
|
||||
const props =
|
||||
typeof details.properties === "object" && details.properties
|
||||
? (details.properties as Record<string, unknown>)
|
||||
: undefined
|
||||
const file = typeof props?.file === "string" ? props.file : undefined
|
||||
if (!file || file.startsWith(".git/")) return
|
||||
refreshVcs()
|
||||
})
|
||||
onCleanup(stopVcs)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sdk().directory,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { For } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DockShell } from "@opencode-ai/ui/dock-surface"
|
||||
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
|
||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
|
||||
export default {
|
||||
title: "Composer/Revert Dock",
|
||||
@@ -21,9 +21,6 @@ Real \`SessionRevertDock\` from app code, rendered above a mock composer card.
|
||||
The live composer overlaps the dock's bottom by 18px (\`session-composer-region-controller.ts\` \`lift()\`).
|
||||
The card below reproduces that overlap so the collapsed/expanded cutoff behavior can be verified in isolation.
|
||||
|
||||
### Layout split
|
||||
Use the **Layout** button to toggle \`newLayoutDesigns\` and preview both the v2 dock and the legacy (v1) \`DockTray\` fallback.
|
||||
|
||||
### Notes
|
||||
- \`onRestore\` only mutates local story state, so nothing in the real session is affected.
|
||||
- Click the header to expand/collapse. Click "Restore message" to remove a row.`,
|
||||
@@ -53,11 +50,9 @@ const btn = (accent?: boolean) =>
|
||||
}) as const
|
||||
|
||||
function Stage(props: { count: number }) {
|
||||
const settings = useSettings()
|
||||
const seed = () => messages.slice(0, props.count).map((text, index) => ({ id: `rolled-${index}`, text }))
|
||||
const [store, setStore] = createStore({ items: seed() })
|
||||
|
||||
const v2 = () => settings.general.newLayoutDesigns()
|
||||
const reset = () => setStore("items", seed())
|
||||
const restore = (id: string) =>
|
||||
setStore(
|
||||
@@ -71,22 +66,15 @@ function Stage(props: { count: number }) {
|
||||
<button style={btn()} onClick={reset}>
|
||||
Reset ({props.count})
|
||||
</button>
|
||||
<button style={btn(v2())} onClick={() => settings.general.setNewLayoutDesigns(!v2())}>
|
||||
Layout: {v2() ? "v2" : "v1"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Reproduce the real composer stack: dock + card overlapping the dock's bottom by lift() = 18px */}
|
||||
<div style={{ display: "flex", "flex-direction": "column" }}>
|
||||
<SessionRevertDock items={store.items} onRestore={restore} />
|
||||
<DockShell
|
||||
data-dock-border-underlay={v2() ? "v2" : "legacy"}
|
||||
data-dock-border-underlay="v2"
|
||||
style={{ position: "relative", "z-index": 70, "margin-top": "-18px" }}
|
||||
classList={{
|
||||
"min-h-24 w-full rounded-[12px] px-4 py-3 text-[13px]": true,
|
||||
"bg-v2-background-bg-base text-v2-text-text-faint": v2(),
|
||||
"text-text-weak": !v2(),
|
||||
}}
|
||||
class="min-h-24 w-full rounded-[12px] bg-v2-background-bg-base px-4 py-3 text-[13px] text-v2-text-text-faint"
|
||||
>
|
||||
Ask anything...
|
||||
</DockShell>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user