Compare commits
511 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6028df564 | |||
| d2843392c8 | |||
| fe63467a4d | |||
| 77cf46721f | |||
| 3ab34035e2 | |||
| 061ca12ec5 | |||
| 9f719059ab | |||
| 37561e4347 | |||
| a59bd3e0bc | |||
| 05e3c4ecee | |||
| 72d008bd5c | |||
| 202ec12056 | |||
| af30b315b2 | |||
| 425a4f584f | |||
| 7504daa602 | |||
| 9ac0f3e9ac | |||
| cf2cd13fb8 | |||
| 6883309997 | |||
| c3562a4a51 | |||
| 30a66dfc31 | |||
| fdc574ff81 | |||
| 1e5ddbd812 | |||
| 3109060216 | |||
| 9031ce7b51 | |||
| 2449b50585 | |||
| 66e6662440 | |||
| 56f3b9044d | |||
| ec58afe5be | |||
| 913659890d | |||
| 7bba9ed4d4 | |||
| b1a4f99181 | |||
| b0f6dc506b | |||
| 14e0b9b17f | |||
| 146cce195a | |||
| e5524f5bf9 | |||
| 16234a0baf | |||
| e5cf3ad3c2 | |||
| 82da3320ba | |||
| aa553dea94 | |||
| 0c9cad86ec | |||
| 69910f361c | |||
| 7bafbb309a | |||
| ec26d78450 | |||
| 5cfb70bff0 | |||
| 49e8d324dd | |||
| 60b9ba4db7 | |||
| a1a401dc9b | |||
| 2f1547ebaf | |||
| 62da1e7682 | |||
| 28a06e52fc | |||
| 5a5d981c4e | |||
| 62a7781e04 | |||
| 2d03cf4a9a | |||
| 4025d30b24 | |||
| dd3c443d8c | |||
| 4aaa9f9e30 | |||
| 31c805bc97 | |||
| 94f2ed1b84 | |||
| 86dc66eae9 | |||
| 9814dc6526 | |||
| f09c859974 | |||
| 1fcdb0246a | |||
| 340ad9e9ed | |||
| 76d814e747 | |||
| 9941e70d29 | |||
| 2afb812620 | |||
| ad2fc1ce4b | |||
| 5acc368ef4 | |||
| e2dc89c6f3 | |||
| e1581183ff | |||
| f195c952fc | |||
| 34b1045a01 | |||
| aa9956d46d | |||
| cec5a752bb | |||
| 0f0a4be253 | |||
| d73995a73a | |||
| 91ff955757 | |||
| 0e88dd99e7 | |||
| f965db9e13 | |||
| 519d344470 | |||
| d9ee222c99 | |||
| a78605f8ea | |||
| c0bc020ad6 | |||
| 0e669bbd18 | |||
| 00a6f228f2 | |||
| 1462f9caf3 | |||
| ebaad55c8c | |||
| f5c770d65e | |||
| 0ba1081cf1 | |||
| 0448a30821 | |||
| 848d763d08 | |||
| fdfd0afed7 | |||
| 5f31d2ca88 | |||
| afdae3950a | |||
| 50b869a508 | |||
| cf8b03ec10 | |||
| 1554a5a82e | |||
| 797359fbf0 | |||
| 034298cb1f | |||
| 1c4196c358 | |||
| 28dbd4ab4a | |||
| 715589fea4 | |||
| 27d6920d60 | |||
| 922903e410 | |||
| 541a25c2a1 | |||
| 1bf2b298e9 | |||
| 5ee18f9b75 | |||
| 26b1d14c16 | |||
| 249dc0e01e | |||
| 8ad03547df | |||
| 1a8fd0e1dc | |||
| 46140b0cc8 | |||
| ca354f8e8a | |||
| 13c9d097a0 | |||
| 1f66db0e4e | |||
| c71fe78970 | |||
| 717e74f3e5 | |||
| b5632ea700 | |||
| 8845a43e9e | |||
| 7e5305c765 | |||
| 245f00a4b3 | |||
| 15c5ec6d24 | |||
| 49707c2117 | |||
| dbb7872326 | |||
| 87e9e700cd | |||
| 4862c3e765 | |||
| b0fcba5724 | |||
| 2b3ddf9f34 | |||
| 3c7f608475 | |||
| 1ed14151a5 | |||
| 16a30041b3 | |||
| 5b02ac4d33 | |||
| 633b5d6208 | |||
| d595e472b3 | |||
| 0bfa55b623 | |||
| d96e3a5423 | |||
| dabf2dc013 | |||
| 775321173d | |||
| b46cec2a7d | |||
| a99ebd1d65 | |||
| a5ea910eac | |||
| 56743dcf04 | |||
| 00ea47a502 | |||
| 0373ea9128 | |||
| e1406e05a3 | |||
| b5553839d0 | |||
| 249381e742 | |||
| 7499021132 | |||
| 9dd24d7d03 | |||
| b2d76434ba | |||
| d18eab5b85 | |||
| 7a5a997173 | |||
| d200da121b | |||
| fe482fe3dd | |||
| 2fce3c1370 | |||
| 7060cfa59b | |||
| 8077e8a9d1 | |||
| d37166397c | |||
| 756c7c60bd | |||
| 94fb361591 | |||
| fdff82e6fc | |||
| b14e7451ab | |||
| 0de5f1ff36 | |||
| 7703786498 | |||
| 748fcb7ebd | |||
| d5f397a2da | |||
| 5ca613e28e | |||
| 9c7de47031 | |||
| f023c63a60 | |||
| 9495ecd536 | |||
| 03bb53c389 | |||
| eab34b46d0 | |||
| c5c9a1d435 | |||
| 47f333299b | |||
| db63eaf6ea | |||
| 0cf99cf5f9 | |||
| d16bfe850a | |||
| 728eae9c25 | |||
| eb84f461b8 | |||
| 0b3a1c2fdf | |||
| 1ccd14b0e1 | |||
| 61e7cdfbff | |||
| 7fe7b9f258 | |||
| a5ba1d075d | |||
| ba437069e6 | |||
| 387c5a02c8 | |||
| 4b1b722e98 | |||
| 6b03be5468 | |||
| 7d2c1ce6c6 | |||
| a9ef5a0fae | |||
| 0cf9a5d20b | |||
| 2c4ad9f405 | |||
| 930526ddcd | |||
| 3bf054c1d9 | |||
| 14c511e380 | |||
| 5ee0238c4a | |||
| 871d38cbd7 | |||
| 1a329e4e67 | |||
| bfb2d8dc76 | |||
| 8f7a6c4a00 | |||
| 1857c73565 | |||
| 5f42351159 | |||
| 968aaa3cfe | |||
| b8266e5819 | |||
| ba746e36d8 | |||
| d5068ba28e | |||
| 7265c46af6 | |||
| 536ee857c6 | |||
| dda69d77e8 | |||
| 0e14404e5f | |||
| 05f51bfe45 | |||
| aee552c043 | |||
| 4f6eaf859b | |||
| d0cb58782f | |||
| a3430db73a | |||
| 3e1972fd92 | |||
| b368e5adbe | |||
| 69e4f52272 | |||
| 8a55920531 | |||
| 9db90a0b76 | |||
| 700d012025 | |||
| 59e486a917 | |||
| 8596967415 | |||
| 00038027c8 | |||
| 5cf597d583 | |||
| d92b8d8009 | |||
| 854c535536 | |||
| 3cf955e9ad | |||
| 63f3e84792 | |||
| 0beb4de3e8 | |||
| 51da3483a9 | |||
| 060fbc9ce7 | |||
| 7a769dab3a | |||
| 2663ecd39d | |||
| 4ce247eaba | |||
| 76d9c2cd76 | |||
| f6101aef8a | |||
| f3874ec2f9 | |||
| 6466fcfdea | |||
| 1f0390cfbb | |||
| 9f06accfb4 | |||
| 7a9724496b | |||
| 3e931152d1 | |||
| ad1d14775d | |||
| fcf4dff2ce | |||
| 56714327f4 | |||
| 21f338652d | |||
| 8115f0c661 | |||
| 87d4cb07b0 | |||
| ee008923f3 | |||
| bbbef0da1c | |||
| 1268f8657e | |||
| 39e7ff932d | |||
| e92c4fb460 | |||
| 4b496066bf | |||
| e63dcd30f3 | |||
| 2935d1819e | |||
| 86907e2e4a | |||
| 31d2d38d7a | |||
| d21477d55a | |||
| 80fa6e6c49 | |||
| dd4aa2ab5a | |||
| e03ffb9ed4 | |||
| b99787e95b | |||
| 562d299a41 | |||
| 9ecb04e35b | |||
| 231689c767 | |||
| d70942079a | |||
| c9426d3a57 | |||
| eb5d30257a | |||
| 939426828d | |||
| 1499daf15f | |||
| 540525e98e | |||
| 0cc55c11ac | |||
| 40da77e77c | |||
| 0d2de7db65 | |||
| a568f616c9 | |||
| e4957a78ea | |||
| 7e35cff4f5 | |||
| 82b796ce31 | |||
| 6c24062d2f | |||
| 3ca09ee3ef | |||
| 003f1ce0f4 | |||
| 61390dbb49 | |||
| a58c3c53a9 | |||
| 75ce8feff5 | |||
| 6bee6ee755 | |||
| 9739d75892 | |||
| 4a976482b1 | |||
| 2caac055ef | |||
| 2697cb8001 | |||
| dbd709376b | |||
| fc08292136 | |||
| ee594ba55b | |||
| 516af86d53 | |||
| f5d20c580b | |||
| 7d5e91b801 | |||
| f70afbea34 | |||
| b275b12e90 | |||
| 4d900b2f46 | |||
| b2661c4a03 | |||
| 6de584f25c | |||
| 8c72edb3ff | |||
| b207e32249 | |||
| 6602341c0d | |||
| b32debb8a3 | |||
| 7ff392abe3 | |||
| 7566cfe602 | |||
| bea3ca5b05 | |||
| c64ac905e1 | |||
| 7a554441b4 | |||
| 14366af069 | |||
| d9ed62e51c | |||
| 39ea816a72 | |||
| 69eee26f30 | |||
| 8f3949d235 | |||
| facd207396 | |||
| 2b28cc9024 | |||
| d37f9e770b | |||
| 8fc02b0130 | |||
| ddf18a7f9c | |||
| fccfef1aaf | |||
| e0e8159965 | |||
| 9cd6e07a50 | |||
| c05ce3b725 | |||
| 26008696e1 | |||
| 6e177ee84a | |||
| 172fd971c5 | |||
| 8bfa188e07 | |||
| 5079fed63a | |||
| 4fae476873 | |||
| 4487fbf52f | |||
| fb9d69ef62 | |||
| ddd6eb4496 | |||
| 12bbe84360 | |||
| 661df8fcf8 | |||
| 16fb6dac8d | |||
| 93131b6e4c | |||
| 8643c0721e | |||
| 5334fd6b81 | |||
| 4cf3eb04cd | |||
| 571ae48e14 | |||
| 9a0a1a801e | |||
| d34eca2f0d | |||
| 5b1085d229 | |||
| 1f053c4942 | |||
| c25128a6f5 | |||
| 24277b51e3 | |||
| 26f64f29fb | |||
| 13822b9424 | |||
| 9332560ffd | |||
| 5aad254f68 | |||
| 0ba2bea40c | |||
| e16115f53f | |||
| 26bf234228 | |||
| 148b3cf034 | |||
| de69c5af0a | |||
| e5a4f6f603 | |||
| 4e8020b503 | |||
| 5778008596 | |||
| b70fc22a4b | |||
| affbea691b | |||
| cecaf82445 | |||
| 777169ac17 | |||
| 58f569fbf7 | |||
| 7dd8b2b7f9 | |||
| fcb4bc1b3a | |||
| 6ada6e0db9 | |||
| cc1d7e5d5b | |||
| 013bd4c918 | |||
| 4799efb9bf | |||
| 2eb3072e3e | |||
| 9cd611780f | |||
| 70443cd2de | |||
| ca8e6e4d67 | |||
| 7bcb519e0c | |||
| 4f6caacde4 | |||
| 81e907febe | |||
| ed4a41f1e0 | |||
| cea9a9905d | |||
| 09e4e5a184 | |||
| 2e0b993d5d | |||
| babaf781da | |||
| a17ce350f1 | |||
| 0e04141849 | |||
| 29130af9ec | |||
| 9235753aa0 | |||
| 796991b3ea | |||
| 6ae05c541d | |||
| bc84698428 | |||
| e3d2a9ddbb | |||
| 3d26bbed82 | |||
| 3ad63536bf | |||
| f347d9a58a | |||
| 3822c0aaec | |||
| 16ada93dd4 | |||
| 6fc5f342dd | |||
| 0e7e791008 | |||
| 11528e43c0 | |||
| 4560435dd9 | |||
| 3349fb95ca | |||
| bff9e576b7 | |||
| da2e640029 | |||
| 8fd7bd19d6 | |||
| 902ac2dad9 | |||
| 33f5b80235 | |||
| d04d13ea22 | |||
| cfcc6f1353 | |||
| 3360480a2a | |||
| 3e7e709884 | |||
| c510661ef3 | |||
| 12fa782137 | |||
| 66dfdb933d | |||
| 3b1970a0f4 | |||
| d8b1d86092 | |||
| d7111a7072 | |||
| 17bd7ffbf1 | |||
| 9fa3a99480 | |||
| 4318b0902d | |||
| 06b27db78e | |||
| fcc9cab760 | |||
| 0dae445f4f | |||
| 15092be204 | |||
| 482ef946fe | |||
| 421f3bbd0b | |||
| 76da54c596 | |||
| 1baa87bf0d | |||
| 8554345ba0 | |||
| b321a2de2b | |||
| 2cd61113c1 | |||
| c1dc769b5f | |||
| 08d422dca1 | |||
| 6f2a6356ed | |||
| 58ab95e32c | |||
| 5aa544179d | |||
| 482dc3a15d | |||
| 710469cf09 | |||
| e897883f91 | |||
| 8d8e8fe8f4 | |||
| df635562e9 | |||
| c575415ec4 | |||
| c0e449d3ac | |||
| ec1a5c261e | |||
| 4212931808 | |||
| 1a215700c8 | |||
| 9d4737fdc9 | |||
| d6f2b9f1b7 | |||
| 8af7b5cd65 | |||
| ba145fd61a | |||
| 02a8711f48 | |||
| 41a6067a04 | |||
| 0f92cec469 | |||
| 5acfe3a20c | |||
| 731c1e58f2 | |||
| 4ef581c111 | |||
| a888bf2194 | |||
| f8a23b428d | |||
| c92aaf8b80 | |||
| 79552d31e6 | |||
| 885bb25f13 | |||
| 987afa3d71 | |||
| a3f5c5d14d | |||
| c411d37484 | |||
| da2e91cf60 | |||
| f327584e0a | |||
| 97a94571a4 | |||
| 6652585a7f | |||
| 532b64c0d5 | |||
| eec4c775a7 | |||
| 01e350449c | |||
| 5792a80a8c | |||
| db039db7f5 | |||
| c1a3936b61 | |||
| a9d9e4d9c4 | |||
| 2531b2d3a9 | |||
| a718f86e0f | |||
| f3efdff861 | |||
| 955d8591df | |||
| 33b3388bf4 | |||
| 716f40b128 | |||
| 0b06ff1407 | |||
| 01ff5b5390 | |||
| 3d1b121e70 | |||
| b70629af27 | |||
| b7b016fa28 | |||
| 5ba2d7e5f0 | |||
| 459b22b83d | |||
| 377812b98a | |||
| 5cc0901e38 | |||
| 7fb6b589d1 | |||
| 3f37b43e7d | |||
| 8805dfc849 | |||
| ac5a5d8b16 | |||
| eaf94ed047 | |||
| b8031c5ae8 | |||
| a531f3f36d | |||
| bb3382311d | |||
| ad545d0cc9 | |||
| ac244b1458 | |||
| f202536b65 | |||
| 405cc3f610 | |||
| 878c1b8c2d | |||
| d5dcadc000 | |||
| 0c154e6a2f | |||
| 4f96975148 | |||
| eaba99711b | |||
| f762125775 | |||
| ded6bb6513 | |||
| 39332f5be6 | |||
| 2c6ff35400 | |||
| 738d6c8899 |
@@ -0,0 +1,12 @@
|
||||
.git
|
||||
.opencode
|
||||
.sst
|
||||
.turbo
|
||||
.wrangler
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/.output
|
||||
**/dist
|
||||
**/.turbo
|
||||
**/.vite
|
||||
**/coverage
|
||||
@@ -9,9 +9,15 @@ on:
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production')
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ github.ref_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
@@ -21,14 +27,11 @@ jobs:
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
# Workaround for Pulumi version conflict:
|
||||
# GitHub runners have Pulumi 3.212.0+ pre-installed, which removed the -root flag
|
||||
# from pulumi-language-nodejs (see https://github.com/pulumi/pulumi/pull/21065).
|
||||
# SST 3.17.x uses Pulumi SDK 3.210.0 which still passes -root, causing a conflict.
|
||||
# Removing the system language plugin forces SST to use its bundled compatible version.
|
||||
# TODO: Remove when sst supports Pulumi >3.210.0
|
||||
- name: Fix Pulumi version conflict
|
||||
run: sudo rm -f /usr/local/bin/pulumi-language-nodejs
|
||||
- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
|
||||
with:
|
||||
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
|
||||
role-session-name: opencode-${{ github.run_id }}
|
||||
aws-region: us-east-1
|
||||
|
||||
- run: bun sst deploy --stage=${{ github.ref_name }}
|
||||
env:
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
||||
- The default branch in this repo is `dev`.
|
||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
|
||||
|
||||
## Commits and PR Titles
|
||||
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ export const api = new sst.cloudflare.Worker("Api", {
|
||||
transform: {
|
||||
worker: (args) => {
|
||||
args.logpush = true
|
||||
if ($app.stage === "vimtor") return
|
||||
if ($app.stage === "vimtor" || $app.stage === "adam") return
|
||||
args.bindings = $resolve(args.bindings).apply((bindings) => [
|
||||
...bindings,
|
||||
{
|
||||
|
||||
+7
-3
@@ -1,7 +1,9 @@
|
||||
import { domain } from "./stage"
|
||||
import { deployAws, domain } from "./stage"
|
||||
import { EMAILOCTOPUS_API_KEY } from "./app"
|
||||
import { SECRET } from "./secret"
|
||||
|
||||
const lake = deployAws ? await import("./lake") : undefined
|
||||
|
||||
////////////////
|
||||
// DATABASE
|
||||
////////////////
|
||||
@@ -240,7 +242,7 @@ const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
|
||||
|
||||
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
|
||||
handler: "packages/console/function/src/log-processor.ts",
|
||||
link: [new sst.Secret("HONEYCOMB_API_KEY")],
|
||||
link: [SECRET.HoneycombApiKey, ...(lake?.lakeIngest ? [lake.lakeIngest] : [])],
|
||||
})
|
||||
|
||||
new sst.cloudflare.x.SolidStart("Console", {
|
||||
@@ -250,6 +252,8 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
bucket,
|
||||
bucketNew,
|
||||
database,
|
||||
SECRET.UpstashRedisRestUrl,
|
||||
SECRET.UpstashRedisRestToken,
|
||||
AUTH_API_URL,
|
||||
STRIPE_WEBHOOK_SECRET,
|
||||
DISCORD_INCIDENT_WEBHOOK_URL,
|
||||
@@ -281,7 +285,7 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
},
|
||||
transform: {
|
||||
server: {
|
||||
placement: { region: "aws:us-east-1" },
|
||||
placement: { region: "aws:us-east-2" },
|
||||
transform: {
|
||||
worker: {
|
||||
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
|
||||
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
import { domain } from "./stage"
|
||||
|
||||
const current = aws.getCallerIdentityOutput({})
|
||||
const partition = aws.getPartitionOutput({})
|
||||
const region = aws.getRegionOutput({})
|
||||
|
||||
const tableBucketName = `opencode-${$app.stage}-lake`
|
||||
const glueCatalogName = "s3tablescatalog"
|
||||
const glueCatalogArn = $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:catalog`
|
||||
const glueS3TablesCatalogArn = $interpolate`${glueCatalogArn}/${glueCatalogName}`
|
||||
const glueS3TablesChildCatalogArn = $interpolate`${glueS3TablesCatalogArn}/${tableBucketName}`
|
||||
const glueS3TablesDatabaseWildcardArn = $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:database/${glueCatalogName}/${tableBucketName}/*`
|
||||
const glueS3TablesTableWildcardArn = $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/${glueCatalogName}/${tableBucketName}/*/*`
|
||||
const s3TablesBucketWildcardArn = $interpolate`arn:${partition.partition}:s3tables:${region.region}:${current.accountId}:bucket/*`
|
||||
|
||||
export const tableBucket = new aws.s3tables.TableBucket("LakeTableBucket", {
|
||||
name: tableBucketName,
|
||||
forceDestroy: $app.stage !== "production",
|
||||
})
|
||||
|
||||
const s3TablesCatalog = new aws.cloudcontrol.Resource(
|
||||
"LakeS3TablesCatalog",
|
||||
{
|
||||
typeName: "AWS::Glue::Catalog",
|
||||
desiredState: $jsonStringify({
|
||||
Name: glueCatalogName,
|
||||
Description: "Federated catalog for S3 Tables",
|
||||
FederatedCatalog: {
|
||||
Identifier: s3TablesBucketWildcardArn,
|
||||
ConnectionName: "aws:s3tables",
|
||||
},
|
||||
CreateDatabaseDefaultPermissions: [
|
||||
{
|
||||
Principal: {
|
||||
DataLakePrincipalIdentifier: "IAM_ALLOWED_PRINCIPALS",
|
||||
},
|
||||
Permissions: ["ALL"],
|
||||
},
|
||||
],
|
||||
CreateTableDefaultPermissions: [
|
||||
{
|
||||
Principal: {
|
||||
DataLakePrincipalIdentifier: "IAM_ALLOWED_PRINCIPALS",
|
||||
},
|
||||
Permissions: ["ALL"],
|
||||
},
|
||||
],
|
||||
AllowFullTableExternalDataAccess: "True",
|
||||
}),
|
||||
},
|
||||
{ dependsOn: [tableBucket] },
|
||||
)
|
||||
|
||||
const athenaResultsBucket = new aws.s3.Bucket("LakeAthenaResults", {
|
||||
bucket: `opencode-${$app.stage}-lake-athena-results`,
|
||||
forceDestroy: $app.stage !== "production",
|
||||
})
|
||||
|
||||
const firehoseErrorBucket = new aws.s3.Bucket("LakeFirehoseErrors", {
|
||||
bucket: `opencode-${$app.stage}-lake-firehose-errors`,
|
||||
forceDestroy: $app.stage !== "production",
|
||||
})
|
||||
|
||||
const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", {
|
||||
name: `opencode-${$app.stage}-lake-workgroup`,
|
||||
forceDestroy: $app.stage !== "production",
|
||||
configuration: {
|
||||
enforceWorkgroupConfiguration: true,
|
||||
publishCloudwatchMetricsEnabled: true,
|
||||
resultConfiguration: {
|
||||
outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const firehoseRole = new aws.iam.Role("LakeFirehoseRole", {
|
||||
assumeRolePolicy: aws.iam.getPolicyDocumentOutput({
|
||||
statements: [
|
||||
{
|
||||
effect: "Allow",
|
||||
actions: ["sts:AssumeRole"],
|
||||
principals: [
|
||||
{
|
||||
type: "Service",
|
||||
identifiers: ["firehose.amazonaws.com"],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}).json,
|
||||
})
|
||||
|
||||
const firehosePolicy = new aws.iam.RolePolicy("LakeFirehosePolicy", {
|
||||
role: firehoseRole.id,
|
||||
policy: aws.iam.getPolicyDocumentOutput({
|
||||
statements: [
|
||||
{
|
||||
effect: "Allow",
|
||||
actions: [
|
||||
"s3tables:ListTableBuckets",
|
||||
"s3tables:GetTableBucket",
|
||||
"s3tables:GetNamespace",
|
||||
"s3tables:GetTable",
|
||||
"s3tables:GetTableData",
|
||||
"s3tables:GetTableMetadataLocation",
|
||||
"s3tables:ListNamespaces",
|
||||
"s3tables:ListTables",
|
||||
"s3tables:PutTableData",
|
||||
"s3tables:UpdateTableMetadataLocation",
|
||||
],
|
||||
resources: ["*"],
|
||||
},
|
||||
{
|
||||
effect: "Allow",
|
||||
actions: [
|
||||
"glue:GetCatalog",
|
||||
"glue:GetCatalogs",
|
||||
"glue:GetDatabase",
|
||||
"glue:GetDatabases",
|
||||
"glue:GetTable",
|
||||
"glue:GetTables",
|
||||
"glue:UpdateTable",
|
||||
],
|
||||
resources: [
|
||||
glueCatalogArn,
|
||||
glueS3TablesCatalogArn,
|
||||
$interpolate`${glueS3TablesCatalogArn}/*`,
|
||||
glueS3TablesDatabaseWildcardArn,
|
||||
glueS3TablesTableWildcardArn,
|
||||
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:database/*`,
|
||||
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/*/*`,
|
||||
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/${glueCatalogName}/*`,
|
||||
],
|
||||
},
|
||||
{
|
||||
effect: "Allow",
|
||||
actions: [
|
||||
"s3:AbortMultipartUpload",
|
||||
"s3:GetBucketLocation",
|
||||
"s3:GetObject",
|
||||
"s3:ListBucket",
|
||||
"s3:ListBucketMultipartUploads",
|
||||
"s3:PutObject",
|
||||
],
|
||||
resources: [firehoseErrorBucket.arn, $interpolate`${firehoseErrorBucket.arn}/*`],
|
||||
},
|
||||
{
|
||||
effect: "Allow",
|
||||
actions: ["lakeformation:GetDataAccess"],
|
||||
resources: ["*"],
|
||||
},
|
||||
],
|
||||
}).json,
|
||||
})
|
||||
|
||||
const firehose = new aws.kinesis.FirehoseDeliveryStream(
|
||||
"LakeFirehose",
|
||||
{
|
||||
name: `opencode-${$app.stage}-lake-ingest`,
|
||||
destination: "iceberg",
|
||||
icebergConfiguration: {
|
||||
appendOnly: true,
|
||||
bufferingInterval: 60,
|
||||
bufferingSize: 1,
|
||||
catalogArn: glueS3TablesChildCatalogArn,
|
||||
processingConfiguration: {
|
||||
enabled: true,
|
||||
processors: [
|
||||
{
|
||||
type: "MetadataExtraction",
|
||||
parameters: [
|
||||
{ parameterName: "JsonParsingEngine", parameterValue: "JQ-1.6" },
|
||||
{
|
||||
parameterName: "MetadataExtractionQuery",
|
||||
parameterValue:
|
||||
'{destinationDatabaseName:._lake_database,destinationTableName:._lake_table,operation:(._lake_operation // "insert")}',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
roleArn: firehoseRole.arn,
|
||||
s3BackupMode: "FailedDataOnly",
|
||||
s3Configuration: {
|
||||
roleArn: firehoseRole.arn,
|
||||
bucketArn: firehoseErrorBucket.arn,
|
||||
errorOutputPrefix: "errors/!{firehose:error-output-type}/",
|
||||
},
|
||||
},
|
||||
},
|
||||
{ dependsOn: [s3TablesCatalog, firehosePolicy] },
|
||||
)
|
||||
|
||||
export const lakeVpc = new sst.aws.Vpc("LakeVpc")
|
||||
export const lakeCluster = new sst.aws.Cluster("LakeCluster", { vpc: lakeVpc })
|
||||
export const lakeRegion = region.region
|
||||
export const lakeCatalog = $interpolate`${glueCatalogName}/${tableBucket.name}`
|
||||
export const lakeAthenaWorkgroup = athenaWorkgroup
|
||||
|
||||
const ingestSecret = new random.RandomPassword("LakeIngestSecret", { length: 32 })
|
||||
|
||||
const ingestConfig = new sst.Linkable("LakeIngestConfig", {
|
||||
properties: {
|
||||
streamName: firehose.name,
|
||||
secret: ingestSecret.result,
|
||||
},
|
||||
})
|
||||
|
||||
const ingestService = new sst.aws.Service("LakeIngestService", {
|
||||
cluster: lakeCluster,
|
||||
architecture: "arm64",
|
||||
cpu: "1 vCPU",
|
||||
memory: "4 GB",
|
||||
image: {
|
||||
context: ".",
|
||||
dockerfile: "packages/stats/server/Dockerfile",
|
||||
},
|
||||
link: [ingestConfig],
|
||||
permissions: [
|
||||
{
|
||||
actions: ["firehose:PutRecord", "firehose:PutRecordBatch"],
|
||||
resources: [firehose.arn],
|
||||
},
|
||||
],
|
||||
scaling: {
|
||||
min: $app.stage === "production" ? 2 : 1,
|
||||
max: $app.stage === "production" ? 32 : 4,
|
||||
cpuUtilization: 60,
|
||||
memoryUtilization: 70,
|
||||
},
|
||||
loadBalancer: {
|
||||
domain: {
|
||||
name: `lake.${domain}`,
|
||||
dns: sst.cloudflare.dns(),
|
||||
},
|
||||
rules: [
|
||||
{ listen: "80/http", redirect: "443/https" },
|
||||
{ listen: "443/https", forward: "3000/http" },
|
||||
],
|
||||
health: {
|
||||
"3000/http": {
|
||||
path: "/ready",
|
||||
successCodes: "200-299",
|
||||
},
|
||||
},
|
||||
},
|
||||
health: {
|
||||
command: [
|
||||
"CMD-SHELL",
|
||||
"bun --eval \"fetch('http://localhost:3000/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))\"",
|
||||
],
|
||||
interval: "30 seconds",
|
||||
retries: 3,
|
||||
startPeriod: "30 seconds",
|
||||
timeout: "5 seconds",
|
||||
},
|
||||
dev: {
|
||||
command: "bun run start",
|
||||
directory: "packages/stats/server",
|
||||
url: "http://localhost:3000",
|
||||
},
|
||||
wait: $app.stage === "production",
|
||||
})
|
||||
|
||||
export const lakeIngest = new sst.Linkable("LakeIngest", {
|
||||
properties: {
|
||||
url: ingestService.url,
|
||||
secret: ingestSecret.result,
|
||||
},
|
||||
})
|
||||
|
||||
export const lakeQueryPermissions = [
|
||||
{
|
||||
actions: ["athena:StartQueryExecution", "athena:GetQueryExecution", "athena:GetQueryResults"],
|
||||
resources: [athenaWorkgroup.arn],
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
"glue:GetCatalog",
|
||||
"glue:GetCatalogs",
|
||||
"glue:GetDatabase",
|
||||
"glue:GetDatabases",
|
||||
"glue:GetTable",
|
||||
"glue:GetTables",
|
||||
"glue:GetPartitions",
|
||||
],
|
||||
resources: [
|
||||
glueCatalogArn,
|
||||
glueS3TablesCatalogArn,
|
||||
$interpolate`${glueS3TablesCatalogArn}/*`,
|
||||
glueS3TablesDatabaseWildcardArn,
|
||||
glueS3TablesTableWildcardArn,
|
||||
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:database/*`,
|
||||
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/*/*`,
|
||||
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/${glueCatalogName}/*`,
|
||||
],
|
||||
},
|
||||
{
|
||||
actions: ["s3:GetBucketLocation", "s3:ListBucket"],
|
||||
resources: [athenaResultsBucket.arn],
|
||||
},
|
||||
{
|
||||
actions: ["s3:GetObject", "s3:PutObject", "s3:AbortMultipartUpload", "s3:ListBucketMultipartUploads"],
|
||||
resources: [$interpolate`${athenaResultsBucket.arn}/*`],
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
"s3tables:GetTableBucket",
|
||||
"s3tables:GetNamespace",
|
||||
"s3tables:GetTable",
|
||||
"s3tables:GetTableData",
|
||||
"s3tables:GetTableMetadataLocation",
|
||||
"s3tables:ListNamespaces",
|
||||
"s3tables:ListTables",
|
||||
],
|
||||
resources: ["*"],
|
||||
},
|
||||
{
|
||||
actions: ["lakeformation:GetDataAccess"],
|
||||
resources: ["*"],
|
||||
},
|
||||
]
|
||||
+11
-11
@@ -4,7 +4,7 @@ import { domain } from "./stage"
|
||||
const description = "Managed by SST (Don't edit in Honeycomb UI)"
|
||||
const alertsDisabled = $app.stage !== "production"
|
||||
|
||||
const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", {
|
||||
const webhookRecipient = new honeycombio.WebhookRecipient("DiscordAlerts", {
|
||||
name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`,
|
||||
url: `https://${domain}/honeycomb/webhook`,
|
||||
secret: SECRET.HoneycombWebhookSecret.result,
|
||||
@@ -67,7 +67,7 @@ IF(
|
||||
)`,
|
||||
})
|
||||
|
||||
return honeycomb.getQuerySpecificationOutput({
|
||||
return honeycombio.getQuerySpecificationOutput({
|
||||
breakdowns: ["model"],
|
||||
calculatedFields: [failedHttpStatus],
|
||||
calculations: [
|
||||
@@ -99,7 +99,7 @@ const providerHttpErrorsQuery = () => {
|
||||
expression: `IF(GT($llm.error.code, "400"), 1, 0)`,
|
||||
})
|
||||
|
||||
return honeycomb.getQuerySpecificationOutput({
|
||||
return honeycombio.getQuerySpecificationOutput({
|
||||
breakdowns: ["provider"],
|
||||
calculatedFields: [successHttpStatus, failedProviderHttpStatus],
|
||||
calculations: [
|
||||
@@ -140,7 +140,7 @@ const modelLowTpsQuery = (product: "go" | "zen") => {
|
||||
{ column: "tps.output", op: "exists" },
|
||||
]
|
||||
|
||||
return honeycomb.getQuerySpecificationOutput({
|
||||
return honeycombio.getQuerySpecificationOutput({
|
||||
breakdowns: ["model"],
|
||||
calculations: [
|
||||
{ op: "COUNT", name: "TOTAL", filterCombination: "AND", filters },
|
||||
@@ -157,7 +157,7 @@ const modelLowTpsQuery = (product: "go" | "zen") => {
|
||||
}).json
|
||||
}
|
||||
|
||||
new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
|
||||
new honeycombio.Trigger("IncreasedModelHttpErrorsGo", {
|
||||
name: "Increased Model HTTP Errors [Go]",
|
||||
description,
|
||||
disabled: alertsDisabled,
|
||||
@@ -177,7 +177,7 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
|
||||
],
|
||||
})
|
||||
|
||||
new honeycomb.Trigger("IncreasedModelHttpErrorsZen", {
|
||||
new honeycombio.Trigger("IncreasedModelHttpErrorsZen", {
|
||||
name: "Increased Model HTTP Errors [Zen]",
|
||||
description,
|
||||
disabled: alertsDisabled,
|
||||
@@ -197,7 +197,7 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsZen", {
|
||||
],
|
||||
})
|
||||
|
||||
new honeycomb.Trigger("LowModelTpsGo", {
|
||||
new honeycombio.Trigger("LowModelTpsGo", {
|
||||
name: "Low Model TPS [Go]",
|
||||
description,
|
||||
disabled: alertsDisabled,
|
||||
@@ -217,7 +217,7 @@ new honeycomb.Trigger("LowModelTpsGo", {
|
||||
],
|
||||
})
|
||||
|
||||
new honeycomb.Trigger("LowModelTpsZen", {
|
||||
new honeycombio.Trigger("LowModelTpsZen", {
|
||||
name: "Low Model TPS [Zen]",
|
||||
description,
|
||||
disabled: alertsDisabled,
|
||||
@@ -237,7 +237,7 @@ new honeycomb.Trigger("LowModelTpsZen", {
|
||||
],
|
||||
})
|
||||
|
||||
new honeycomb.Trigger("IncreasedProviderHttpErrors", {
|
||||
new honeycombio.Trigger("IncreasedProviderHttpErrors", {
|
||||
name: "Increased Provider HTTP Errors",
|
||||
description,
|
||||
disabled: alertsDisabled,
|
||||
@@ -257,11 +257,11 @@ new honeycomb.Trigger("IncreasedProviderHttpErrors", {
|
||||
],
|
||||
})
|
||||
|
||||
new honeycomb.Trigger("IncreasedFreeTierRequests", {
|
||||
new honeycombio.Trigger("IncreasedFreeTierRequests", {
|
||||
name: "Increased Free Tier Requests",
|
||||
description,
|
||||
disabled: alertsDisabled,
|
||||
queryJson: honeycomb.getQuerySpecificationOutput({
|
||||
queryJson: honeycombio.getQuerySpecificationOutput({
|
||||
calculations: [{ op: "COUNT" }],
|
||||
filters: [
|
||||
{ column: "event_type", op: "=", value: "completions" },
|
||||
|
||||
@@ -7,5 +7,8 @@ sst.Linkable.wrap(random.RandomPassword, (resource) => ({
|
||||
export const SECRET = {
|
||||
R2AccessKey: new sst.Secret("R2AccessKey", "unknown"),
|
||||
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
|
||||
HoneycombApiKey: new sst.Secret("HONEYCOMB_API_KEY"),
|
||||
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
|
||||
UpstashRedisRestUrl: new sst.Secret("UpstashRedisRestUrl"),
|
||||
UpstashRedisRestToken: new sst.Secret("UpstashRedisRestToken"),
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ export const domain = (() => {
|
||||
})()
|
||||
|
||||
export const zoneID = "430ba34c138cfb5360826c4909f99be8"
|
||||
export const awsStage = $app.stage === "production" ? "production" : "dev"
|
||||
export const deployAws = $app.stage === awsStage
|
||||
|
||||
new cloudflare.RegionalHostname("RegionalHostname", {
|
||||
hostname: domain,
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
import { lakeAthenaWorkgroup, lakeCatalog, lakeCluster, lakeQueryPermissions, lakeRegion, tableBucket } from "./lake"
|
||||
|
||||
const domain = (() => {
|
||||
if ($app.stage === "production") return "stats.opencode.ai"
|
||||
if ($app.stage === "dev") return "stats.dev.opencode.ai"
|
||||
return `stats.${$app.stage}.dev.opencode.ai`
|
||||
})()
|
||||
|
||||
////////////////
|
||||
// LAKE
|
||||
////////////////
|
||||
|
||||
const inferenceNamespace = new aws.s3tables.Namespace("LakeInferenceNamespace", {
|
||||
namespace: "inference",
|
||||
tableBucketArn: tableBucket.arn,
|
||||
})
|
||||
|
||||
const inferenceEventTable = new aws.s3tables.Table(
|
||||
"LakeInferenceEventTable",
|
||||
{
|
||||
name: "event",
|
||||
namespace: inferenceNamespace.namespace,
|
||||
tableBucketArn: inferenceNamespace.tableBucketArn,
|
||||
format: "ICEBERG",
|
||||
metadata: {
|
||||
iceberg: {
|
||||
schema: {
|
||||
fields: [
|
||||
{ name: "event_timestamp", type: "string", required: false },
|
||||
{ name: "event_date", type: "string", required: false },
|
||||
{ name: "event_type", type: "string", required: false },
|
||||
{ name: "dataset", type: "string", required: false },
|
||||
{ name: "cf_continent", type: "string", required: false },
|
||||
{ name: "cf_country", type: "string", required: false },
|
||||
{ name: "cf_city", type: "string", required: false },
|
||||
{ name: "cf_region", type: "string", required: false },
|
||||
{ name: "cf_latitude", type: "double", required: false },
|
||||
{ name: "cf_longitude", type: "double", required: false },
|
||||
{ name: "cf_timezone", type: "string", required: false },
|
||||
{ name: "duration", type: "double", required: false },
|
||||
{ name: "request_length", type: "long", required: false },
|
||||
{ name: "status", type: "int", required: false },
|
||||
{ name: "ip", type: "string", required: false },
|
||||
{ name: "is_stream", type: "boolean", required: false },
|
||||
{ name: "session", type: "string", required: false },
|
||||
{ name: "request", type: "string", required: false },
|
||||
{ name: "client", type: "string", required: false },
|
||||
{ name: "user_agent", type: "string", required: false },
|
||||
{ name: "model_variant", type: "string", required: false },
|
||||
{ name: "source", type: "string", required: false },
|
||||
{ name: "provider", type: "string", required: false },
|
||||
{ name: "provider_model", type: "string", required: false },
|
||||
{ name: "model", type: "string", required: false },
|
||||
{ name: "llm_error_code", type: "int", required: false },
|
||||
{ name: "llm_error_message", type: "string", required: false },
|
||||
{ name: "error_response", type: "string", required: false },
|
||||
{ name: "error_type", type: "string", required: false },
|
||||
{ name: "error_message", type: "string", required: false },
|
||||
{ name: "error_cause", type: "string", required: false },
|
||||
{ name: "error_cause2", type: "string", required: false },
|
||||
{ name: "api_key", type: "string", required: false },
|
||||
{ name: "workspace", type: "string", required: false },
|
||||
{ name: "is_subscription", type: "boolean", required: false },
|
||||
{ name: "subscription", type: "string", required: false },
|
||||
{ name: "response_length", type: "long", required: false },
|
||||
{ name: "time_to_first_byte", type: "long", required: false },
|
||||
{ name: "timestamp_first_byte", type: "long", required: false },
|
||||
{ name: "timestamp_last_byte", type: "long", required: false },
|
||||
{ name: "tokens_input", type: "long", required: false },
|
||||
{ name: "tokens_output", type: "long", required: false },
|
||||
{ name: "tokens_reasoning", type: "long", required: false },
|
||||
{ name: "tokens_cache_read", type: "long", required: false },
|
||||
{ name: "tokens_cache_write_5m", type: "long", required: false },
|
||||
{ name: "tokens_cache_write_1h", type: "long", required: false },
|
||||
{ name: "cost_input_microcents", type: "long", required: false },
|
||||
{ name: "cost_output_microcents", type: "long", required: false },
|
||||
{ name: "cost_cache_read_microcents", type: "long", required: false },
|
||||
{ name: "cost_cache_write_microcents", type: "long", required: false },
|
||||
{ name: "cost_total_microcents", type: "long", required: false },
|
||||
{ name: "cost_input", type: "long", required: false },
|
||||
{ name: "cost_output", type: "long", required: false },
|
||||
{ name: "cost_cache_read", type: "long", required: false },
|
||||
{ name: "cost_cache_write_5m", type: "long", required: false },
|
||||
{ name: "cost_cache_write_1h", type: "long", required: false },
|
||||
{ name: "cost_total", type: "long", required: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ deleteBeforeReplace: $app.stage !== "production" },
|
||||
)
|
||||
|
||||
export const inferenceEvent = new sst.Linkable("InferenceEvent", {
|
||||
properties: {
|
||||
region: lakeRegion,
|
||||
catalog: lakeCatalog,
|
||||
database: inferenceNamespace.namespace,
|
||||
table: inferenceEventTable.name,
|
||||
tableBucket: tableBucket.name,
|
||||
workgroup: lakeAthenaWorkgroup.name,
|
||||
},
|
||||
})
|
||||
|
||||
////////////////
|
||||
// DATABASE
|
||||
////////////////
|
||||
|
||||
const cluster = planetscale.getDatabaseOutput({
|
||||
name: "opencode-stats",
|
||||
organization: "anomalyco",
|
||||
})
|
||||
|
||||
const branch =
|
||||
$app.stage === "production"
|
||||
? planetscale.getBranchOutput({
|
||||
name: "production",
|
||||
organization: cluster.organization,
|
||||
database: cluster.name,
|
||||
})
|
||||
: new planetscale.Branch("StatsDatabaseBranch", {
|
||||
database: cluster.name,
|
||||
organization: cluster.organization,
|
||||
name: $app.stage,
|
||||
parentBranch: "production",
|
||||
})
|
||||
|
||||
const password = new planetscale.Password("StatsDatabasePassword", {
|
||||
name: $app.stage,
|
||||
database: cluster.name,
|
||||
organization: cluster.organization,
|
||||
branch: branch.name,
|
||||
})
|
||||
|
||||
const databaseUrl = $interpolate`mysql://${password.username.apply(encodeURIComponent)}:${password.plaintext.apply(
|
||||
encodeURIComponent,
|
||||
)}@${password.accessHostUrl}/${cluster.name}`
|
||||
|
||||
export const database = new sst.Linkable("StatsDatabase", {
|
||||
properties: {
|
||||
host: password.accessHostUrl,
|
||||
database: cluster.name,
|
||||
username: password.username,
|
||||
password: password.plaintext,
|
||||
port: 3306,
|
||||
url: databaseUrl,
|
||||
},
|
||||
})
|
||||
|
||||
new sst.x.DevCommand("StatsStudio", {
|
||||
link: [database],
|
||||
environment: {
|
||||
DATABASE_URL: databaseUrl,
|
||||
},
|
||||
dev: {
|
||||
command: "bun db:studio",
|
||||
directory: "packages/stats/core",
|
||||
autostart: false,
|
||||
},
|
||||
})
|
||||
|
||||
////////////////
|
||||
// APP
|
||||
////////////////
|
||||
|
||||
// export const app = new sst.cloudflare.x.SolidStart("Stats", {
|
||||
// path: "packages/stats/app",
|
||||
// buildCommand: "bun run build",
|
||||
// domain,
|
||||
// link: [database],
|
||||
// environment: {
|
||||
// PUBLIC_URL: `https://${domain}`,
|
||||
// },
|
||||
// })
|
||||
|
||||
////////////////
|
||||
// SERVICES
|
||||
////////////////
|
||||
|
||||
const statsSyncConfig = new sst.Linkable("StatsSyncConfig", {
|
||||
properties: {
|
||||
dataset: "zen",
|
||||
},
|
||||
})
|
||||
|
||||
export const statSync = new sst.aws.Service("StatsSyncService", {
|
||||
cluster: lakeCluster,
|
||||
architecture: "arm64",
|
||||
cpu: "0.25 vCPU",
|
||||
memory: "0.5 GB",
|
||||
image: {
|
||||
context: ".",
|
||||
dockerfile: "packages/stats/server/Dockerfile",
|
||||
},
|
||||
command: ["bun", "src/stat-sync.ts"],
|
||||
link: [database, inferenceEvent, statsSyncConfig],
|
||||
permissions: lakeQueryPermissions,
|
||||
scaling: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
},
|
||||
dev: {
|
||||
command: "bun src/stat-sync.ts",
|
||||
directory: "packages/stats/server",
|
||||
autostart: false,
|
||||
},
|
||||
})
|
||||
+10
-1
@@ -3,6 +3,7 @@
|
||||
stdenv,
|
||||
bun,
|
||||
nodejs,
|
||||
darwin,
|
||||
electron_41,
|
||||
makeWrapper,
|
||||
writableTmpDirAsHomeHook,
|
||||
@@ -14,7 +15,12 @@ let
|
||||
in
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "opencode-desktop";
|
||||
inherit (opencode) version src node_modules;
|
||||
inherit (opencode)
|
||||
version
|
||||
src
|
||||
node_modules
|
||||
patches
|
||||
;
|
||||
|
||||
nativeBuildInputs = [
|
||||
bun
|
||||
@@ -23,6 +29,9 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
writableTmpDirAsHomeHook
|
||||
] ++ lib.optionals stdenv.hostPlatform.isLinux [
|
||||
autoPatchelfHook
|
||||
] ++ lib.optionals stdenv.hostPlatform.isDarwin [
|
||||
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
|
||||
darwin.autoSignDarwinBinariesHook
|
||||
];
|
||||
|
||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-kCSAVPQgJROcvnnwf0Cn6PuYL25hYgTasJeBJlmnFgQ=",
|
||||
"aarch64-linux": "sha256-prY27Ek2QhW+4OvBJ3bHHkUDoLTA4mD3KQmOQqSbAuo=",
|
||||
"aarch64-darwin": "sha256-0yIqnnjreVHTgGZLrKFpT9Cc2B2LNfmYcRByaCu7tiU=",
|
||||
"x86_64-darwin": "sha256-n+urvMRozB9nO5D3qyCweSa5HExFk1YGEzOt2445LEE="
|
||||
"x86_64-linux": "sha256-KFVkxs0jtN1EXvwuxFMOlWg6BDr8L6K/wJZ/N73p8cI=",
|
||||
"aarch64-linux": "sha256-7OLglRBBt5prwb9e0I0iMTXi3MrDfDI+8xcDrxtq/JI=",
|
||||
"aarch64-darwin": "sha256-Q7iE6xJyomDnvqUgsHh+FlCnJFI6vK0zJSfI9BBYitQ=",
|
||||
"x86_64-darwin": "sha256-9lVsq2Bw74Vgphg6GSo/HxCtDXUyabxcS/vE1Um/Hfw="
|
||||
}
|
||||
}
|
||||
|
||||
+12
-6
@@ -10,6 +10,7 @@
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
"dev:stats": "bun sst shell --stage=dev -- bun run --cwd packages/stats/app dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
@@ -17,13 +18,14 @@
|
||||
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
|
||||
"prepare": "husky",
|
||||
"random": "echo 'Random script'",
|
||||
"hello": "echo 'Hello World!'",
|
||||
"sso": "aws sso login --sso-session=opencode --no-browser",
|
||||
"test": "echo 'do not run tests from root' && exit 1"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/*",
|
||||
"packages/console/*",
|
||||
"packages/stats/*",
|
||||
"packages/sdk/js",
|
||||
"packages/slack"
|
||||
],
|
||||
@@ -36,9 +38,9 @@
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.2.15",
|
||||
"@opentui/keymap": "0.2.15",
|
||||
"@opentui/solid": "0.2.15",
|
||||
"@opentui/core": "0.2.16",
|
||||
"@opentui/keymap": "0.2.16",
|
||||
"@opentui/solid": "0.2.16",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
@@ -72,6 +74,7 @@
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
"zod": "4.1.8",
|
||||
"remeda": "2.26.0",
|
||||
"sst": "4.13.1",
|
||||
"shiki": "3.20.0",
|
||||
"solid-list": "0.3.0",
|
||||
"tailwindcss": "4.1.11",
|
||||
@@ -98,7 +101,7 @@
|
||||
"oxlint-tsgolint": "0.21.0",
|
||||
"prettier": "3.6.2",
|
||||
"semver": "^7.6.0",
|
||||
"sst": "3.18.10",
|
||||
"sst": "catalog:",
|
||||
"turbo": "2.8.13"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -139,6 +142,9 @@
|
||||
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
|
||||
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const directory = "C:/OpenCode/TimelineStateRegression"
|
||||
const projectID = "proj_timeline_state_regression"
|
||||
const sessionID = "ses_timeline_state_regression"
|
||||
const userMessageID = "msg_user_regression"
|
||||
const assistantMessageID = "msg_assistant_regression"
|
||||
const editPartID = "prt_0001_edit"
|
||||
const textPartID = "prt_9999_text"
|
||||
const title = "Timeline collapse state regression"
|
||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
|
||||
type EventPayload = {
|
||||
directory: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__timelineDiffProbe: {
|
||||
reset: () => void
|
||||
shadowRoots: () => number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userMessage = {
|
||||
info: {
|
||||
id: userMessageID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 },
|
||||
summary: { diffs: [] },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_user_text",
|
||||
sessionID,
|
||||
messageID: userMessageID,
|
||||
type: "text",
|
||||
text: "Please edit the file.",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const editPart = {
|
||||
id: editPartID,
|
||||
sessionID,
|
||||
messageID: assistantMessageID,
|
||||
type: "tool",
|
||||
callID: "call_edit_regression",
|
||||
tool: "edit",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "src/regression.ts" },
|
||||
output: "Edited src/regression.ts",
|
||||
title: "src/regression.ts",
|
||||
metadata: {
|
||||
filediff: {
|
||||
file: "src/regression.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
before: "export const value = 'before'\n",
|
||||
after: "export const value = 'after'\n",
|
||||
},
|
||||
diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
|
||||
},
|
||||
time: { start: 1700000001000, end: 1700000002000 },
|
||||
},
|
||||
}
|
||||
|
||||
const streamedTextPart = {
|
||||
id: textPartID,
|
||||
sessionID,
|
||||
messageID: assistantMessageID,
|
||||
type: "text",
|
||||
text: "Streaming added a later assistant text part.",
|
||||
}
|
||||
|
||||
const assistantMessage = {
|
||||
info: {
|
||||
id: assistantMessageID,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created: 1700000001000 },
|
||||
parentID: userMessageID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
},
|
||||
parts: [editPart],
|
||||
}
|
||||
|
||||
test.describe("regression: session timeline local row state", () => {
|
||||
test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
|
||||
const events: EventPayload[] = []
|
||||
await mockServer(page, events)
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expect(page.getByRole("heading", { name: title })).toBeVisible()
|
||||
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
||||
await expect(wrapper).toBeVisible()
|
||||
await expectExpanded(wrapper, true)
|
||||
|
||||
await wrapper.evaluate((element) => {
|
||||
;(element as HTMLElement).dataset.regressionMarker = "before-stream"
|
||||
})
|
||||
await wrapper.locator('[data-slot="collapsible-trigger"]').first().click()
|
||||
await expectExpanded(wrapper, false)
|
||||
|
||||
events.push({
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.updated",
|
||||
properties: { part: streamedTextPart },
|
||||
},
|
||||
})
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
expect(await readToolState(page)).toEqual({
|
||||
expanded: false,
|
||||
row: "AssistantPart",
|
||||
streamedTextVisible: true,
|
||||
})
|
||||
})
|
||||
|
||||
test("does not remount an edit diff when sibling parts or diff counts update", async ({ page }) => {
|
||||
const events: EventPayload[] = []
|
||||
await installDiffProbe(page)
|
||||
await mockServer(page, events)
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expect(page.getByRole("heading", { name: title })).toBeVisible()
|
||||
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
||||
await expect(wrapper).toBeVisible()
|
||||
await expect(wrapper.locator('[data-component="file"][data-mode="diff"]').first()).toBeVisible()
|
||||
await markDiffProbe(page)
|
||||
|
||||
events.push({
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.updated",
|
||||
properties: { part: streamedTextPart },
|
||||
},
|
||||
})
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
|
||||
expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
|
||||
|
||||
await markDiffProbe(page)
|
||||
events.push({
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.updated",
|
||||
properties: { part: editPartWithAdditions(2) },
|
||||
},
|
||||
})
|
||||
|
||||
await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible(
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
|
||||
})
|
||||
})
|
||||
|
||||
async function configurePage(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: {
|
||||
editToolPartsExpanded: true,
|
||||
shellToolPartsExpanded: true,
|
||||
showReasoningSummaries: true,
|
||||
showSessionProgressBar: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function expectExpanded(locator: Locator, expected: boolean) {
|
||||
await expect.poll(() => locator.evaluate(readExpanded)).toBe(expected)
|
||||
}
|
||||
|
||||
async function readToolState(page: Page) {
|
||||
return page
|
||||
.locator(`[data-timeline-part-id="${editPartID}"]`)
|
||||
.first()
|
||||
.evaluate(
|
||||
(element, textPartID) => ({
|
||||
expanded: (() => {
|
||||
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
|
||||
const aria = trigger?.getAttribute("aria-expanded")
|
||||
if (aria === "true") return true
|
||||
if (aria === "false") return false
|
||||
|
||||
const root = element.querySelector('[data-component="collapsible"]')
|
||||
if (root?.hasAttribute("data-expanded")) return true
|
||||
if (root?.hasAttribute("data-closed")) return false
|
||||
|
||||
const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
|
||||
return !!content && content.getBoundingClientRect().height > 0
|
||||
})(),
|
||||
row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"),
|
||||
streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`),
|
||||
}),
|
||||
textPartID,
|
||||
)
|
||||
}
|
||||
|
||||
async function installDiffProbe(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
let shadowRootCount = 0
|
||||
const attachShadow = Element.prototype.attachShadow
|
||||
Element.prototype.attachShadow = function (init) {
|
||||
shadowRootCount += 1
|
||||
return attachShadow.call(this, init)
|
||||
}
|
||||
window.__timelineDiffProbe = {
|
||||
reset: () => {
|
||||
shadowRootCount = 0
|
||||
},
|
||||
shadowRoots: () => shadowRootCount,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function markDiffProbe(page: Page) {
|
||||
await page
|
||||
.locator(`[data-timeline-part-id="${editPartID}"]`)
|
||||
.first()
|
||||
.evaluate((element) => {
|
||||
const tool = element as HTMLElement
|
||||
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
|
||||
if (!file) throw new Error("missing edit diff file")
|
||||
|
||||
tool.dataset.timelineProbe = "before"
|
||||
file.dataset.timelineProbe = "before"
|
||||
window.__timelineDiffProbe.reset()
|
||||
})
|
||||
}
|
||||
|
||||
async function readDiffProbe(page: Page) {
|
||||
return page
|
||||
.locator(`[data-timeline-part-id="${editPartID}"]`)
|
||||
.first()
|
||||
.evaluate((element) => {
|
||||
const tool = element as HTMLElement
|
||||
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
|
||||
return {
|
||||
fileMarker: file?.dataset.timelineProbe,
|
||||
shadowRoots: window.__timelineDiffProbe.shadowRoots(),
|
||||
toolMarker: tool.dataset.timelineProbe,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function editPartWithAdditions(additions: number) {
|
||||
return {
|
||||
...editPart,
|
||||
state: {
|
||||
...editPart.state,
|
||||
metadata: {
|
||||
...editPart.state.metadata,
|
||||
filediff: {
|
||||
...editPart.state.metadata.filediff,
|
||||
additions,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function readExpanded(element: Element) {
|
||||
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
|
||||
const aria = trigger?.getAttribute("aria-expanded")
|
||||
if (aria === "true") return true
|
||||
if (aria === "false") return false
|
||||
|
||||
const root = element.querySelector('[data-component="collapsible"]')
|
||||
if (root?.hasAttribute("data-expanded")) return true
|
||||
if (root?.hasAttribute("data-closed")) return false
|
||||
|
||||
const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
|
||||
return !!content && content.getBoundingClientRect().height > 0
|
||||
}
|
||||
|
||||
async function mockServer(page: Page, events: EventPayload[]) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
sessions: [session()],
|
||||
pageMessages: () => ({ items: [userMessage, assistantMessage] }),
|
||||
events: () => events.splice(0),
|
||||
})
|
||||
}
|
||||
|
||||
function project() {
|
||||
return {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "timeline-state-regression",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
}
|
||||
}
|
||||
|
||||
function session() {
|
||||
return {
|
||||
id: sessionID,
|
||||
slug: "timeline-state-regression",
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
}
|
||||
}
|
||||
|
||||
function provider() {
|
||||
return {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
}
|
||||
}
|
||||
|
||||
function base64Encode(value: string) {
|
||||
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const directory = "C:/OpenCode/ContextResizeRegression"
|
||||
const projectID = "proj_context_resize_regression"
|
||||
const sessionID = "ses_context_resize_regression"
|
||||
const title = "Context resize regression"
|
||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
|
||||
const followingTextID = "prt_0104_text"
|
||||
|
||||
type Message = {
|
||||
info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
||||
parts: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
const messages = [...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(), ...turn(10, true)]
|
||||
|
||||
test.describe("regression: session timeline context group resize", () => {
|
||||
test("remeasures a recent explored context group before the next paint", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockServer(page)
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expect(page.getByRole("heading", { name: title })).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first()).toBeVisible()
|
||||
await settle(page)
|
||||
|
||||
const samples = await sampleExpansion(page)
|
||||
const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5)
|
||||
|
||||
console.log("context resize samples", JSON.stringify(samples, null, 2))
|
||||
|
||||
expect(samples[0]?.overlap).toBe(0)
|
||||
expect(visibleOverlap).toEqual([])
|
||||
expect(samples.at(-1)?.expanded).toBe("true")
|
||||
})
|
||||
})
|
||||
|
||||
async function configurePage(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: {
|
||||
editToolPartsExpanded: true,
|
||||
shellToolPartsExpanded: true,
|
||||
showReasoningSummaries: true,
|
||||
showSessionProgressBar: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function sampleExpansion(page: Page) {
|
||||
return page.evaluate(
|
||||
({ contextIDs, followingTextID }) =>
|
||||
new Promise<
|
||||
{
|
||||
frame: number
|
||||
label: string
|
||||
scrollTop: number
|
||||
scrollHeight: number
|
||||
contextBottom: number
|
||||
textTop: number
|
||||
overlap: number
|
||||
gap: number
|
||||
expanded: string | null
|
||||
}[]
|
||||
>((resolve) => {
|
||||
const context = document.querySelector<HTMLElement>(`[data-timeline-part-ids="${contextIDs.join(",")}"]`)
|
||||
const text = document.querySelector<HTMLElement>(`[data-timeline-part-id="${followingTextID}"]`)
|
||||
const scroller = context?.closest<HTMLElement>(".scroll-view__viewport")
|
||||
const trigger = context?.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')
|
||||
const contextRow = context?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
|
||||
const textRow = text?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
|
||||
if (!context || !text || !scroller || !trigger || !contextRow || !textRow)
|
||||
throw new Error("missing regression nodes")
|
||||
|
||||
scroller.scrollTop = scroller.scrollHeight
|
||||
const samples: {
|
||||
frame: number
|
||||
label: string
|
||||
scrollTop: number
|
||||
scrollHeight: number
|
||||
contextBottom: number
|
||||
textTop: number
|
||||
overlap: number
|
||||
gap: number
|
||||
expanded: string | null
|
||||
}[] = []
|
||||
const capture = (frame: number, label: string) => {
|
||||
const contextRect = contextRow.getBoundingClientRect()
|
||||
const textRect = textRow.getBoundingClientRect()
|
||||
samples.push({
|
||||
frame,
|
||||
label,
|
||||
scrollTop: Math.round(scroller.scrollTop * 10) / 10,
|
||||
scrollHeight: Math.round(scroller.scrollHeight * 10) / 10,
|
||||
contextBottom: Math.round(contextRect.bottom * 10) / 10,
|
||||
textTop: Math.round(textRect.top * 10) / 10,
|
||||
overlap: Math.max(0, Math.round((contextRect.bottom - textRect.top) * 10) / 10),
|
||||
gap: Math.max(0, Math.round((textRect.top - contextRect.bottom) * 10) / 10),
|
||||
expanded: trigger.getAttribute("aria-expanded"),
|
||||
})
|
||||
}
|
||||
|
||||
capture(-1, "before")
|
||||
trigger.click()
|
||||
capture(0, "sync-after-click")
|
||||
|
||||
let frame = 1
|
||||
const tick = () => {
|
||||
capture(frame, "raf")
|
||||
frame += 1
|
||||
if (frame > 8) {
|
||||
resolve(samples)
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
}),
|
||||
{ contextIDs, followingTextID },
|
||||
)
|
||||
}
|
||||
|
||||
function turn(index: number, target: boolean): Message[] {
|
||||
const userID = id("msg_user", index)
|
||||
const assistantID = id("msg_assistant", index)
|
||||
return [
|
||||
{
|
||||
info: {
|
||||
id: userID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
summary: { diffs: [] },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: [{ id: id("prt_user", index), sessionID, messageID: userID, type: "text", text: `User message ${index}` }],
|
||||
},
|
||||
{
|
||||
info: {
|
||||
id: assistantID,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 },
|
||||
parentID: userID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
finish: "stop",
|
||||
},
|
||||
parts: target
|
||||
? [
|
||||
contextTool(contextIDs[0]!, assistantID, "read", { filePath: "src/recent-a.ts", offset: 0, limit: 120 }),
|
||||
contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }),
|
||||
contextTool(contextIDs[2]!, assistantID, "grep", { path: directory, pattern: "Explored", include: "*.ts" }),
|
||||
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }),
|
||||
{
|
||||
id: followingTextID,
|
||||
sessionID,
|
||||
messageID: assistantID,
|
||||
type: "text",
|
||||
text: "This assistant text is immediately after the explored context group.",
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: id("prt_text", index),
|
||||
sessionID,
|
||||
messageID: assistantID,
|
||||
type: "text",
|
||||
text: `Assistant filler ${index}. ${"filler ".repeat(60)}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function contextTool(partID: string, messageID: string, tool: string, input: Record<string, unknown>) {
|
||||
return {
|
||||
id: partID,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "tool",
|
||||
callID: `call_${partID}`,
|
||||
tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
input,
|
||||
output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`,
|
||||
title: input.filePath || input.path || input.pattern || "completed",
|
||||
metadata: {},
|
||||
time: { start: 1700000000000, end: 1700000000100 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function mockServer(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
sessions: [session()],
|
||||
pageMessages: () => ({ items: messages }),
|
||||
})
|
||||
}
|
||||
|
||||
async function settle(page: Page) {
|
||||
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))
|
||||
}
|
||||
|
||||
function id(prefix: string, index: number) {
|
||||
return `${prefix}_${String(index).padStart(4, "0")}`
|
||||
}
|
||||
|
||||
function project() {
|
||||
return {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "context-resize-regression",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
}
|
||||
}
|
||||
|
||||
function session() {
|
||||
return {
|
||||
id: sessionID,
|
||||
slug: "context-resize-regression",
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
}
|
||||
}
|
||||
|
||||
function provider() {
|
||||
return {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
}
|
||||
}
|
||||
|
||||
function base64Encode(value: string) {
|
||||
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { fixture, pageMessages } from "./session-timeline.fixture"
|
||||
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
@@ -37,12 +38,12 @@ test.describe("smoke: session timeline", () => {
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
})
|
||||
await configureSmokePage(page)
|
||||
await configureSmokePage(page, fixture.directory)
|
||||
|
||||
await openProject(page, "SmokeProject")
|
||||
await navigateToSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
||||
await expectSessionReady(page, "smoke-project")
|
||||
await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||
await selectHomeProject(page, fixture.project.name)
|
||||
await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle)
|
||||
await expectSessionReady(page)
|
||||
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
|
||||
const expectedPartIDs = fixture.expected.targetPartIDs
|
||||
const expectedMessageIDs = fixture.expected.targetMessageIDs
|
||||
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
|
||||
@@ -50,7 +51,7 @@ test.describe("smoke: session timeline", () => {
|
||||
})
|
||||
})
|
||||
|
||||
async function configureSmokePage(page: Page) {
|
||||
async function configureSmokePage(page: Page, directory: string) {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
@@ -63,7 +64,23 @@ async function configureSmokePage(page: Page) {
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
await page.addInitScript((directory) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: {
|
||||
local: [{ worktree: directory, expanded: true }],
|
||||
},
|
||||
lastProject: {
|
||||
local: directory,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}, directory)
|
||||
|
||||
await page.addInitScript(() => {
|
||||
const smoke = window as SmokeWindow
|
||||
smoke.__timelineSmokeErrorToasts = []
|
||||
smoke.__timelineSmokeForbiddenText = []
|
||||
@@ -392,21 +409,20 @@ function expectCompleteScroll(
|
||||
expect(expectedPartIDs.length).toBe(331)
|
||||
}
|
||||
|
||||
async function openProject(page: Page, projectName: string) {
|
||||
async function selectHomeProject(page: Page, projectName: string) {
|
||||
await page.goto("/")
|
||||
await page.getByRole("button", { name: new RegExp(projectName, "i") }).click()
|
||||
await page
|
||||
.locator('[data-component="home-project-row"]')
|
||||
.filter({ hasText: new RegExp(projectName, "i") })
|
||||
.click()
|
||||
await expect(page).toHaveURL(/\/$/)
|
||||
}
|
||||
|
||||
async function navigateToSession(page: Page, sessionId: string, expectedTitle: string) {
|
||||
// Use evaluate to click to avoid strict visibility/animation issues during rapid e2e navigation
|
||||
await page
|
||||
.locator(`a[href*="${sessionId}"]`)
|
||||
.first()
|
||||
.evaluate((el) => (el as HTMLElement).click())
|
||||
async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) {
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionId}`)
|
||||
await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible()
|
||||
}
|
||||
|
||||
async function expectSessionReady(page: Page, projectName: string) {
|
||||
await expect(page.getByText(projectName).first()).toBeVisible()
|
||||
await expect(page.getByText("Ask anything...")).toBeVisible()
|
||||
async function expectSessionReady(page: Page) {
|
||||
await expect(page.getByRole("textbox", { name: /Ask anything/i })).toBeVisible()
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface MockServerConfig {
|
||||
project: unknown
|
||||
sessions: ({ id: string } & Record<string, unknown>)[]
|
||||
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
|
||||
events?: () => unknown[]
|
||||
}
|
||||
|
||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
@@ -43,7 +44,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (url.port !== targetPort) return route.fallback()
|
||||
|
||||
const path = url.pathname
|
||||
if (path === "/global/event" || path === "/event") return sse(route)
|
||||
if (path === "/global/event" || path === "/event") return sse(route, config.events?.())
|
||||
if (path === "/global/health") return json(route, { healthy: true })
|
||||
if (emptyObject.has(path)) return json(route, {})
|
||||
if (emptyList.has(path)) return json(route, [])
|
||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||
@@ -81,6 +83,10 @@ function json(route: Route, body: unknown, headers?: Record<string, string>) {
|
||||
})
|
||||
}
|
||||
|
||||
function sse(route: Route) {
|
||||
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||
function sse(route: Route, events?: unknown[]) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.6",
|
||||
"version": "1.15.11",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -42,10 +42,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/active-element": "2.1.3",
|
||||
"@solid-primitives/audio": "1.4.2",
|
||||
@@ -54,6 +54,7 @@
|
||||
"@solid-primitives/i18n": "2.2.1",
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.5",
|
||||
"@solid-primitives/scheduled": "1.5.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solid-primitives/timer": "1.4.4",
|
||||
|
||||
Binary file not shown.
+67
-49
@@ -14,6 +14,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
type Component,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
@@ -30,8 +31,8 @@ import { Dynamic } from "solid-js/web"
|
||||
import { CommandProvider } from "@/context/command"
|
||||
import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
import { GlobalSDKProvider } from "@/context/global-sdk"
|
||||
import { GlobalSyncProvider } from "@/context/global-sync"
|
||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider } from "@/context/server-sync"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
@@ -40,30 +41,27 @@ import { NotificationProvider } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
||||
import { TerminalProvider } from "@/context/terminal"
|
||||
import { WslServersProvider } from "@/context/wsl-servers"
|
||||
import DirectoryLayout from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import { ServersProvider } from "./context/servers"
|
||||
|
||||
const HomeRoute = lazy(() => import("@/pages/home"))
|
||||
const loadSession = () => import("@/pages/session")
|
||||
const Session = lazy(loadSession)
|
||||
const Loading = () => <div class="size-full" />
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
|
||||
if (typeof location === "object" && /\/session(?:\/|$)/.test(location.pathname)) {
|
||||
void loadSession()
|
||||
}
|
||||
|
||||
const SessionRoute = () => (
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
const SessionRoute = Object.assign(
|
||||
() => (
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
),
|
||||
{ preload: Session.preload },
|
||||
)
|
||||
|
||||
const SessionIndexRoute = () => <Navigate href="session" />
|
||||
|
||||
function UiI18nBridge(props: ParentProps) {
|
||||
const language = useLanguage()
|
||||
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
||||
@@ -74,10 +72,11 @@ declare global {
|
||||
__OPENCODE__?: {
|
||||
updaterEnabled?: boolean
|
||||
deepLinks?: string[]
|
||||
wsl?: boolean
|
||||
activeServer?: string
|
||||
}
|
||||
api?: {
|
||||
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
|
||||
exportDebugLogs?: () => Promise<string>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,9 +94,26 @@ function QueryProvider(props: ParentProps) {
|
||||
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
function BodyDesignClass() {
|
||||
const settings = useSettings()
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const enabled = settings.general.newLayoutDesigns()
|
||||
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)
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function AppShellProviders(props: ParentProps) {
|
||||
return (
|
||||
<SettingsProvider>
|
||||
<BodyDesignClass />
|
||||
<PermissionProvider>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider>
|
||||
@@ -156,11 +172,13 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||
}}
|
||||
>
|
||||
<QueryProvider>
|
||||
<DialogProvider>
|
||||
<MarkedProvider>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</MarkedProvider>
|
||||
</DialogProvider>
|
||||
<WslServersProvider>
|
||||
<DialogProvider>
|
||||
<MarkedProvider>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</MarkedProvider>
|
||||
</DialogProvider>
|
||||
</WslServersProvider>
|
||||
</QueryProvider>
|
||||
</ErrorBoundary>
|
||||
</UiI18nBridge>
|
||||
@@ -283,11 +301,11 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key:
|
||||
)
|
||||
}
|
||||
|
||||
function ServerKey(props: ParentProps) {
|
||||
function ServerKey(props: { children: (key: ServerConnection.Key) => JSX.Element }) {
|
||||
const server = useServer()
|
||||
return (
|
||||
<Show when={server.key} keyed>
|
||||
{props.children}
|
||||
{(key) => props.children(key)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -300,31 +318,31 @@ export function AppInterface(props: {
|
||||
disableHealthCheck?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ServerProvider
|
||||
defaultServer={props.defaultServer}
|
||||
disableHealthCheck={props.disableHealthCheck}
|
||||
servers={props.servers}
|
||||
>
|
||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||
<ServerKey>
|
||||
<QueryProvider>
|
||||
<GlobalSDKProvider>
|
||||
<GlobalSyncProvider>
|
||||
<Dynamic
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
||||
>
|
||||
<Route path="/" component={HomeRoute} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={SessionIndexRoute} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
</Route>
|
||||
</Dynamic>
|
||||
</GlobalSyncProvider>
|
||||
</GlobalSDKProvider>
|
||||
</QueryProvider>
|
||||
</ServerKey>
|
||||
</ConnectionGate>
|
||||
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
||||
<ServersProvider>
|
||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||
<ServerKey>
|
||||
{() => (
|
||||
<QueryProvider>
|
||||
<ServerSDKProvider>
|
||||
<ServerSyncProvider>
|
||||
<Dynamic
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
||||
>
|
||||
<Route path="/" component={HomeRoute} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
</Route>
|
||||
</Dynamic>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</QueryProvider>
|
||||
)}
|
||||
</ServerKey>
|
||||
</ConnectionGate>
|
||||
</ServersProvider>
|
||||
</ServerProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,15 +12,15 @@ import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
|
||||
export function DialogConnectProvider(props: { provider: string }) {
|
||||
const dialog = useDialog()
|
||||
const globalSync = useGlobalSync()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const serverSync = useServerSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const providers = useProviders()
|
||||
|
||||
@@ -41,9 +41,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
})
|
||||
|
||||
const provider = createMemo(
|
||||
() =>
|
||||
providers.all().find((x) => x.id === props.provider) ??
|
||||
globalSync.data.provider.all.find((x) => x.id === props.provider)!,
|
||||
() => providers.all().get(props.provider) ?? serverSync.data.provider.all.get(props.provider)!,
|
||||
)
|
||||
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
||||
{
|
||||
@@ -54,16 +52,16 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
const [auth] = createResource(
|
||||
() => props.provider,
|
||||
async () => {
|
||||
const cached = globalSync.data.provider_auth[props.provider]
|
||||
const cached = serverSync.data.provider_auth[props.provider]
|
||||
if (cached) return cached
|
||||
const res = await globalSDK.client.provider.auth()
|
||||
const res = await serverSDK.client.provider.auth()
|
||||
if (!alive.value) return fallback()
|
||||
globalSync.set("provider_auth", res.data ?? {})
|
||||
serverSync.set("provider_auth", res.data ?? {})
|
||||
return res.data?.[props.provider] ?? fallback()
|
||||
},
|
||||
)
|
||||
const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider])
|
||||
const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback())
|
||||
const loading = createMemo(() => auth.loading && !serverSync.data.provider_auth[props.provider])
|
||||
const methods = createMemo(() => auth.latest ?? serverSync.data.provider_auth[props.provider] ?? fallback())
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: undefined as undefined | number,
|
||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||
@@ -160,7 +158,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
}
|
||||
dispatch({ type: "auth.pending" })
|
||||
const start = Date.now()
|
||||
await globalSDK.client.provider.oauth
|
||||
await serverSDK.client.provider.oauth
|
||||
.authorize(
|
||||
{
|
||||
providerID: props.provider,
|
||||
@@ -332,7 +330,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
})
|
||||
|
||||
async function complete() {
|
||||
await globalSDK.client.global.dispose()
|
||||
await serverSDK.client.global.dispose()
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
@@ -409,7 +407,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
await globalSDK.client.auth.set({
|
||||
await serverSDK.client.auth.set({
|
||||
providerID: props.provider,
|
||||
auth: {
|
||||
type: "api",
|
||||
@@ -480,7 +478,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
const result = await globalSDK.client.provider.oauth
|
||||
const result = await serverSDK.client.provider.oauth
|
||||
.callback({
|
||||
providerID: props.provider,
|
||||
method: store.methodIndex,
|
||||
@@ -526,14 +524,14 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
const code = createMemo(() => {
|
||||
const instructions = store.authorization?.instructions
|
||||
if (instructions?.includes(":")) {
|
||||
return instructions.split(":")[1]?.trim()
|
||||
return instructions.split(":").pop()?.trim()
|
||||
}
|
||||
return instructions
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
void (async () => {
|
||||
const result = await globalSDK.client.provider.oauth
|
||||
const result = await serverSDK.client.provider.oauth
|
||||
.callback({
|
||||
providerID: props.provider,
|
||||
method: store.methodIndex,
|
||||
|
||||
@@ -9,8 +9,8 @@ import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { batch, For } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
@@ -21,8 +21,8 @@ type Props = {
|
||||
|
||||
export function DialogCustomProvider(props: Props) {
|
||||
const dialog = useDialog()
|
||||
const globalSync = useGlobalSync()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const serverSync = useServerSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const language = useLanguage()
|
||||
|
||||
const [form, setForm] = createStore<FormState>({
|
||||
@@ -105,8 +105,8 @@ export function DialogCustomProvider(props: Props) {
|
||||
const output = validateCustomProvider({
|
||||
form,
|
||||
t: language.t,
|
||||
disabledProviders: globalSync.data.config.disabled_providers ?? [],
|
||||
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
|
||||
disabledProviders: serverSync.data.config.disabled_providers ?? [],
|
||||
existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
|
||||
})
|
||||
batch(() => {
|
||||
setForm("err", output.err)
|
||||
@@ -118,11 +118,11 @@ export function DialogCustomProvider(props: Props) {
|
||||
|
||||
const saveMutation = useMutation(() => ({
|
||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||
const disabledProviders = globalSync.data.config.disabled_providers ?? []
|
||||
const disabledProviders = serverSync.data.config.disabled_providers ?? []
|
||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||
|
||||
if (result.key) {
|
||||
await globalSDK.client.auth.set({
|
||||
await serverSDK.client.auth.set({
|
||||
providerID: result.providerID,
|
||||
auth: {
|
||||
type: "api",
|
||||
@@ -131,7 +131,7 @@ export function DialogCustomProvider(props: Props) {
|
||||
})
|
||||
}
|
||||
|
||||
await globalSync.updateConfig({
|
||||
await serverSync.updateConfig({
|
||||
provider: { [result.providerID]: result.config },
|
||||
disabled_providers: nextDisabled,
|
||||
})
|
||||
|
||||
@@ -6,20 +6,20 @@ import { useMutation } from "@tanstack/solid-query"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getProjectAvatarSource } from "@/pages/layout/sidebar-items"
|
||||
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
|
||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||
|
||||
export function DialogEditProject(props: { project: LocalProject }) {
|
||||
const dialog = useDialog()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const language = useLanguage()
|
||||
|
||||
const folderName = createMemo(() => getFilename(props.project.worktree))
|
||||
@@ -78,19 +78,19 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
const start = store.startup.trim()
|
||||
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
await globalSDK.client.project.update({
|
||||
await serverSDK.client.project.update({
|
||||
projectID: props.project.id,
|
||||
directory: props.project.worktree,
|
||||
name,
|
||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||
commands: { start },
|
||||
})
|
||||
globalSync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
||||
serverSync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
||||
dialog.close()
|
||||
return
|
||||
}
|
||||
|
||||
globalSync.project.meta(props.project.worktree, {
|
||||
serverSync.project.meta(props.project.worktree, {
|
||||
name,
|
||||
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
|
||||
commands: { start: start || undefined },
|
||||
|
||||
@@ -6,8 +6,8 @@ import type { ListRef } from "@opencode-ai/ui/list"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { createMemo, createResource, createSignal } from "solid-js"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
@@ -128,7 +128,7 @@ function uniqueRows(rows: Row[]) {
|
||||
}
|
||||
|
||||
function useDirectorySearch(args: {
|
||||
sdk: ReturnType<typeof useGlobalSDK>
|
||||
sdk: ReturnType<typeof useServerSDK>
|
||||
start: () => string | undefined
|
||||
home: () => string
|
||||
}) {
|
||||
@@ -246,8 +246,8 @@ function useDirectorySearch(args: {
|
||||
}
|
||||
|
||||
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const sync = useGlobalSync()
|
||||
const sdk = useGlobalSDK()
|
||||
const sync = useServerSync()
|
||||
const sdk = useServerSDK()
|
||||
const layout = useLayout()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
@@ -9,8 +9,8 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -175,7 +175,7 @@ function createFileEntries(props: {
|
||||
function createSessionEntries(props: {
|
||||
workspaces: () => string[]
|
||||
label: (directory: string) => string
|
||||
globalSDK: ReturnType<typeof useGlobalSDK>
|
||||
serverSDK: ReturnType<typeof useServerSDK>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const state: {
|
||||
@@ -207,7 +207,7 @@ function createSessionEntries(props: {
|
||||
state.inflight = Promise.all(
|
||||
dirs.map((directory) => {
|
||||
const description = props.label(directory)
|
||||
return props.globalSDK.client.session
|
||||
return props.serverSDK.client.session
|
||||
.list({ directory, roots: true })
|
||||
.then((x) =>
|
||||
(x.data ?? [])
|
||||
@@ -268,8 +268,8 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
const file = useFile()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
const filesOnly = () => props.mode === "files"
|
||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||
@@ -292,21 +292,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
if (directory && !dirs.includes(directory)) return [...dirs, directory]
|
||||
return dirs
|
||||
})
|
||||
const homedir = createMemo(() => globalSync.data.path.home)
|
||||
const homedir = createMemo(() => serverSync.data.path.home)
|
||||
const label = (directory: string) => {
|
||||
const current = project()
|
||||
const kind =
|
||||
current && directory === current.worktree
|
||||
? language.t("workspace.type.local")
|
||||
: language.t("workspace.type.sandbox")
|
||||
const [store] = globalSync.child(directory, { bootstrap: false })
|
||||
const [store] = serverSync.child(directory, { bootstrap: false })
|
||||
const home = homedir()
|
||||
const path = home ? directory.replace(home, "~") : directory
|
||||
const name = store.vcs?.branch ?? getFilename(directory)
|
||||
return `${kind} : ${name || path}`
|
||||
}
|
||||
|
||||
const { sessions } = createSessionEntries({ workspaces, label, globalSDK, language })
|
||||
const { sessions } = createSessionEntries({ workspaces, label, serverSDK, language })
|
||||
|
||||
const items = async (text: string) => {
|
||||
const query = text.trim()
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useQueryOptions } from "@/context/global-sync"
|
||||
import { useQueryOptions } from "@/context/server-sync"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
const statusLabels = {
|
||||
|
||||
@@ -91,7 +91,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
||||
<div class="w-full">
|
||||
<List
|
||||
class="w-full px-0"
|
||||
key={(x) => x?.id}
|
||||
key={(p) => p.id}
|
||||
items={providers.popular}
|
||||
activeIcon="plus-small"
|
||||
sortBy={(a, b) => {
|
||||
|
||||
@@ -35,7 +35,7 @@ export const DialogSelectProvider: Component = () => {
|
||||
key={(x) => x?.id}
|
||||
items={() => {
|
||||
language.locale()
|
||||
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all()]
|
||||
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
|
||||
}}
|
||||
filterKeys={["id", "name"]}
|
||||
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
||||
|
||||
@@ -5,20 +5,26 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
|
||||
import { batch, createEffect, createMemo, createResource, For, onCleanup, Show, untrack } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { DialogWslServer } from "@/components/dialog-wsl-server"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { useWslServers } from "@/context/wsl-servers"
|
||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||
|
||||
const DEFAULT_USERNAME = "opencode"
|
||||
|
||||
interface DialogSelectServerProps {
|
||||
onNavigateHome?: () => void
|
||||
}
|
||||
|
||||
interface ServerFormProps {
|
||||
value: string
|
||||
name: string
|
||||
@@ -27,7 +33,6 @@ interface ServerFormProps {
|
||||
placeholder: string
|
||||
busy: boolean
|
||||
error: string
|
||||
status: boolean | undefined
|
||||
onChange: (value: string) => void
|
||||
onNameChange: (value: string) => void
|
||||
onUsernameChange: (value: string) => void
|
||||
@@ -44,15 +49,17 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
|
||||
})
|
||||
}
|
||||
|
||||
function isWslSidecar(conn: ServerConnection.Any): conn is ServerConnection.Sidecar & { variant: "wsl" } {
|
||||
return conn.type === "sidecar" && conn.variant === "wsl"
|
||||
}
|
||||
|
||||
function useDefaultServer() {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [defaultKey, defaultUrlActions] = createResource(
|
||||
const [defaultKey, defaultActions] = createResource(
|
||||
async () => {
|
||||
try {
|
||||
const key = await platform.getDefaultServer?.()
|
||||
if (!key) return null
|
||||
return key
|
||||
return (await platform.getDefaultServer?.()) ?? null
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
return null
|
||||
@@ -60,52 +67,18 @@ function useDefaultServer() {
|
||||
},
|
||||
{ initialValue: null },
|
||||
)
|
||||
|
||||
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
|
||||
const setDefault = async (key: ServerConnection.Key | null) => {
|
||||
try {
|
||||
await platform.setDefaultServer?.(key)
|
||||
defaultUrlActions.mutate(key)
|
||||
defaultActions.mutate(key)
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return { defaultKey, canDefault, setDefault }
|
||||
}
|
||||
|
||||
function useServerPreview() {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
|
||||
const looksComplete = (value: string) => {
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return false
|
||||
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
||||
if (!host) return false
|
||||
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
|
||||
return host.includes(".") || host.includes(":")
|
||||
}
|
||||
|
||||
const previewStatus = async (
|
||||
value: string,
|
||||
username: string,
|
||||
password: string,
|
||||
setStatus: (value: boolean | undefined) => void,
|
||||
) => {
|
||||
setStatus(undefined)
|
||||
if (!looksComplete(value)) return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return
|
||||
const http: ServerConnection.HttpBase = { url: normalized }
|
||||
if (username) http.username = username
|
||||
if (password) http.password = password
|
||||
const result = await checkServerHealth(http)
|
||||
setStatus(result.healthy)
|
||||
}
|
||||
|
||||
return { previewStatus }
|
||||
}
|
||||
|
||||
function ServerForm(props: ServerFormProps) {
|
||||
const language = useLanguage()
|
||||
const keyDown = (event: KeyboardEvent) => {
|
||||
@@ -171,15 +144,18 @@ function ServerForm(props: ServerFormProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function DialogSelectServer() {
|
||||
const navigate = useNavigate()
|
||||
export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
const dialog = useDialog()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const { defaultKey, canDefault, setDefault } = useDefaultServer()
|
||||
const { previewStatus } = useServerPreview()
|
||||
const wslServers = useWslServers()
|
||||
const defaultServer = useDefaultServer()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
let disposed = false
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
})
|
||||
const [store, setStore] = createStore({
|
||||
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||
addServer: {
|
||||
@@ -189,7 +165,9 @@ export function DialogSelectServer() {
|
||||
password: "",
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined as boolean | undefined,
|
||||
},
|
||||
addWsl: {
|
||||
showWizard: false,
|
||||
},
|
||||
editServer: {
|
||||
id: undefined as string | undefined,
|
||||
@@ -198,7 +176,6 @@ export function DialogSelectServer() {
|
||||
username: "",
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined as boolean | undefined,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -210,7 +187,6 @@ export function DialogSelectServer() {
|
||||
password: "",
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
const resetEdit = () => {
|
||||
@@ -221,7 +197,6 @@ export function DialogSelectServer() {
|
||||
username: "",
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -294,6 +269,32 @@ export function DialogSelectServer() {
|
||||
},
|
||||
}))
|
||||
|
||||
const removeWslMutation = useMutation(() => ({
|
||||
mutationFn: async (key: ServerConnection.Key) => {
|
||||
await platform.wslServers?.removeServer(key)
|
||||
return key
|
||||
},
|
||||
onSuccess: async (key) => {
|
||||
if (defaultServer.defaultKey() === key) await defaultServer.setDefault(null)
|
||||
server.remove(key)
|
||||
},
|
||||
onError: (err) => showRequestError(language, err),
|
||||
}))
|
||||
|
||||
const retryWslMutation = useMutation(() => ({
|
||||
mutationFn: async (key: ServerConnection.Key) => {
|
||||
await platform.wslServers?.startServer(key)
|
||||
},
|
||||
onError: (err) => showRequestError(language, err),
|
||||
}))
|
||||
|
||||
const updateWslMutation = useMutation(() => ({
|
||||
mutationFn: async (distro: string) => {
|
||||
await platform.wslServers?.installOpencode(distro)
|
||||
},
|
||||
onError: (err) => showRequestError(language, err),
|
||||
}))
|
||||
|
||||
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
||||
const active = server.key
|
||||
const newConn = server.add(next)
|
||||
@@ -312,6 +313,32 @@ export function DialogSelectServer() {
|
||||
})
|
||||
|
||||
const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0])
|
||||
const wslState = () => wslServers.data
|
||||
const healthPollKey = createMemo(() =>
|
||||
items()
|
||||
.map((conn) =>
|
||||
[ServerConnection.key(conn), conn.http.url, conn.http.username ?? "", conn.http.password ?? ""].join("\n"),
|
||||
)
|
||||
.join("\n\n"),
|
||||
)
|
||||
const health = (key: ServerConnection.Key) => store.status[key]
|
||||
const wslRuntime = (conn: ServerConnection.Any) => {
|
||||
if (!isWslSidecar(conn)) return
|
||||
return wslState()?.servers.find((item) => item.config.id === ServerConnection.key(conn))?.runtime
|
||||
}
|
||||
const nonReadyWslServers = createMemo(() =>
|
||||
(wslState()?.servers ?? []).filter((item) => item.runtime.kind !== "ready"),
|
||||
)
|
||||
const canRetryWsl = (conn: ServerConnection.Any) => {
|
||||
const runtime = wslRuntime(conn)
|
||||
return runtime?.kind === "failed" || runtime?.kind === "stopped"
|
||||
}
|
||||
const canRetryWslRuntime = (kind: string) => kind === "failed" || kind === "stopped"
|
||||
const wslRuntimeLabel = (kind: string) => {
|
||||
if (kind === "starting") return "Starting"
|
||||
if (kind === "failed") return "Failed"
|
||||
return "Stopped"
|
||||
}
|
||||
|
||||
const sortedItems = createMemo(() => {
|
||||
const list = items()
|
||||
@@ -326,7 +353,7 @@ export function DialogSelectServer() {
|
||||
return list.slice().sort((a, b) => {
|
||||
if (a === active) return -1
|
||||
if (b === active) return 1
|
||||
const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)])
|
||||
const diff = rank(health(ServerConnection.key(a))) - rank(health(ServerConnection.key(b)))
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
@@ -334,39 +361,60 @@ export function DialogSelectServer() {
|
||||
|
||||
async function refreshHealth() {
|
||||
const results: Record<ServerConnection.Key, ServerHealth> = {}
|
||||
const list = untrack(items)
|
||||
await Promise.all(
|
||||
items().map(async (conn) => {
|
||||
list.map(async (conn) => {
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||
}),
|
||||
)
|
||||
if (disposed) return
|
||||
setStore("status", reconcile(results))
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
items()
|
||||
healthPollKey()
|
||||
void refreshHealth()
|
||||
const interval = setInterval(refreshHealth, 10_000)
|
||||
onCleanup(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
const wslCheck = (conn: ServerConnection.Any) => {
|
||||
if (!isWslSidecar(conn)) return null
|
||||
return wslState()?.opencodeChecks[conn.distro] ?? null
|
||||
}
|
||||
|
||||
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
||||
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
|
||||
dialog.close()
|
||||
if (persist && conn.type === "http") {
|
||||
server.add(conn)
|
||||
navigate("/")
|
||||
if (!persist && health(ServerConnection.key(conn))?.healthy === false) return
|
||||
const nextKey = ServerConnection.key(conn)
|
||||
const changed = server.key !== nextKey
|
||||
|
||||
const navigateHome = () => props.onNavigateHome?.()
|
||||
|
||||
const apply = () => {
|
||||
dialog.close()
|
||||
if (persist && conn.type === "http") {
|
||||
server.add(conn)
|
||||
navigateHome()
|
||||
return
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
navigateHome()
|
||||
server.setActive(nextKey)
|
||||
})
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
await apply()
|
||||
return
|
||||
}
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
|
||||
|
||||
apply()
|
||||
}
|
||||
|
||||
const handleAddChange = (value: string) => {
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { url: value, error: "" })
|
||||
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddNameChange = (value: string) => {
|
||||
@@ -377,25 +425,16 @@ export function DialogSelectServer() {
|
||||
const handleAddUsernameChange = (value: string) => {
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { username: value, error: "" })
|
||||
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddPasswordChange = (value: string) => {
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { password: value, error: "" })
|
||||
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleEditChange = (value: string) => {
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { value, error: "" })
|
||||
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleEditNameChange = (value: string) => {
|
||||
@@ -406,20 +445,15 @@ export function DialogSelectServer() {
|
||||
const handleEditUsernameChange = (value: string) => {
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { username: value, error: "" })
|
||||
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleEditPasswordChange = (value: string) => {
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { password: value, error: "" })
|
||||
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const mode = createMemo<"list" | "add" | "edit">(() => {
|
||||
const mode = createMemo<"list" | "add-wsl" | "add" | "edit">(() => {
|
||||
if (store.addWsl.showWizard) return "add-wsl"
|
||||
if (store.editServer.id) return "edit"
|
||||
if (store.addServer.showForm) return "add"
|
||||
return "list"
|
||||
@@ -433,9 +467,11 @@ export function DialogSelectServer() {
|
||||
const resetForm = () => {
|
||||
resetAdd()
|
||||
resetEdit()
|
||||
setStore("addWsl", "showWizard", false)
|
||||
}
|
||||
|
||||
const startAdd = () => {
|
||||
setStore("addWsl", "showWizard", false)
|
||||
resetEdit()
|
||||
setStore("addServer", {
|
||||
showForm: true,
|
||||
@@ -444,11 +480,11 @@ export function DialogSelectServer() {
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const startEdit = (conn: ServerConnection.Http) => {
|
||||
setStore("addWsl", "showWizard", false)
|
||||
resetAdd()
|
||||
setStore("editServer", {
|
||||
id: conn.http.url,
|
||||
@@ -457,10 +493,22 @@ export function DialogSelectServer() {
|
||||
username: conn.http.username ?? "",
|
||||
password: conn.http.password ?? "",
|
||||
error: "",
|
||||
status: store.status[ServerConnection.key(conn)]?.healthy,
|
||||
})
|
||||
}
|
||||
|
||||
const startAddWsl = () => {
|
||||
resetAdd()
|
||||
resetEdit()
|
||||
setStore("addWsl", "showWizard", true)
|
||||
}
|
||||
|
||||
const handleAddedWsl = async (distro: string) => {
|
||||
const key = ServerConnection.Key.make(`wsl:${distro}`)
|
||||
setStore("addWsl", "showWizard", false)
|
||||
const conn = items().find((item) => ServerConnection.key(item) === key)
|
||||
if (conn) await select(conn)
|
||||
}
|
||||
|
||||
const submitForm = () => {
|
||||
if (mode() === "add") {
|
||||
if (addMutation.isPending) return
|
||||
@@ -477,14 +525,22 @@ export function DialogSelectServer() {
|
||||
|
||||
const isFormMode = createMemo(() => mode() !== "list")
|
||||
const isAddMode = createMemo(() => mode() === "add")
|
||||
const isAddWslMode = createMemo(() => mode() === "add-wsl")
|
||||
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
|
||||
const canAddWsl = createMemo(() => !!platform.wslServers && platform.os === "windows")
|
||||
|
||||
const formTitle = createMemo(() => {
|
||||
if (!isFormMode()) return language.t("dialog.server.title")
|
||||
return (
|
||||
<div class="flex items-center gap-2 -ml-2">
|
||||
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
|
||||
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
|
||||
<span>
|
||||
{isAddWslMode()
|
||||
? "Add WSL server"
|
||||
: isAddMode()
|
||||
? language.t("dialog.server.add.title")
|
||||
: language.t("dialog.server.edit.title")}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -495,37 +551,126 @@ export function DialogSelectServer() {
|
||||
resetEdit()
|
||||
})
|
||||
|
||||
async function handleRemove(url: ServerConnection.Key) {
|
||||
server.remove(url)
|
||||
if ((await platform.getDefaultServer?.()) === url) {
|
||||
void platform.setDefaultServer?.(null)
|
||||
}
|
||||
async function handleRemove(key: ServerConnection.Key) {
|
||||
server.remove(key)
|
||||
if (defaultServer.defaultKey() === key) await defaultServer.setDefault(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={formTitle()}>
|
||||
<div class="flex flex-1 min-h-0 flex-col gap-2">
|
||||
<Dialog
|
||||
title={formTitle()}
|
||||
fit={isAddWslMode()}
|
||||
class={isAddWslMode() ? "[&_[data-slot=dialog-body]]:flex-none [&_[data-slot=dialog-body]]:overflow-visible" : undefined}
|
||||
>
|
||||
<div class={isAddWslMode() ? "flex flex-col gap-2" : "flex flex-1 min-h-0 flex-col gap-2"}>
|
||||
<Show
|
||||
when={!isFormMode()}
|
||||
fallback={
|
||||
<ServerForm
|
||||
value={isAddMode() ? store.addServer.url : store.editServer.value}
|
||||
name={isAddMode() ? store.addServer.name : store.editServer.name}
|
||||
username={isAddMode() ? store.addServer.username : store.editServer.username}
|
||||
password={isAddMode() ? store.addServer.password : store.editServer.password}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
busy={formBusy()}
|
||||
error={isAddMode() ? store.addServer.error : store.editServer.error}
|
||||
status={isAddMode() ? store.addServer.status : store.editServer.status}
|
||||
onChange={isAddMode() ? handleAddChange : handleEditChange}
|
||||
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
|
||||
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
|
||||
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
|
||||
onSubmit={submitForm}
|
||||
onBack={resetForm}
|
||||
/>
|
||||
<Show
|
||||
when={isAddWslMode()}
|
||||
fallback={
|
||||
<ServerForm
|
||||
value={isAddMode() ? store.addServer.url : store.editServer.value}
|
||||
name={isAddMode() ? store.addServer.name : store.editServer.name}
|
||||
username={isAddMode() ? store.addServer.username : store.editServer.username}
|
||||
password={isAddMode() ? store.addServer.password : store.editServer.password}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
busy={formBusy()}
|
||||
error={isAddMode() ? store.addServer.error : store.editServer.error}
|
||||
onChange={isAddMode() ? handleAddChange : handleEditChange}
|
||||
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
|
||||
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
|
||||
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
|
||||
onSubmit={submitForm}
|
||||
onBack={resetForm}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DialogWslServer onAdded={handleAddedWsl} />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Show when={nonReadyWslServers().length > 0}>
|
||||
<div class="px-5">
|
||||
<div class="bg-surface-base rounded-md overflow-hidden">
|
||||
<For each={nonReadyWslServers()}>
|
||||
{(item) => {
|
||||
const key = ServerConnection.Key.make(item.config.id)
|
||||
const retryable = () => canRetryWslRuntime(item.runtime.kind)
|
||||
return (
|
||||
<div class="min-h-14 p-3 flex items-center gap-3 border-b border-border-weak-base last:border-b-0">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-critical-base": item.runtime.kind === "failed",
|
||||
"bg-border-weak-base": item.runtime.kind !== "failed",
|
||||
}}
|
||||
/>
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span class="text-14-medium text-text-base truncate">{item.config.distro}</span>
|
||||
<span class="text-11-regular text-text-weak border border-border-weak-base bg-surface-base px-1.5 py-0.5 rounded-md shrink-0">
|
||||
WSL
|
||||
</span>
|
||||
<Show when={defaultServer.defaultKey() === key}>
|
||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs shrink-0">
|
||||
{language.t("dialog.server.status.default")}
|
||||
</span>
|
||||
</Show>
|
||||
<span class="text-12-regular text-text-weak truncate">
|
||||
{wslRuntimeLabel(item.runtime.kind)}
|
||||
</span>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="mt-1">
|
||||
<Show when={retryable()}>
|
||||
<DropdownMenu.Item onSelect={() => retryWslMutation.mutate(key)}>
|
||||
<DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={defaultServer.canDefault() && defaultServer.defaultKey() !== key}>
|
||||
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(key)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={defaultServer.canDefault() && defaultServer.defaultKey() === key}>
|
||||
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(null)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={retryable() || defaultServer.canDefault()}>
|
||||
<DropdownMenu.Separator />
|
||||
</Show>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => removeWslMutation.mutate(key)}
|
||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||
>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.delete")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<List
|
||||
search={{
|
||||
placeholder: language.t("dialog.server.search.placeholder"),
|
||||
@@ -534,7 +679,7 @@ export function DialogSelectServer() {
|
||||
noInitialSelection
|
||||
emptyMessage={language.t("dialog.server.empty")}
|
||||
items={sortedItems}
|
||||
key={(x) => x.http.url}
|
||||
key={(x) => ServerConnection.key(x)}
|
||||
onSelect={(x) => {
|
||||
if (x) void select(x)
|
||||
}}
|
||||
@@ -543,18 +688,35 @@ export function DialogSelectServer() {
|
||||
>
|
||||
{(i) => {
|
||||
const key = ServerConnection.key(i)
|
||||
const wsl = isWslSidecar(i)
|
||||
const wslDistro = wsl ? i.distro : undefined
|
||||
const blocked = () => health(key)?.healthy === false
|
||||
const canChangeDefault = () => defaultServer.canDefault() && (i.type === "http" || wsl)
|
||||
const canRemove = () => i.type === "http" || wsl
|
||||
const opencodeAction = () => {
|
||||
const check = wslCheck(i)
|
||||
if (!check) return null
|
||||
if (!check.resolvedPath) return "Install OpenCode"
|
||||
if (check.matchesDesktop === false) return "Update OpenCode"
|
||||
return null
|
||||
}
|
||||
const updating = () => {
|
||||
const job = wslState()?.job
|
||||
return job?.kind === "install-opencode" && job.distro === wslDistro
|
||||
}
|
||||
return (
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
|
||||
<div class="flex flex-col h-full items-start w-5">
|
||||
<ServerHealthIndicator health={store.status[key]} />
|
||||
<ServerHealthIndicator health={health(key)} />
|
||||
</div>
|
||||
<ServerRow
|
||||
conn={i}
|
||||
dimmed={store.status[key]?.healthy === false}
|
||||
status={store.status[key]}
|
||||
dimmed={blocked()}
|
||||
status={health(key)}
|
||||
version={wslCheck(i)?.version ?? undefined}
|
||||
class="flex items-center gap-3 min-w-0 flex-1"
|
||||
badge={
|
||||
<Show when={defaultKey() === ServerConnection.key(i)}>
|
||||
<Show when={defaultServer.defaultKey() === ServerConnection.key(i)}>
|
||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||
{language.t("dialog.server.status.default")}
|
||||
</span>
|
||||
@@ -562,12 +724,32 @@ export function DialogSelectServer() {
|
||||
}
|
||||
showCredentials
|
||||
/>
|
||||
<div class="flex items-center justify-center gap-4 pl-4">
|
||||
<div class="flex items-center justify-center gap-3 pl-4">
|
||||
<Show when={wsl && opencodeAction()}>
|
||||
{(label) => (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
disabled={!!wslState()?.job}
|
||||
class="shrink-0"
|
||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
||||
onClick={(e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (wslDistro) updateWslMutation.mutate(wslDistro)
|
||||
}}
|
||||
>
|
||||
<Show when={updating()}>
|
||||
<Spinner class="size-3.5 shrink-0" />
|
||||
</Show>
|
||||
{label()}
|
||||
</Button>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={ServerConnection.key(current()) === key}>
|
||||
<Icon name="check" class="h-6" />
|
||||
</Show>
|
||||
|
||||
<Show when={i.type === "http"}>
|
||||
<Show when={i.type === "http" || i.type === "sidecar"}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
@@ -579,35 +761,54 @@ export function DialogSelectServer() {
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="mt-1">
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
if (i.type !== "http") return
|
||||
startEdit(i)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={canDefault() && defaultKey() !== key}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(key)}>
|
||||
<Show when={i.type === "http"}>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
if (i.type !== "http") return
|
||||
startEdit(i)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={wsl && canRetryWsl(i)}>
|
||||
<DropdownMenu.Item onSelect={() => retryWslMutation.mutate(key)}>
|
||||
<DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={canChangeDefault() && defaultServer.defaultKey() !== key}>
|
||||
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(key)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={canDefault() && defaultKey() === key}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
||||
<Show when={canChangeDefault() && defaultServer.defaultKey() === key}>
|
||||
<DropdownMenu.Item onSelect={() => void defaultServer.setDefault(null)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => handleRemove(ServerConnection.key(i))}
|
||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={canRemove() && (i.type === "http" || canChangeDefault() || canRetryWsl(i))}>
|
||||
<DropdownMenu.Separator />
|
||||
</Show>
|
||||
<Show when={canRemove()}>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
if (wsl) {
|
||||
removeWslMutation.mutate(key)
|
||||
return
|
||||
}
|
||||
void handleRemove(key)
|
||||
}}
|
||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||
>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.delete")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
@@ -621,17 +822,32 @@ export function DialogSelectServer() {
|
||||
|
||||
<div class="shrink-0 px-5 pb-5">
|
||||
<Show
|
||||
when={isFormMode()}
|
||||
when={!isAddWslMode() && isFormMode()}
|
||||
fallback={
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
size="large"
|
||||
onClick={startAdd}
|
||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||
>
|
||||
{language.t("dialog.server.add.button")}
|
||||
</Button>
|
||||
<Show when={!isAddWslMode()}>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
size="large"
|
||||
onClick={startAdd}
|
||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||
>
|
||||
{language.t("dialog.server.add.button")}
|
||||
</Button>
|
||||
<Show when={canAddWsl()}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
size="large"
|
||||
onClick={startAddWsl}
|
||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||
>
|
||||
Add WSL
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5">
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useWslServers } from "@/context/wsl-servers"
|
||||
|
||||
type WslServerStep = "wsl" | "distro" | "opencode"
|
||||
|
||||
const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"]
|
||||
|
||||
function isHiddenDistro(name: string) {
|
||||
return /^docker-desktop(?:-data)?$/i.test(name)
|
||||
}
|
||||
|
||||
interface DialogWslServerProps {
|
||||
onAdded?: (distro: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
export function DialogWslServer(props: DialogWslServerProps = {}) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const wslServers = useWslServers()
|
||||
const api = platform.wslServers!
|
||||
const [store, setStore] = createStore({
|
||||
step: undefined as WslServerStep | undefined,
|
||||
selectedDistro: null as string | null,
|
||||
installTarget: undefined as string | undefined,
|
||||
adding: false,
|
||||
})
|
||||
const current = () => wslServers.data
|
||||
let disposed = false
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
})
|
||||
const busy = createMemo(() => !!current()?.job || store.adding)
|
||||
const selectedProbe = createMemo(() => {
|
||||
const distro = store.selectedDistro
|
||||
if (!distro) return null
|
||||
return current()?.distroProbes[distro] ?? null
|
||||
})
|
||||
const selectedInstalled = createMemo(() => {
|
||||
const distro = store.selectedDistro
|
||||
if (!distro) return null
|
||||
return (current()?.installed ?? []).find((item) => item.name === distro) ?? null
|
||||
})
|
||||
const visibleInstalledDistros = createMemo(() =>
|
||||
(current()?.installed ?? []).filter((item) => !isHiddenDistro(item.name)),
|
||||
)
|
||||
const visibleOnlineDistros = createMemo(() => (current()?.online ?? []).filter((item) => !isHiddenDistro(item.name)))
|
||||
const defaultInstalledDistro = createMemo(() => visibleInstalledDistros().find((item) => item.isDefault) ?? null)
|
||||
const opencodeCheck = createMemo(() => {
|
||||
const distro = store.selectedDistro
|
||||
if (!distro) return null
|
||||
return current()?.opencodeChecks[distro] ?? null
|
||||
})
|
||||
const distroWarningProbe = createMemo(() => {
|
||||
const probe = selectedProbe()
|
||||
if (!probe) return null
|
||||
if (distroReady()) return null
|
||||
return probe
|
||||
})
|
||||
const distroUnavailableMessage = createMemo(() => {
|
||||
const probe = distroWarningProbe()
|
||||
const distro = store.selectedDistro
|
||||
if (!probe || probe.canExecute || !distro) return null
|
||||
if (!selectedInstalled()) return `${distro} is not installed yet.`
|
||||
return `Open ${distro} once to finish setup.`
|
||||
})
|
||||
const distroMissingTools = createMemo(() => {
|
||||
const probe = distroWarningProbe()
|
||||
if (!probe?.canExecute) return null
|
||||
if (probe.hasBash && probe.hasCurl) return null
|
||||
return probe
|
||||
})
|
||||
const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro)))
|
||||
const addableInstalledDistros = createMemo(() => {
|
||||
return visibleInstalledDistros().filter((item) => !existingServerDistros().has(item.name))
|
||||
})
|
||||
const installableDistros = createMemo(() => {
|
||||
const online = visibleOnlineDistros()
|
||||
const installed = new Set(visibleInstalledDistros().map((item) => item.name))
|
||||
const hasVersionedUbuntu = online.some((item) => /^Ubuntu-\d/.test(item.name))
|
||||
return online
|
||||
.filter((item) => !installed.has(item.name))
|
||||
.filter((item) => !(item.name === "Ubuntu" && hasVersionedUbuntu))
|
||||
})
|
||||
const installTarget = createMemo(() => installableDistros().find((item) => item.name === store.installTarget) ?? null)
|
||||
const installingDistro = createMemo(() => current()?.job?.kind === "install-distro")
|
||||
const installingOpencode = createMemo(() => {
|
||||
const job = current()?.job
|
||||
return job?.kind === "install-opencode" && job.distro === store.selectedDistro
|
||||
})
|
||||
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
|
||||
const distroReady = createMemo(() => {
|
||||
const probe = selectedProbe()
|
||||
if (!probe || !store.selectedDistro) return false
|
||||
if (selectedInstalled()?.version === 1) return false
|
||||
return probe.canExecute && probe.hasBash && probe.hasCurl
|
||||
})
|
||||
const opencodeReady = createMemo(() => {
|
||||
const check = opencodeCheck()
|
||||
return !!check?.resolvedPath && !check.error
|
||||
})
|
||||
const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
|
||||
const addDisabled = createMemo(() => {
|
||||
const job = current()?.job
|
||||
if (!job) return store.adding
|
||||
return store.adding || job.kind !== "probe-opencode"
|
||||
})
|
||||
const recommendedStep = createMemo<WslServerStep>(() => {
|
||||
if (!wslReady()) return "wsl"
|
||||
if (!distroReady()) return "distro"
|
||||
return "opencode"
|
||||
})
|
||||
// activeStep falls back to recommendedStep when the user hasn't picked one.
|
||||
// Once the user clicks a step tab we respect their choice rather than snapping
|
||||
// them back when a probe result updates recommendedStep.
|
||||
const activeStep = createMemo(() => store.step ?? recommendedStep())
|
||||
|
||||
const autoProbe = createMemo(() => {
|
||||
const state = current()
|
||||
if (!state || busy()) return null
|
||||
if (state.pendingRestart) return null
|
||||
if (!state.runtime) return { key: "runtime", run: () => api.probeRuntime() }
|
||||
if (!wslReady()) return null
|
||||
if (!state.installed.length && !state.online.length) {
|
||||
return { key: "distros", run: () => api.refreshDistros() }
|
||||
}
|
||||
const distro = store.selectedDistro
|
||||
if (distro && !state.distroProbes[distro]) {
|
||||
return { key: `probe-distro:${distro}`, run: () => api.probeDistro(distro) }
|
||||
}
|
||||
if (!distro || !distroReady()) return null
|
||||
if (!state.opencodeChecks[distro]) {
|
||||
return { key: `probe-opencode:${distro}`, run: () => api.probeOpencode(distro) }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
let lastAutoProbe: string | null = null
|
||||
createEffect(() => {
|
||||
const probe = autoProbe()
|
||||
if (!probe || probe.key === lastAutoProbe) return
|
||||
const key = probe.key
|
||||
lastAutoProbe = key
|
||||
void (async () => {
|
||||
try {
|
||||
await probe.run()
|
||||
} catch (err) {
|
||||
if (disposed) return
|
||||
// Allow the same probe to run again when reactive inputs next change
|
||||
// (e.g. user reselects a distro). Without this the user would be stuck
|
||||
// on a transient wsl.exe failure until they pick a different distro.
|
||||
if (lastAutoProbe === key) lastAutoProbe = null
|
||||
requestError(language, err)
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const state = current()
|
||||
const distro = defaultInstalledDistro()
|
||||
if (!state || !distro || busy()) return
|
||||
if (store.selectedDistro) return
|
||||
if (existingServerDistros().has(distro.name)) return
|
||||
setStore("selectedDistro", distro.name)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const distros = installableDistros()
|
||||
if (!distros.length) {
|
||||
if (store.installTarget) setStore("installTarget", undefined)
|
||||
return
|
||||
}
|
||||
if (store.installTarget && distros.some((item) => item.name === store.installTarget)) return
|
||||
setStore("installTarget", distros[0]!.name)
|
||||
})
|
||||
|
||||
const wslMessage = createMemo(() => {
|
||||
const state = current()
|
||||
if (!state || state.job?.kind === "runtime") return "Checking WSL..."
|
||||
if (state.pendingRestart) return "Windows needs a restart to finish installing WSL."
|
||||
if (state.runtime?.available) return state.runtime.version ?? "WSL is ready."
|
||||
return state.runtime?.error ?? "WSL is required to continue."
|
||||
})
|
||||
|
||||
const distroMessage = createMemo(() => {
|
||||
const state = current()
|
||||
if (!state) return "Checking distros..."
|
||||
const distro = store.selectedDistro
|
||||
if (state.job?.kind === "install-distro") return `Installing ${state.job.distro}...`
|
||||
if (state.job?.kind === "probe-distro") return `Checking ${state.job.distro}...`
|
||||
if (state.job?.kind === "distros") return "Listing distros..."
|
||||
if (distroUnavailableMessage()) return distroUnavailableMessage()!
|
||||
if (selectedProbe() && distroReady()) return `${selectedProbe()!.name} is ready.`
|
||||
if (distro) return `Finishing setup for ${distro}.`
|
||||
return "Pick a distro or install one below."
|
||||
})
|
||||
|
||||
const opencodeMessage = createMemo(() => {
|
||||
const state = current()
|
||||
if (!state) return "Checking OpenCode..."
|
||||
const distro = store.selectedDistro
|
||||
if (state.job?.kind === "probe-opencode" || state.job?.kind === "install-opencode") {
|
||||
return distro ? `Checking OpenCode in ${distro}...` : "Checking OpenCode..."
|
||||
}
|
||||
if (opencodeCheck()?.error) return opencodeCheck()!.error
|
||||
if (opencodeCheck()?.matchesDesktop === false) {
|
||||
return distro ? `Update OpenCode in ${distro}.` : "Update OpenCode."
|
||||
}
|
||||
if (opencodeReady()) return distro ? `OpenCode is ready in ${distro}.` : "OpenCode is ready."
|
||||
return distro ? `Install OpenCode in ${distro}.` : "Choose a distro first."
|
||||
})
|
||||
|
||||
const run = async (action: () => Promise<unknown>) => {
|
||||
try {
|
||||
await action()
|
||||
} catch (err) {
|
||||
requestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
const runSelectedDistro = (action: (distro: string) => Promise<unknown>) => {
|
||||
const distro = store.selectedDistro
|
||||
if (!distro) return
|
||||
void run(() => action(distro))
|
||||
}
|
||||
|
||||
const selectDistro = (name: string) => {
|
||||
setStore("selectedDistro", name)
|
||||
setStore("step", undefined)
|
||||
}
|
||||
|
||||
const finish = async () => {
|
||||
const distro = store.selectedDistro
|
||||
if (!distro) return
|
||||
setStore("adding", true)
|
||||
try {
|
||||
await api.addServer(distro)
|
||||
if (props.onAdded) {
|
||||
await props.onAdded(distro)
|
||||
} else {
|
||||
dialog.close()
|
||||
}
|
||||
} catch (err) {
|
||||
requestError(language, err)
|
||||
} finally {
|
||||
setStore("adding", false)
|
||||
}
|
||||
}
|
||||
|
||||
const steps = createMemo(() => {
|
||||
const active = activeStep()
|
||||
const activeIndex = STEPS.indexOf(active)
|
||||
const recommendedIndex = STEPS.indexOf(recommendedStep())
|
||||
return STEPS.map((step) => {
|
||||
const index = STEPS.indexOf(step)
|
||||
return {
|
||||
step,
|
||||
title: step === "wsl" ? "WSL" : step === "distro" ? "Choose distro" : "OpenCode",
|
||||
state:
|
||||
active === step
|
||||
? "current"
|
||||
: step === "wsl"
|
||||
? wslReady()
|
||||
? "done"
|
||||
: "warning"
|
||||
: step === "distro"
|
||||
? distroReady()
|
||||
? "done"
|
||||
: index > activeIndex
|
||||
? "locked"
|
||||
: "warning"
|
||||
: opencodeCheck()?.matchesDesktop === false
|
||||
? "warning"
|
||||
: opencodeReady()
|
||||
? "done"
|
||||
: index > activeIndex
|
||||
? "locked"
|
||||
: "warning",
|
||||
locked: index > recommendedIndex,
|
||||
}
|
||||
})
|
||||
})
|
||||
const loadError = createMemo(() => {
|
||||
const error = wslServers.error
|
||||
if (!error) return "Failed to load WSL state."
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="px-5 pb-5 flex flex-col gap-4">
|
||||
<Show when={!wslServers.isPending} fallback={<div class="px-1 py-6 text-14-regular text-text-weak">Loading...</div>}>
|
||||
<Show when={!wslServers.isError} fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{loadError()}</div>}>
|
||||
<div class="flex gap-2 pb-1">
|
||||
<For each={steps()}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
class="basis-0 flex-1 min-w-0 rounded-md border px-3 py-2 text-left transition-colors"
|
||||
classList={{
|
||||
"border-border-strong-base bg-surface-base-hover": item.state === "current",
|
||||
"border-icon-success-base/40 bg-surface-base": item.state === "done",
|
||||
"border-border-weak-base bg-background-base opacity-60": item.state === "locked",
|
||||
"border-icon-warning-base/40 bg-surface-base": item.state === "warning",
|
||||
}}
|
||||
disabled={item.locked}
|
||||
onClick={() => setStore("step", item.step)}
|
||||
>
|
||||
<div class="text-13-medium text-text-strong">{item.title}</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Switch>
|
||||
<Match when={activeStep() === "wsl"}>
|
||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-14-medium text-text-strong">WSL</div>
|
||||
<Show when={current()?.runtime && !wslReady() && !current()?.pendingRestart}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy()}
|
||||
onClick={() => void run(() => api.installWsl())}
|
||||
>
|
||||
Install WSL
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{wslMessage()}</div>
|
||||
<Show when={current()?.pendingRestart}>
|
||||
<div class="rounded-md border border-border-weak-base px-3 py-3 flex items-center justify-between gap-3">
|
||||
<div class="text-12-regular text-text-warning-base">Windows restart required.</div>
|
||||
<Button variant="secondary" size="large" onClick={() => void platform.restart()}>
|
||||
Relaunch OpenCode
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center justify-end">
|
||||
<Button variant="secondary" size="large" disabled={busy() || !wslReady()} onClick={() => setStore("step", "distro")}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={activeStep() === "distro"}>
|
||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-14-medium text-text-strong">Choose a distro</div>
|
||||
<Show when={store.selectedDistro}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
disabled={busy()}
|
||||
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{distroMessage()}</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Show
|
||||
when={addableInstalledDistros().length > 0}
|
||||
fallback={
|
||||
<div class="text-12-regular text-text-weak">
|
||||
{visibleInstalledDistros().length
|
||||
? "All installed distros are already added."
|
||||
: current()?.runtime?.available
|
||||
? "No distros detected yet."
|
||||
: "Checking distros..."}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={addableInstalledDistros()}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border-weak-base px-3 py-2 text-left transition-colors"
|
||||
classList={{ "bg-surface-raised-base": store.selectedDistro === item.name }}
|
||||
onClick={() => selectDistro(item.name)}
|
||||
>
|
||||
<div class="text-13-medium text-text-strong">{item.name}</div>
|
||||
<Show when={item.isDefault}>
|
||||
<div class="text-12-regular text-text-weak">Default</div>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={installableDistros().length > 0}>
|
||||
<div class="rounded-md border border-border-weak-base p-2 flex flex-col gap-2">
|
||||
<div class="px-1 flex items-center justify-between gap-3">
|
||||
<div class="text-12-medium text-text-weak">Install</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Show when={installingDistro()}>
|
||||
<Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
|
||||
</Show>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
disabled={busy() || !installTarget()}
|
||||
onClick={() => void run(() => api.installDistro(installTarget()!.name))}
|
||||
>
|
||||
{installingDistro() ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Install distro"
|
||||
class="max-h-52 overflow-y-auto rounded-md bg-background-base"
|
||||
>
|
||||
<For each={installableDistros()}>
|
||||
{(item) => {
|
||||
const selected = () => store.installTarget === item.name
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected()}
|
||||
disabled={busy()}
|
||||
class="w-full px-3 py-2 flex items-center gap-3 text-left border-b border-border-weak-base last:border-b-0 transition-colors"
|
||||
classList={{
|
||||
"bg-surface-raised-base": selected(),
|
||||
"hover:bg-surface-base": !selected(),
|
||||
}}
|
||||
onClick={() => setStore("installTarget", item.name)}
|
||||
>
|
||||
<div
|
||||
class="mt-0.5 h-4 w-4 rounded-full border border-border-strong-base flex items-center justify-center shrink-0"
|
||||
classList={{ "border-text-strong": selected() }}
|
||||
>
|
||||
<div class="h-2 w-2 rounded-full bg-text-strong" classList={{ hidden: !selected() }} />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-13-medium text-text-strong truncate">{item.label}</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={
|
||||
selectedInstalled()?.version === 1 ||
|
||||
distroUnavailableMessage() ||
|
||||
distroMissingTools()
|
||||
}
|
||||
>
|
||||
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
|
||||
<Show when={selectedInstalled()?.version === 1}>
|
||||
<div class="text-12-regular text-text-warning-base">WSL 2 is required.</div>
|
||||
</Show>
|
||||
<Show when={distroUnavailableMessage()}>
|
||||
{(message) => <div class="text-12-regular text-text-warning-base">{message()}</div>}
|
||||
</Show>
|
||||
<Show when={distroMissingTools()}>
|
||||
<div class="text-12-regular text-text-warning-base">This distro needs bash and curl.</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy() || !selectedInstalled()}
|
||||
onClick={() => runSelectedDistro((distro) => api.openTerminal(distro))}
|
||||
>
|
||||
Open terminal
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
disabled={busy() || !store.selectedDistro}
|
||||
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy() || !store.selectedDistro || !distroReady()}
|
||||
onClick={() => setStore("step", "opencode")}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={activeStep() === "opencode"}>
|
||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-14-medium text-text-strong">OpenCode</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={store.selectedDistro}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
disabled={busy()}
|
||||
onClick={() => runSelectedDistro((distro) => api.probeOpencode(distro))}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={!opencodeReady() || opencodeCheck()?.matchesDesktop === false}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy()}
|
||||
onClick={() => runSelectedDistro((distro) => api.installOpencode(distro))}
|
||||
>
|
||||
<Show when={installingOpencode()}>
|
||||
<Spinner class="size-4 shrink-0" />
|
||||
</Show>
|
||||
{opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{opencodeMessage()}</div>
|
||||
<Show when={opencodeCheck()?.matchesDesktop === false ? opencodeCheck() : null}>
|
||||
{(check) => (
|
||||
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
|
||||
<div class="text-12-regular text-text-weak">Path: {check().resolvedPath ?? "not found"}</div>
|
||||
<div class="text-12-regular text-text-weak">
|
||||
Version: {check().version ?? "unknown"}
|
||||
<Show when={check().expectedVersion}>
|
||||
{(expected) => <span>{` · desktop ${expected()}`}</span>}
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-warning-base">
|
||||
Installed version does not match the desktop app version.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
<Show when={activeStep() === "opencode" && allReady() && store.selectedDistro}>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="large" disabled={store.adding} onClick={() => dialog.close()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="large" disabled={addDisabled()} onClick={() => void finish()}>
|
||||
{store.adding ? "Adding..." : "Add WSL server"}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function requestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||
console.error("WSL servers request failed", err instanceof Error ? (err.stack ?? err.message) : String(err))
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -162,8 +162,8 @@ beforeAll(async () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/global-sync", () => ({
|
||||
useGlobalSync: () => ({
|
||||
mock.module("@/context/server-sync", () => ({
|
||||
useServerSync: () => ({
|
||||
child: (directory: string) => {
|
||||
syncedDirectories.push(directory)
|
||||
storedSessions[directory] ??= []
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { batch, type Accessor } from "solid-js"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLocal } from "@/context/local"
|
||||
@@ -38,7 +38,7 @@ export type FollowupDraft = {
|
||||
|
||||
type FollowupSendInput = {
|
||||
client: ReturnType<typeof useSDK>["client"]
|
||||
globalSync: ReturnType<typeof useGlobalSync>
|
||||
serverSync: ReturnType<typeof useServerSync>
|
||||
sync: ReturnType<typeof useSync>
|
||||
draft: FollowupDraft
|
||||
messageID?: string
|
||||
@@ -53,7 +53,7 @@ const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttac
|
||||
export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
const text = draftText(input.draft.prompt)
|
||||
const images = draftImages(input.draft.prompt)
|
||||
const [, setStore] = input.globalSync.child(input.draft.sessionDirectory)
|
||||
const [, setStore] = input.serverSync.child(input.draft.sessionDirectory)
|
||||
|
||||
const setBusy = () => {
|
||||
if (!input.optimisticBusy) return
|
||||
@@ -205,7 +205,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const globalSync = useGlobalSync()
|
||||
const serverSync = useServerSync()
|
||||
const local = useLocal()
|
||||
const permission = usePermission()
|
||||
const prompt = usePrompt()
|
||||
@@ -226,8 +226,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return Promise.resolve()
|
||||
|
||||
globalSync.todo.set(sessionID, [])
|
||||
const [, setStore] = globalSync.child(sdk.directory)
|
||||
serverSync.todo.set(sessionID, [])
|
||||
const [, setStore] = serverSync.child(sdk.directory)
|
||||
setStore("todo", sessionID, [])
|
||||
|
||||
input.onAbort?.()
|
||||
@@ -273,7 +273,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
const seed = (dir: string, info: Session) => {
|
||||
const [, setStore] = globalSync.child(dir)
|
||||
const [, setStore] = serverSync.child(dir)
|
||||
setStore("session", (list: Session[]) => {
|
||||
const result = Binary.search(list, info.id, (item) => item.id)
|
||||
const next = [...list]
|
||||
@@ -354,7 +354,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
directory: sessionDirectory,
|
||||
throwOnError: true,
|
||||
})
|
||||
globalSync.child(sessionDirectory)
|
||||
serverSync.child(sessionDirectory)
|
||||
}
|
||||
|
||||
input.onNewSessionWorktreeReset?.()
|
||||
@@ -557,7 +557,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
void sendFollowupDraft({
|
||||
client,
|
||||
sync,
|
||||
globalSync,
|
||||
serverSync,
|
||||
draft,
|
||||
messageID,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { ServerHealth } from "@/utils/server-health"
|
||||
interface ServerRowProps extends ParentProps {
|
||||
conn: ServerConnection.Any
|
||||
status?: ServerHealth
|
||||
version?: string
|
||||
class?: string
|
||||
nameClass?: string
|
||||
versionClass?: string
|
||||
@@ -31,6 +32,8 @@ export function ServerRow(props: ServerRowProps) {
|
||||
let nameRef: HTMLSpanElement | undefined
|
||||
let versionRef: HTMLSpanElement | undefined
|
||||
const name = createMemo(() => serverName(props.conn))
|
||||
const isWsl = createMemo(() => props.conn.type === "sidecar" && props.conn.variant === "wsl")
|
||||
const version = createMemo(() => props.version ?? props.status?.version)
|
||||
|
||||
const check = () => {
|
||||
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
||||
@@ -41,7 +44,7 @@ export function ServerRow(props: ServerRowProps) {
|
||||
createEffect(() => {
|
||||
name()
|
||||
props.conn.http.url
|
||||
props.status?.version
|
||||
version()
|
||||
queueMicrotask(check)
|
||||
})
|
||||
|
||||
@@ -54,8 +57,11 @@ export function ServerRow(props: ServerRowProps) {
|
||||
const tooltipValue = () => (
|
||||
<span class="flex items-center gap-2">
|
||||
<span>{serverName(props.conn, true)}</span>
|
||||
<Show when={props.status?.version}>
|
||||
<span class="text-text-invert-weak">v{props.status?.version}</span>
|
||||
<Show when={isWsl()}>
|
||||
<span class="text-text-invert-weak">WSL</span>
|
||||
</Show>
|
||||
<Show when={version()}>
|
||||
<span class="text-text-invert-weak">v{version()}</span>
|
||||
</Show>
|
||||
</span>
|
||||
)
|
||||
@@ -76,15 +82,20 @@ export function ServerRow(props: ServerRowProps) {
|
||||
<span ref={nameRef} class={`${props.nameClass ?? "truncate"} min-w-0`}>
|
||||
{name()}
|
||||
</span>
|
||||
<Show when={isWsl()}>
|
||||
<span class="text-11-regular text-text-weak border border-border-weak-base bg-surface-base px-1.5 py-0.5 rounded-md shrink-0">
|
||||
WSL
|
||||
</span>
|
||||
</Show>
|
||||
<Show
|
||||
when={badge()}
|
||||
fallback={
|
||||
<Show when={props.status?.version}>
|
||||
<Show when={version()}>
|
||||
<span
|
||||
ref={versionRef}
|
||||
class={`${props.versionClass ?? "text-text-weak text-14-regular truncate"} min-w-0`}
|
||||
>
|
||||
v{props.status?.version}
|
||||
v{version()}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
}),
|
||||
)
|
||||
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
|
||||
const context = createMemo(() => metrics().context)
|
||||
const cost = createMemo(() => {
|
||||
return usd().format(metrics().totalCost)
|
||||
|
||||
@@ -3,3 +3,4 @@ export { SessionContextTab } from "./session-context-tab"
|
||||
export { SortableTab, FileVisual } from "./session-sortable-tab"
|
||||
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
|
||||
export { NewSessionView } from "./session-new-view"
|
||||
export { NewSessionDesignView } from "./session-new-design-view"
|
||||
|
||||
@@ -132,7 +132,7 @@ export function SessionContextTab() {
|
||||
}),
|
||||
)
|
||||
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
|
||||
const ctx = createMemo(() => metrics().context)
|
||||
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||
|
||||
|
||||
@@ -24,7 +24,9 @@ import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { StatusPopover } from "../status-popover"
|
||||
import { StatusPopover, StatusPopoverV2 } from "../status-popover"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
|
||||
|
||||
const OPEN_APPS = [
|
||||
"vscode",
|
||||
@@ -153,11 +155,11 @@ export function SessionHeader() {
|
||||
})
|
||||
const hotkey = createMemo(() => command.keybind("file.open"))
|
||||
const os = createMemo(() => detectOS(platform))
|
||||
const isDesktopBeta = platform.platform === "desktop" && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
|
||||
const search = createMemo(() => !isDesktopBeta || settings.general.showSearch())
|
||||
const tree = createMemo(() => !isDesktopBeta || settings.general.showFileTree())
|
||||
const term = createMemo(() => !isDesktopBeta || settings.general.showTerminal())
|
||||
const status = createMemo(() => !isDesktopBeta || settings.general.showStatus())
|
||||
const isDesktopV2 = createMemo(() => platform.platform === "desktop" && settings.general.newLayoutDesigns())
|
||||
const search = createMemo(() => (isDesktopV2() ? settings.general.showSearch() : true))
|
||||
const tree = createMemo(() => (isDesktopV2() ? settings.general.showFileTree() : true))
|
||||
const term = createMemo(() => (isDesktopV2() ? settings.general.showTerminal() : true))
|
||||
const status = createMemo(() => (isDesktopV2() ? settings.general.showStatus() : true))
|
||||
|
||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||
finder: true,
|
||||
@@ -231,6 +233,14 @@ export function SessionHeader() {
|
||||
const tint = createMemo(() =>
|
||||
messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
|
||||
)
|
||||
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
|
||||
statusVisible: status(),
|
||||
statusLabel: language.t("status.popover.trigger"),
|
||||
reviewLabel: language.t("command.review.toggle"),
|
||||
reviewKeybind: command.keybind("review.toggle"),
|
||||
reviewOpened: view().reviewPanel.opened(),
|
||||
onReviewToggle: () => view().reviewPanel.toggle(),
|
||||
}))
|
||||
|
||||
const selectApp = (app: OpenApp) => {
|
||||
if (!options().some((item) => item.id === app)) return
|
||||
@@ -311,193 +321,235 @@ export function SessionHeader() {
|
||||
<Show when={rightMount()}>
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={projectDirectory()}>
|
||||
<div class="hidden xl:flex items-center">
|
||||
<Show
|
||||
when={canOpen()}
|
||||
fallback={
|
||||
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
|
||||
onClick={copyPath}
|
||||
aria-label={language.t("session.header.open.copyPath")}
|
||||
>
|
||||
<Icon name="copy" size="small" class="text-icon-base" />
|
||||
<span class="text-12-regular text-text-strong">
|
||||
{language.t("session.header.open.copyPath")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
|
||||
classList={{
|
||||
"bg-surface-raised-base-active": opening(),
|
||||
}}
|
||||
onClick={() => openDir(current().id)}
|
||||
disabled={opening()}
|
||||
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
|
||||
>
|
||||
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
|
||||
<Show when={opening()} fallback={<AppIcon id={current().icon} />}>
|
||||
<Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
|
||||
</Show>
|
||||
<Show
|
||||
when={isDesktopV2}
|
||||
fallback={
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={projectDirectory()}>
|
||||
<div class="hidden xl:flex items-center">
|
||||
<Show
|
||||
when={canOpen()}
|
||||
fallback={
|
||||
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
|
||||
onClick={copyPath}
|
||||
aria-label={language.t("session.header.open.copyPath")}
|
||||
>
|
||||
<Icon name="copy" size="small" class="text-icon-base" />
|
||||
<span class="text-12-regular text-text-strong">
|
||||
{language.t("session.header.open.copyPath")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
open={menu.open}
|
||||
onOpenChange={(open) => setMenu("open", open)}
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="chevron-down"
|
||||
variant="ghost"
|
||||
disabled={opening()}
|
||||
class="rounded-none h-full w-[20px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
|
||||
classList={{
|
||||
"bg-surface-raised-base-active": opening(),
|
||||
}}
|
||||
aria-label={language.t("session.header.open.menu")}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="[&_[data-slot=dropdown-menu-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]+[data-slot=dropdown-menu-radio-item]]:mt-1">
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupLabel class="!px-1 !py-1">
|
||||
{language.t("session.header.openIn")}
|
||||
</DropdownMenu.GroupLabel>
|
||||
<DropdownMenu.RadioGroup
|
||||
class="mt-1"
|
||||
value={current().id}
|
||||
onChange={(value) => {
|
||||
if (!OPEN_APPS.includes(value as OpenApp)) return
|
||||
selectApp(value as OpenApp)
|
||||
}}
|
||||
>
|
||||
<For each={options()}>
|
||||
{(o) => (
|
||||
<DropdownMenu.RadioItem
|
||||
value={o.id}
|
||||
disabled={opening()}
|
||||
onSelect={() => {
|
||||
setMenu("open", false)
|
||||
openDir(o.id)
|
||||
}}
|
||||
>
|
||||
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
|
||||
<AppIcon id={o.icon} />
|
||||
</div>
|
||||
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.ItemIndicator>
|
||||
<Icon name="check-small" size="small" class="text-icon-weak" />
|
||||
</DropdownMenu.ItemIndicator>
|
||||
</DropdownMenu.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setMenu("open", false)
|
||||
copyPath()
|
||||
}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
|
||||
classList={{
|
||||
"bg-surface-raised-base-active": opening(),
|
||||
}}
|
||||
onClick={() => openDir(current().id)}
|
||||
disabled={opening()}
|
||||
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
|
||||
>
|
||||
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
|
||||
<Show when={opening()} fallback={<AppIcon id={current().icon} />}>
|
||||
<Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
|
||||
</Show>
|
||||
</div>
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
open={menu.open}
|
||||
onOpenChange={(open) => setMenu("open", open)}
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="chevron-down"
|
||||
variant="ghost"
|
||||
disabled={opening()}
|
||||
class="rounded-none h-full w-[20px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
|
||||
classList={{
|
||||
"bg-surface-raised-base-active": opening(),
|
||||
}}
|
||||
>
|
||||
<div class="flex size-5 shrink-0 items-center justify-center">
|
||||
<Icon name="copy" size="small" class="text-icon-weak" />
|
||||
</div>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("session.header.open.copyPath")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
aria-label={language.t("session.header.open.menu")}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="[&_[data-slot=dropdown-menu-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]+[data-slot=dropdown-menu-radio-item]]:mt-1">
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupLabel class="!px-1 !py-1">
|
||||
{language.t("session.header.openIn")}
|
||||
</DropdownMenu.GroupLabel>
|
||||
<DropdownMenu.RadioGroup
|
||||
class="mt-1"
|
||||
value={current().id}
|
||||
onChange={(value) => {
|
||||
if (!OPEN_APPS.includes(value as OpenApp)) return
|
||||
selectApp(value as OpenApp)
|
||||
}}
|
||||
>
|
||||
<For each={options()}>
|
||||
{(o) => (
|
||||
<DropdownMenu.RadioItem
|
||||
value={o.id}
|
||||
disabled={opening()}
|
||||
onSelect={() => {
|
||||
setMenu("open", false)
|
||||
openDir(o.id)
|
||||
}}
|
||||
>
|
||||
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
|
||||
<AppIcon id={o.icon} />
|
||||
</div>
|
||||
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.ItemIndicator>
|
||||
<Icon name="check-small" size="small" class="text-icon-weak" />
|
||||
</DropdownMenu.ItemIndicator>
|
||||
</DropdownMenu.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setMenu("open", false)
|
||||
copyPath()
|
||||
}}
|
||||
>
|
||||
<div class="flex size-5 shrink-0 items-center justify-center">
|
||||
<Icon name="copy" size="small" class="text-icon-weak" />
|
||||
</div>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("session.header.open.copyPath")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1">
|
||||
<Show when={status()}>
|
||||
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
||||
<StatusPopover />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={term()}>
|
||||
<TooltipKeybind
|
||||
title={language.t("command.terminal.toggle")}
|
||||
keybind={command.keybind("terminal.toggle")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
|
||||
onClick={toggleTerminal}
|
||||
aria-label={language.t("command.terminal.toggle")}
|
||||
aria-expanded={view().terminal.opened()}
|
||||
aria-controls="terminal-panel"
|
||||
>
|
||||
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
|
||||
<div class="hidden md:flex items-center gap-1 shrink-0">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.review.toggle")}
|
||||
keybind={command.keybind("review.toggle")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border"
|
||||
onClick={() => view().reviewPanel.toggle()}
|
||||
aria-label={language.t("command.review.toggle")}
|
||||
aria-expanded={view().reviewPanel.opened()}
|
||||
aria-controls="review-panel"
|
||||
>
|
||||
<Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
|
||||
<Show when={tree()}>
|
||||
<TooltipKeybind
|
||||
title={language.t("command.fileTree.toggle")}
|
||||
keybind={command.keybind("fileTree.toggle")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||
onClick={() => layout.fileTree.toggle()}
|
||||
aria-label={language.t("command.fileTree.toggle")}
|
||||
aria-expanded={layout.fileTree.opened()}
|
||||
aria-controls="file-tree-panel"
|
||||
<div class="flex items-center gap-1">
|
||||
<Show when={status()}>
|
||||
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
||||
<StatusPopover />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={term()}>
|
||||
<TooltipKeybind
|
||||
title={language.t("command.terminal.toggle")}
|
||||
keybind={command.keybind("terminal.toggle")}
|
||||
>
|
||||
<div class="relative flex items-center justify-center size-4">
|
||||
<Icon
|
||||
size="small"
|
||||
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
|
||||
classList={{
|
||||
"text-icon-strong": layout.fileTree.opened(),
|
||||
"text-icon-weak": !layout.fileTree.opened(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
|
||||
onClick={toggleTerminal}
|
||||
aria-label={language.t("command.terminal.toggle")}
|
||||
aria-expanded={view().terminal.opened()}
|
||||
aria-controls="terminal-panel"
|
||||
>
|
||||
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
|
||||
<div class="hidden md:flex items-center gap-1 shrink-0">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.review.toggle")}
|
||||
keybind={command.keybind("review.toggle")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border"
|
||||
onClick={() => view().reviewPanel.toggle()}
|
||||
aria-label={language.t("command.review.toggle")}
|
||||
aria-expanded={view().reviewPanel.opened()}
|
||||
aria-controls="review-panel"
|
||||
>
|
||||
<Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
|
||||
<Show when={tree()}>
|
||||
<TooltipKeybind
|
||||
title={language.t("command.fileTree.toggle")}
|
||||
keybind={command.keybind("fileTree.toggle")}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||
onClick={() => layout.fileTree.toggle()}
|
||||
aria-label={language.t("command.fileTree.toggle")}
|
||||
aria-expanded={layout.fileTree.opened()}
|
||||
aria-controls="file-tree-panel"
|
||||
>
|
||||
<div class="relative flex items-center justify-center size-4">
|
||||
<Icon
|
||||
size="small"
|
||||
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
|
||||
classList={{
|
||||
"text-icon-strong": layout.fileTree.opened(),
|
||||
"text-icon-weak": !layout.fileTree.opened(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SessionHeaderV2Actions state={v2ActionsState()} />
|
||||
</Show>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type SessionHeaderV2ActionsState = {
|
||||
statusVisible: boolean
|
||||
statusLabel: string
|
||||
reviewLabel: string
|
||||
reviewKeybind: string
|
||||
reviewOpened: boolean
|
||||
onReviewToggle: () => void
|
||||
}
|
||||
|
||||
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
||||
return (
|
||||
<div class="flex items-center gap-0">
|
||||
<Show when={props.state.statusVisible}>
|
||||
<Tooltip placement="bottom" value={props.state.statusLabel}>
|
||||
<StatusPopoverV2 />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="!w-9 shrink-0"
|
||||
state={props.state.reviewOpened ? "pressed" : undefined}
|
||||
onClick={props.state.onReviewToggle}
|
||||
aria-label={props.state.reviewLabel}
|
||||
aria-expanded={props.state.reviewOpened}
|
||||
aria-controls="review-panel"
|
||||
icon={<IconV2 name="sidebar-right" />}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { JSX } from "solid-js"
|
||||
import { WordmarkV2 } from "@opencode-ai/ui/v2/components/wordmark-v2.jsx"
|
||||
|
||||
export function NewSessionDesignView(props: { children: JSX.Element }) {
|
||||
return (
|
||||
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep">
|
||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||
<div class="w-full max-w-[720px]">
|
||||
<WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" />
|
||||
<div class="mt-8">{props.children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,8 +12,8 @@ import { useParams } from "@solidjs/router"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import {
|
||||
monoDefault,
|
||||
monoFontFamily,
|
||||
@@ -175,8 +175,8 @@ export const SettingsGeneral: Component = () => {
|
||||
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const globalSync = useGlobalSync()
|
||||
const globalSdk = useGlobalSDK()
|
||||
const serverSync = useServerSync()
|
||||
const globalSdk = useServerSDK()
|
||||
|
||||
const [shells] = createResource(
|
||||
() =>
|
||||
@@ -193,16 +193,22 @@ export const SettingsGeneral: Component = () => {
|
||||
{ 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(() => globalSync.data.config.shell ?? "")
|
||||
const currentShell = createMemo(() => serverSync.data.config.shell ?? "")
|
||||
|
||||
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
||||
const list = shells.latest
|
||||
const current = globalSync.data.config.shell
|
||||
const current = serverSync.data.config.shell
|
||||
|
||||
const nameCounts = new Map<string, number>()
|
||||
for (const s of list) {
|
||||
@@ -239,6 +245,13 @@ export const SettingsGeneral: Component = () => {
|
||||
})
|
||||
}
|
||||
|
||||
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") },
|
||||
@@ -330,7 +343,7 @@ export const SettingsGeneral: Component = () => {
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
if (option.value === currentShell()) return
|
||||
globalSync.updateConfig({ shell: option.value })
|
||||
serverSync.updateConfig({ shell: option.value })
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
@@ -386,6 +399,18 @@ export const SettingsGeneral: Component = () => {
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.newLayoutDesigns.title")}
|
||||
description={language.t("settings.general.row.newLayoutDesigns.description")}
|
||||
>
|
||||
<div data-action="settings-new-layout-designs">
|
||||
<Switch
|
||||
checked={settings.general.newLayoutDesigns()}
|
||||
onChange={(checked) => settings.general.setNewLayoutDesigns(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
@@ -431,18 +456,6 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showTerminal.title")}
|
||||
description={language.t("settings.general.row.showTerminal.description")}
|
||||
>
|
||||
<div data-action="settings-show-terminal">
|
||||
<Switch
|
||||
checked={settings.general.showTerminal()}
|
||||
onChange={(checked) => settings.general.setShowTerminal(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showStatus.title")}
|
||||
description={language.t("settings.general.row.showStatus.description")}
|
||||
@@ -454,6 +467,18 @@ export const SettingsGeneral: Component = () => {
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
@@ -729,7 +754,45 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
)
|
||||
|
||||
console.log(import.meta.env)
|
||||
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)]">
|
||||
@@ -749,33 +812,9 @@ export const SettingsGeneral: Component = () => {
|
||||
|
||||
<UpdatesSection />
|
||||
|
||||
<Show when={linux()}>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
|
||||
<DisplaySection />
|
||||
|
||||
<SettingsList>
|
||||
<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>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
|
||||
<Show when={desktop()}>
|
||||
<AdvancedSection />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,8 @@ import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||
@@ -30,8 +30,8 @@ const PROVIDER_NOTES = [
|
||||
export const SettingsProviders: Component = () => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const providers = useProviders()
|
||||
|
||||
const connected = createMemo(() => {
|
||||
@@ -74,7 +74,7 @@ export const SettingsProviders: Component = () => {
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
const isConfigCustom = (providerID: string) => {
|
||||
const provider = globalSync.data.config.provider?.[providerID]
|
||||
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
|
||||
@@ -82,11 +82,11 @@ export const SettingsProviders: Component = () => {
|
||||
}
|
||||
|
||||
const disableProvider = async (providerID: string, name: string) => {
|
||||
const before = globalSync.data.config.disabled_providers ?? []
|
||||
const before = serverSync.data.config.disabled_providers ?? []
|
||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||
globalSync.set("config", "disabled_providers", next)
|
||||
serverSync.set("config", "disabled_providers", next)
|
||||
|
||||
await globalSync
|
||||
await serverSync
|
||||
.updateConfig({ disabled_providers: next })
|
||||
.then(() => {
|
||||
showToast({
|
||||
@@ -97,7 +97,7 @@ export const SettingsProviders: Component = () => {
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
globalSync.set("config", "disabled_providers", before)
|
||||
serverSync.set("config", "disabled_providers", before)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
@@ -105,14 +105,14 @@ export const SettingsProviders: Component = () => {
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
if (isConfigCustom(providerID)) {
|
||||
await globalSDK.client.auth.remove({ providerID }).catch(() => undefined)
|
||||
await serverSDK.client.auth.remove({ providerID }).catch(() => undefined)
|
||||
await disableProvider(providerID, name)
|
||||
return
|
||||
}
|
||||
await globalSDK.client.auth
|
||||
await serverSDK.client.auth
|
||||
.remove({ providerID })
|
||||
.then(async () => {
|
||||
await globalSDK.client.global.dispose()
|
||||
await serverSDK.client.global.dispose()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
|
||||
@@ -5,18 +5,19 @@ import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useLocation, useNavigate } from "@solidjs/router"
|
||||
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
import { useQueryOptions } from "@/context/global-sync"
|
||||
import { type ServerHealth } from "@/utils/server-health"
|
||||
import { useQueryOptions } from "@/context/server-sync"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useServers } from "@/context/servers"
|
||||
|
||||
const pollMs = 10_000
|
||||
|
||||
@@ -54,40 +55,6 @@ const listServersByHealth = (
|
||||
})
|
||||
}
|
||||
|
||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
||||
|
||||
createEffect(() => {
|
||||
if (!enabled()) {
|
||||
setStatus(reconcile({}))
|
||||
return
|
||||
}
|
||||
const list = servers()
|
||||
let dead = false
|
||||
|
||||
const refresh = async () => {
|
||||
const results: Record<string, ServerHealth> = {}
|
||||
await Promise.all(
|
||||
list.map(async (conn) => {
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||
}),
|
||||
)
|
||||
if (dead) return
|
||||
setStatus(reconcile(results))
|
||||
}
|
||||
|
||||
void refresh()
|
||||
const id = setInterval(() => void refresh(), pollMs)
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
clearInterval(id)
|
||||
})
|
||||
})
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
const useDefaultServerKey = (
|
||||
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
||||
) => {
|
||||
@@ -166,14 +133,169 @@ const useMcpToggleMutation = () => {
|
||||
}))
|
||||
}
|
||||
|
||||
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
const sync = useSync()
|
||||
type ServerStatusState = {
|
||||
servers: () => ServerStatusItem[]
|
||||
defaultKey: () => ServerConnection.Key | undefined
|
||||
ariaLabel: string
|
||||
serversLabel: string
|
||||
defaultLabel: string
|
||||
manageLabel: string
|
||||
onManage: () => void
|
||||
}
|
||||
|
||||
type ServerStatusItem = {
|
||||
key: ServerConnection.Key
|
||||
conn: ServerConnection.Any
|
||||
health?: ServerHealth
|
||||
blocked: boolean
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
export function StatusPopoverServerBody() {
|
||||
const servers = useServers()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
|
||||
let dialogRun = 0
|
||||
let dialogDead = false
|
||||
onCleanup(() => {
|
||||
dialogDead = true
|
||||
dialogRun += 1
|
||||
})
|
||||
|
||||
const sortedServers = createMemo(() => listServersByHealth(servers.list(), server.key, servers.health))
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const serverItems = createMemo(() =>
|
||||
sortedServers().map((conn) => {
|
||||
const key = ServerConnection.key(conn)
|
||||
return {
|
||||
key,
|
||||
conn,
|
||||
health: servers.health[key],
|
||||
blocked: servers.health[key]?.healthy === false,
|
||||
active: !!server.current && key === ServerConnection.key(server.current),
|
||||
onSelect: () => {
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(key))
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
<ServerStatusPopoverView
|
||||
state={{
|
||||
servers: serverItems,
|
||||
defaultKey: defaultServer.key,
|
||||
ariaLabel: language.t("status.popover.ariaLabel"),
|
||||
serversLabel: language.t("status.popover.tab.servers"),
|
||||
defaultLabel: language.t("common.default"),
|
||||
manageLabel: language.t("status.popover.action.manageServers"),
|
||||
onManage: () => {
|
||||
const run = ++dialogRun
|
||||
void import("./dialog-select-server").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
|
||||
})
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ServerStatusPopoverView(props: { state: ServerStatusState }) {
|
||||
return (
|
||||
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
|
||||
<Tabs
|
||||
aria-label={props.state.ariaLabel}
|
||||
class="tabs bg-background-strong rounded-xl overflow-hidden"
|
||||
data-component="tabs"
|
||||
data-active="servers"
|
||||
defaultValue="servers"
|
||||
variant="alt"
|
||||
>
|
||||
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
||||
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
||||
{props.state.servers().length > 0 ? `${props.state.servers().length} ` : ""}
|
||||
{props.state.serversLabel}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="servers">
|
||||
<ServerStatusList state={props.state} />
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ServerStatusList(props: { state: ServerStatusState }) {
|
||||
return (
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<For each={props.state.servers()}>
|
||||
{(item) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
|
||||
classList={{
|
||||
"hover:bg-surface-raised-base-hover": !item.blocked,
|
||||
"cursor-not-allowed": item.blocked,
|
||||
}}
|
||||
aria-disabled={item.blocked}
|
||||
onClick={() => {
|
||||
if (item.blocked) return
|
||||
item.onSelect()
|
||||
}}
|
||||
>
|
||||
<ServerHealthIndicator health={item.health} />
|
||||
<ServerRow
|
||||
conn={item.conn}
|
||||
dimmed={item.blocked}
|
||||
status={item.health}
|
||||
class="flex items-center gap-2 w-full min-w-0"
|
||||
nameClass="text-14-regular text-text-base truncate"
|
||||
versionClass="text-12-regular text-text-weak truncate"
|
||||
badge={
|
||||
<Show when={item.key === props.state.defaultKey()}>
|
||||
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
||||
{props.state.defaultLabel}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="flex-1" />
|
||||
<Show when={item.active}>
|
||||
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
|
||||
</Show>
|
||||
</ServerRow>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Button variant="secondary" class="mt-3 self-start h-8 px-3 py-1.5" onClick={props.state.onManage}>
|
||||
{props.state.manageLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusPopoverBody(props: { shown: Accessor<boolean>; close?: () => void }) {
|
||||
const sync = useSync()
|
||||
const servers = useServers()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
@@ -192,15 +314,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
dialogDead = true
|
||||
dialogRun += 1
|
||||
})
|
||||
const servers = createMemo(() => {
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
if (!current) return list
|
||||
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
||||
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
||||
})
|
||||
const health = useServerHealth(servers, props.shown)
|
||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||
const sortedServers = createMemo(() => listServersByHealth(servers.list(), server.key, servers.health))
|
||||
const toggleMcp = useMcpToggleMutation()
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
@@ -226,7 +340,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
>
|
||||
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
||||
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
||||
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
|
||||
{servers.list().length > 0 ? `${servers.list().length} ` : ""}
|
||||
{language.t("status.popover.tab.servers")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
|
||||
@@ -249,7 +363,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
<For each={sortedServers()}>
|
||||
{(s) => {
|
||||
const key = ServerConnection.key(s)
|
||||
const blocked = () => health[key]?.healthy === false
|
||||
const blocked = () => servers.health[key]?.healthy === false
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -261,15 +375,23 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
aria-disabled={blocked()}
|
||||
onClick={() => {
|
||||
if (blocked()) return
|
||||
props.close?.()
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(key))
|
||||
const activate = () => {
|
||||
if (location.pathname !== "/") {
|
||||
setTimeout(activate, 16)
|
||||
return
|
||||
}
|
||||
setTimeout(() => server.setActive(key), 0)
|
||||
}
|
||||
setTimeout(activate, 0)
|
||||
}}
|
||||
>
|
||||
<ServerHealthIndicator health={health[key]} />
|
||||
<ServerHealthIndicator health={servers.health[key]} />
|
||||
<ServerRow
|
||||
conn={s}
|
||||
dimmed={blocked()}
|
||||
status={health[key]}
|
||||
status={servers.health[key]}
|
||||
class="flex items-center gap-2 w-full min-w-0"
|
||||
nameClass="text-14-regular text-text-base truncate"
|
||||
versionClass="text-12-regular text-text-weak truncate"
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { Suspense, createMemo, createSignal, lazy, Show } from "solid-js"
|
||||
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useServers } from "@/context/servers"
|
||||
|
||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
|
||||
|
||||
export function StatusPopover() {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const servers = useServers()
|
||||
const sync = useSync()
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const ready = createMemo(() => server.healthy() === false || sync.data.mcp_ready)
|
||||
const ready = createMemo(() => servers.health[server.key]?.healthy === false || sync.data.mcp_ready)
|
||||
const mcpIssue = createMemo(() => {
|
||||
const mcp = Object.values(sync.data.mcp ?? {})
|
||||
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
|
||||
@@ -21,7 +26,8 @@ export function StatusPopover() {
|
||||
if (failed) return "critical" as const
|
||||
if (warn) return "warning" as const
|
||||
})
|
||||
const healthy = createMemo(() => server.healthy() === true && !mcpIssue())
|
||||
const serverHealthy = () => servers.health[server.key]?.healthy === true
|
||||
const healthy = createMemo(() => servers.health[server.key]?.healthy === true && !mcpIssue())
|
||||
|
||||
return (
|
||||
<Popover
|
||||
@@ -43,10 +49,9 @@ export function StatusPopover() {
|
||||
classList={{
|
||||
"absolute -top-px -right-px size-1.5 rounded-full": true,
|
||||
"bg-icon-success-base": ready() && healthy(),
|
||||
"bg-icon-warning-base": ready() && server.healthy() === true && mcpIssue() === "warning",
|
||||
"bg-icon-critical-base":
|
||||
server.healthy() === false || (ready() && server.healthy() === true && mcpIssue() === "critical"),
|
||||
"bg-border-weak-base": server.healthy() === undefined || !ready(),
|
||||
"bg-icon-warning-base": ready() && serverHealthy() && mcpIssue() === "warning",
|
||||
"bg-icon-critical-base": serverHealthy() || (ready() && serverHealthy() && mcpIssue() === "critical"),
|
||||
"bg-border-weak-base": serverHealthy() || !ready(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -62,9 +67,141 @@ export function StatusPopover() {
|
||||
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
|
||||
}
|
||||
>
|
||||
<Body shown={shown} />
|
||||
<Body shown={shown} close={() => setShown(false)} />
|
||||
</Suspense>
|
||||
</Show>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusPopoverV2(props: { scope?: "server" }) {
|
||||
if (props.scope === "server") return <ServerStatusPopover />
|
||||
return <DirectoryStatusPopover />
|
||||
}
|
||||
|
||||
function DirectoryStatusPopover() {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const servers = useServers()
|
||||
const sync = useSync()
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const serverHealth = () => servers.health[server.key]?.healthy
|
||||
const ready = createMemo(() => serverHealth() === false || sync.data.mcp_ready)
|
||||
const mcpIssue = createMemo(() => {
|
||||
const mcp = Object.values(sync.data.mcp ?? {})
|
||||
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
|
||||
const warn = mcp.some((item) => item.status === "needs_auth")
|
||||
if (failed) return "critical" as const
|
||||
if (warn) return "warning" as const
|
||||
})
|
||||
const healthy = createMemo(() => serverHealth() === true && !mcpIssue())
|
||||
const state = createMemo<StatusPopoverState>(() => ({
|
||||
shown: shown(),
|
||||
ready: ready(),
|
||||
healthy: healthy(),
|
||||
serverHealth: serverHealth(),
|
||||
issue: mcpIssue(),
|
||||
label: language.t("status.popover.trigger"),
|
||||
onOpenChange: setShown,
|
||||
body: () => (
|
||||
<StatusPopoverBody shown={shown()}>
|
||||
<Body shown={shown} />
|
||||
</StatusPopoverBody>
|
||||
),
|
||||
}))
|
||||
|
||||
return <StatusPopoverView state={state()} />
|
||||
}
|
||||
|
||||
function ServerStatusPopover() {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const servers = useServers()
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const serverHealth = () => servers.health[server.key]?.healthy
|
||||
const state = createMemo<StatusPopoverState>(() => ({
|
||||
shown: shown(),
|
||||
ready: serverHealth() !== undefined,
|
||||
healthy: serverHealth() === true,
|
||||
serverHealth: serverHealth(),
|
||||
label: language.t("status.popover.trigger"),
|
||||
onOpenChange: setShown,
|
||||
body: () => (
|
||||
<StatusPopoverBody shown={shown()}>
|
||||
<ServerBody />
|
||||
</StatusPopoverBody>
|
||||
),
|
||||
}))
|
||||
|
||||
return <StatusPopoverView state={state()} />
|
||||
}
|
||||
|
||||
type StatusPopoverState = {
|
||||
shown: boolean
|
||||
ready: boolean
|
||||
healthy: boolean
|
||||
serverHealth: boolean | undefined
|
||||
issue?: "critical" | "warning"
|
||||
label: string
|
||||
onOpenChange: (value: boolean) => void
|
||||
body: () => JSX.Element
|
||||
}
|
||||
|
||||
function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
|
||||
return (
|
||||
<Show when={props.shown}>
|
||||
<Suspense
|
||||
fallback={<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />}
|
||||
>
|
||||
{props.children}
|
||||
</Suspense>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusPopoverView(props: { state: StatusPopoverState }) {
|
||||
const statusDotClass = () => ({
|
||||
"absolute rounded-full": true,
|
||||
"bg-icon-success-base": props.state.ready && props.state.healthy,
|
||||
"bg-icon-warning-base": props.state.ready && props.state.serverHealth === true && props.state.issue === "warning",
|
||||
"bg-icon-critical-base":
|
||||
props.state.serverHealth === false ||
|
||||
(props.state.ready && props.state.serverHealth === true && props.state.issue === "critical"),
|
||||
"bg-border-weak-base": props.state.serverHealth === undefined || !props.state.ready,
|
||||
})
|
||||
|
||||
const popoverProps = {
|
||||
class:
|
||||
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
|
||||
gutter: 4,
|
||||
placement: "bottom-end" as const,
|
||||
shift: -168,
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={props.state.shown}
|
||||
onOpenChange={props.state.onOpenChange}
|
||||
triggerAs={IconButtonV2}
|
||||
triggerProps={{
|
||||
variant: "ghost-muted",
|
||||
size: "large",
|
||||
class: "!w-9 shrink-0",
|
||||
state: props.state.shown ? "pressed" : undefined,
|
||||
"aria-label": props.state.label,
|
||||
}}
|
||||
trigger={
|
||||
<div class="relative size-4">
|
||||
<IconV2 name={props.state.shown ? "status-active" : "status"} />
|
||||
<div
|
||||
classList={statusDotClass()}
|
||||
class="-top-1 -right-1 size-2 border border-[var(--v2-background-bg-deep)]"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
{...popoverProps}
|
||||
>
|
||||
{props.state.body()}
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events"
|
||||
|
||||
describe("titlebar session events", () => {
|
||||
test("reads valid removed session tab details", () => {
|
||||
expect(
|
||||
readSessionTabsRemovedDetail(
|
||||
new CustomEvent(SESSION_TABS_REMOVED_EVENT, {
|
||||
detail: { directory: "/tmp/project", sessionIDs: ["ses_1", "ses_2", 1] },
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
directory: "/tmp/project",
|
||||
sessionIDs: ["ses_1", "ses_2"],
|
||||
})
|
||||
})
|
||||
|
||||
test("ignores invalid removed session tab details", () => {
|
||||
expect(readSessionTabsRemovedDetail(new Event(SESSION_TABS_REMOVED_EVENT))).toBeUndefined()
|
||||
expect(
|
||||
readSessionTabsRemovedDetail(
|
||||
new CustomEvent(SESSION_TABS_REMOVED_EVENT, {
|
||||
detail: { directory: "/tmp/project", sessionIDs: [] },
|
||||
}),
|
||||
),
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
export const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed"
|
||||
|
||||
export type SessionTabsRemovedDetail = {
|
||||
directory: string
|
||||
sessionIDs: string[]
|
||||
}
|
||||
|
||||
export function notifySessionTabsRemoved(input: SessionTabsRemovedDetail) {
|
||||
window.dispatchEvent(new CustomEvent(SESSION_TABS_REMOVED_EVENT, { detail: input }))
|
||||
}
|
||||
|
||||
export function readSessionTabsRemovedDetail(event: Event): SessionTabsRemovedDetail | undefined {
|
||||
if (!(event instanceof CustomEvent)) return undefined
|
||||
|
||||
const detail: unknown = event.detail
|
||||
if (!detail || typeof detail !== "object") return undefined
|
||||
if (!("directory" in detail)) return undefined
|
||||
if (!("sessionIDs" in detail)) return undefined
|
||||
if (typeof detail.directory !== "string") return undefined
|
||||
if (!Array.isArray(detail.sessionIDs)) return undefined
|
||||
|
||||
const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string")
|
||||
if (sessionIDs.length === 0) return undefined
|
||||
|
||||
return {
|
||||
directory: detail.directory,
|
||||
sessionIDs,
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,28 @@ import { Button } from "@opencode-ai/ui/button"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
|
||||
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { getAvatarColors, useLayout, type LocalProject } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { WindowsAppMenu } from "./windows-app-menu"
|
||||
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { decodeDirectory } from "@/pages/directory-layout"
|
||||
import { iife } from "@opencode-ai/core/util/iife"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
|
||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { StatusPopoverV2 } from "@/components/status-popover"
|
||||
import {
|
||||
readSessionTabsRemovedDetail,
|
||||
SESSION_TABS_REMOVED_EVENT,
|
||||
type SessionTabsRemovedDetail,
|
||||
} from "@/components/titlebar-session-events"
|
||||
|
||||
type TauriDesktopWindow = {
|
||||
startDragging?: () => Promise<void>
|
||||
@@ -40,13 +51,20 @@ type TauriApi = {
|
||||
const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__
|
||||
const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.()
|
||||
const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.()
|
||||
const titlebarHeight = 40
|
||||
const legacyTitlebarHeight = 40
|
||||
const v2TitlebarHeight = 44
|
||||
const minTitlebarZoom = 0.25
|
||||
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
|
||||
|
||||
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
|
||||
|
||||
export function Titlebar() {
|
||||
export type TitlebarUpdate = {
|
||||
version: () => string | undefined
|
||||
installing: () => boolean
|
||||
install: () => void
|
||||
}
|
||||
|
||||
export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const layout = useLayout()
|
||||
const platform = usePlatform()
|
||||
const command = useCommand()
|
||||
@@ -56,17 +74,20 @@ export function Titlebar() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const params = useParams()
|
||||
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
|
||||
|
||||
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
||||
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
||||
const electronWindows = createMemo(() => windows() && !tauriApi())
|
||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||
const web = createMemo(() => platform.platform === "web")
|
||||
const zoom = () => platform.webviewZoom?.() ?? 1
|
||||
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
|
||||
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
|
||||
const minHeight = () => {
|
||||
if (mac()) return `${titlebarHeight / zoom()}px`
|
||||
if (windows()) return `${titlebarHeight / Math.min(titlebarZoom(), 1)}px`
|
||||
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
|
||||
if (mac()) return `${height / zoom()}px`
|
||||
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
|
||||
return undefined
|
||||
}
|
||||
const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px`
|
||||
@@ -98,7 +119,23 @@ export function Titlebar() {
|
||||
const canBack = createMemo(() => history.index > 0)
|
||||
const canForward = createMemo(() => history.index < history.stack.length - 1)
|
||||
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
||||
const nav = createMemo(() => import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" || settings.general.showNavigation())
|
||||
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
|
||||
const updateState = createMemo<TitlebarUpdatePillState>(() => {
|
||||
const version = props.update?.version()
|
||||
return {
|
||||
visible: version !== undefined,
|
||||
installing: props.update?.installing() ?? false,
|
||||
label: "Update",
|
||||
ariaLabel: language.t("toast.update.action.installRestart"),
|
||||
title: version ? `Update ${version}` : undefined,
|
||||
onInstall: () => props.update?.install(),
|
||||
}
|
||||
})
|
||||
const v2RightState = createMemo<TitlebarV2RightState>(() => ({
|
||||
update: updateState(),
|
||||
statusVisible: !params.dir && settings.general.showStatus(),
|
||||
statusLabel: language.t("status.popover.trigger"),
|
||||
}))
|
||||
|
||||
const back = () => {
|
||||
const next = backPath(history)
|
||||
@@ -183,19 +220,41 @@ export function Titlebar() {
|
||||
|
||||
return (
|
||||
<header
|
||||
class="h-10 shrink-0 bg-background-base relative overflow-hidden flex flex-row"
|
||||
style={{ "min-height": minHeight(), "padding-left": mac() ? `${84 / zoom()}px` : 0 }}
|
||||
classList={{
|
||||
"shrink-0 relative overflow-hidden flex flex-row": true,
|
||||
"h-11 bg-v2-background-bg-deep": useV2Titlebar(),
|
||||
"h-10 bg-background-base": !useV2Titlebar(),
|
||||
}}
|
||||
style={{
|
||||
"min-height": minHeight(),
|
||||
"padding-left": mac() ? `${84 / zoom()}px` : 0,
|
||||
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
||||
"max-width": electronWindows()
|
||||
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
|
||||
: undefined,
|
||||
"align-self": electronWindows() ? "flex-start" : undefined,
|
||||
}}
|
||||
data-tauri-drag-region
|
||||
onMouseDown={drag}
|
||||
onDblClick={maximize}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<Match when={useV2Titlebar()}>
|
||||
{(_) => {
|
||||
const globalSync = useGlobalSync()
|
||||
const serverSync = useServerSync()
|
||||
const navigate = useNavigate()
|
||||
const homeMatch = useMatch(() => "/")
|
||||
|
||||
type Tab = { dir: string; sessionId: string; params: any; href: string }
|
||||
const newSessionHref = () => {
|
||||
if (params.dir) return `/${params.dir}/session`
|
||||
|
||||
const project = layout.projects.list()[0]
|
||||
if (!project) return "/"
|
||||
|
||||
return `/${base64Encode(project.worktree)}/session`
|
||||
}
|
||||
|
||||
type Tab = { dir: string; sessionId: string; href: string }
|
||||
|
||||
const [tabsStore, tabsStoreActions] = iife(() => {
|
||||
const [store, setStore] = createStore<Tab[]>(
|
||||
@@ -205,7 +264,6 @@ export function Titlebar() {
|
||||
{
|
||||
dir: decodeDirectory(params.dir) ?? "",
|
||||
sessionId: params.id,
|
||||
params: { id: params.id, dir: params.dir },
|
||||
href: makeSessionHref(params.dir, params.id),
|
||||
},
|
||||
]
|
||||
@@ -223,7 +281,7 @@ export function Titlebar() {
|
||||
)
|
||||
},
|
||||
removeTab: (href: string) => {
|
||||
startTransition(() => {
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
const index = tabs.findIndex((t) => t.href === href)
|
||||
@@ -236,11 +294,45 @@ export function Titlebar() {
|
||||
)
|
||||
})
|
||||
},
|
||||
removeSessions: (input: SessionTabsRemovedDetail) => {
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
const sessionIDs = new Set(input.sessionIDs)
|
||||
const currentHref = params.dir && params.id ? makeSessionHref(params.dir, params.id) : undefined
|
||||
const currentIndex = currentHref ? tabs.findIndex((tab) => tab.href === currentHref) : -1
|
||||
const removedCurrent =
|
||||
currentIndex !== -1 &&
|
||||
tabs[currentIndex]?.dir === input.directory &&
|
||||
sessionIDs.has(tabs[currentIndex]?.sessionId ?? "")
|
||||
|
||||
for (let i = tabs.length - 1; i >= 0; i--) {
|
||||
const tab = tabs[i]
|
||||
if (!tab) continue
|
||||
if (tab.dir !== input.directory) continue
|
||||
if (!sessionIDs.has(tab.sessionId)) continue
|
||||
tabs.splice(i, 1)
|
||||
}
|
||||
|
||||
if (!removedCurrent) return
|
||||
const nextTab = tabs[currentIndex] ?? tabs[tabs.length - 1]
|
||||
if (nextTab) navigate(nextTab.href)
|
||||
else navigate("/")
|
||||
}),
|
||||
)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
return [store, actions]
|
||||
})
|
||||
|
||||
makeEventListener(window, SESSION_TABS_REMOVED_EVENT, (event) => {
|
||||
const detail = readSessionTabsRemovedDetail(event)
|
||||
if (!detail) return
|
||||
tabsStoreActions.removeSessions(detail)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const params = useParams()
|
||||
if (!(params.dir && params.id)) return
|
||||
@@ -248,16 +340,112 @@ export function Titlebar() {
|
||||
tabsStoreActions.addTab({
|
||||
dir: decodeDirectory(params.dir) ?? "",
|
||||
sessionId: params.id,
|
||||
params: { id: params.id, dir: params.dir },
|
||||
href: makeSessionHref(params.dir, params.id),
|
||||
})
|
||||
})
|
||||
|
||||
const projects = createMemo(() => layout.projects.list())
|
||||
const projectByID = createMemo(
|
||||
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
||||
)
|
||||
|
||||
const currentSessionTab = () => {
|
||||
if (!params.dir || !params.id) return
|
||||
const href = makeSessionHref(params.dir, params.id)
|
||||
return tabsStore.find((tab) => tab.href === href)
|
||||
}
|
||||
|
||||
const closeCurrentSessionTab = () => {
|
||||
const tab = currentSessionTab()
|
||||
if (!tab) return false
|
||||
tabsStoreActions.removeTab(tab.href)
|
||||
return true
|
||||
}
|
||||
|
||||
const closeNewSessionTab = () => {
|
||||
if (!(params.dir && !params.id)) return false
|
||||
const last = tabsStore[tabsStore.length - 1]
|
||||
if (last) navigate(last.href)
|
||||
else navigate("/")
|
||||
return true
|
||||
}
|
||||
|
||||
makeEventListener(
|
||||
document,
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return
|
||||
if (event.key.toLowerCase() !== "w") return
|
||||
if (!(closeCurrentSessionTab() || closeNewSessionTab())) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
},
|
||||
{ capture: true },
|
||||
)
|
||||
|
||||
command.register(() => {
|
||||
const commands = [
|
||||
{
|
||||
id: `tab.prev`,
|
||||
category: "tab",
|
||||
title: "",
|
||||
keybind: `mod+option+ArrowLeft`,
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href)
|
||||
if (index === -1) return
|
||||
|
||||
index -= 1
|
||||
if (index === -1) index = tabsStore.length - 1
|
||||
|
||||
const next = tabsStore[index]
|
||||
if (next) navigate(next.href)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: `tab.next`,
|
||||
category: "tab",
|
||||
title: "",
|
||||
keybind: `mod+option+ArrowRight`,
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href)
|
||||
if (index === -1) return
|
||||
|
||||
index += 1
|
||||
if (index === tabsStore.length) index = 0
|
||||
|
||||
const next = tabsStore[index]
|
||||
if (next) navigate(next.href)
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => {
|
||||
const index = i
|
||||
const number = index + 1
|
||||
return {
|
||||
id: `tab.${number}`,
|
||||
category: "tab",
|
||||
title: "",
|
||||
keybind: `mod+${number}`,
|
||||
disabled: layout.projects.list().length <= index,
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
const tab = tabsStore[index]
|
||||
if (tab) navigate(tab.href)
|
||||
},
|
||||
}
|
||||
}),
|
||||
]
|
||||
|
||||
return commands
|
||||
})
|
||||
|
||||
const tabsEnriched = iife(() => {
|
||||
const base = mapArray(
|
||||
() => tabsStore,
|
||||
(tab) => {
|
||||
const sync = globalSync.createDirSyncContext(tab.dir)
|
||||
const sync = serverSync.createDirSyncContext(tab.dir)
|
||||
const session = sync.session.get(tab.sessionId)
|
||||
return session ? { ...tab, info: session } : null
|
||||
},
|
||||
@@ -267,78 +455,73 @@ export function Titlebar() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3">
|
||||
<div
|
||||
class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3 py-2"
|
||||
classList={{
|
||||
"pl-2": mac(),
|
||||
"pl-4": !mac(),
|
||||
}}
|
||||
>
|
||||
<ChannelIndicator />
|
||||
<Show when={windows() || linux()}>
|
||||
<WindowsAppMenu command={command} platform={platform} />
|
||||
<WindowsAppMenu command={command} platform={platform} variant="v2" />
|
||||
</Show>
|
||||
<IconButtonV2
|
||||
as="a"
|
||||
href="/"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="!w-8"
|
||||
state={!!useMatch(() => "/")() ? "pressed" : undefined}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M13.9948 11.668H9.32812M11.6641 9.33203V13.9987M6.66667 9.33203V13.9987H2V9.33203H6.66667ZM6.66667 2V6.66667H2V2H6.66667ZM13.9948 2V6.66667H9.32812V2H13.9948Z"
|
||||
stroke="currentColor"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</IconButtonV2>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<For each={tabsEnriched()}>
|
||||
{(tab, i) => (
|
||||
<>
|
||||
{i() !== 0 && <div class="w-[1.5px] h-3 rounded-full bg-[var(--v2-background-bg-layer-02)]" />}
|
||||
<TabNavItem
|
||||
href={tab.href}
|
||||
title={tab.info.title}
|
||||
onClose={() => tabsStoreActions.removeTab(tab.href)}
|
||||
hideClose={tabsEnriched().length < 2}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<button>
|
||||
<div class="p-1.5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
d="M7.99978 2.88867V13.1109M2.88867 7.99978H13.1109"
|
||||
stroke="#808080"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
as="a"
|
||||
href="/"
|
||||
class="!w-9"
|
||||
icon={<IconV2 name="grid-plus" />}
|
||||
state={!!homeMatch() ? "pressed" : undefined}
|
||||
/>
|
||||
|
||||
<div class="flex-1" />
|
||||
{/*<button class="px-2.5 py-1.5 bg-[rgba(0,0,0,0.08)] rounded-[6px]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
class="size-4"
|
||||
<div class="flex min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden">
|
||||
<div class="flex min-w-0 flex-row items-center gap-1.5 overflow-hidden">
|
||||
<For each={tabsEnriched()}>
|
||||
{(tab, i) => (
|
||||
<>
|
||||
{i() !== 0 && (
|
||||
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
|
||||
)}
|
||||
<TabNavItem
|
||||
href={tab.href}
|
||||
title={tab.info.title}
|
||||
project={projectForSession(tab.info, projects(), projectByID())}
|
||||
directory={tab.dir}
|
||||
onClose={() => tabsStoreActions.removeTab(tab.href)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<Show
|
||||
when={creating() && params.dir}
|
||||
fallback={
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="shrink-0"
|
||||
icon={<IconV2 name="plus" />}
|
||||
as="a"
|
||||
href={newSessionHref()}
|
||||
aria-label={language.t("command.session.new")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<path
|
||||
d="M10.4443 2.44436V13.5555M1.55546 13.5554H14.4443V2.44434H1.55542L1.55546 13.5554Z"
|
||||
stroke="#3A3A3A"
|
||||
<NewSessionTabItem
|
||||
href={`/${params.dir}/session`}
|
||||
title={language.t("command.session.new")}
|
||||
onClose={() => navigate(tabsEnriched().at(-1)?.href ?? "/")}
|
||||
/>
|
||||
</svg>
|
||||
</button>*/}
|
||||
</Show>
|
||||
<div class="min-w-0 flex-1" />
|
||||
</div>
|
||||
<TitlebarV2Right state={v2RightState()} />
|
||||
<Show when={windows() && !electronWindows()}>
|
||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
@@ -358,7 +541,7 @@ export function Titlebar() {
|
||||
<WindowsAppMenu command={command} platform={platform} />
|
||||
</Show>
|
||||
<Show when={mac()}>
|
||||
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
|
||||
{/*<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />*/}
|
||||
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
|
||||
<IconButton
|
||||
icon="menu"
|
||||
@@ -502,22 +685,75 @@ export function Titlebar() {
|
||||
)
|
||||
}
|
||||
|
||||
function TabNavItem(props: { href: string; title: string; hideClose?: boolean; onClose: () => void }) {
|
||||
type TitlebarUpdatePillState = {
|
||||
visible: boolean
|
||||
installing: boolean
|
||||
label: string
|
||||
ariaLabel: string
|
||||
title?: string
|
||||
onInstall: () => void
|
||||
}
|
||||
|
||||
type TitlebarV2RightState = {
|
||||
update: TitlebarUpdatePillState
|
||||
statusVisible: boolean
|
||||
statusLabel: string
|
||||
}
|
||||
|
||||
function TitlebarV2Right(props: { state: TitlebarV2RightState }) {
|
||||
return (
|
||||
<div class="flex shrink-0 items-center justify-end gap-0">
|
||||
<TitlebarUpdatePill state={props.state.update} />
|
||||
<Show when={props.state.statusVisible}>
|
||||
<Tooltip placement="bottom" value={props.state.statusLabel}>
|
||||
<StatusPopoverV2 scope="server" />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<div id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TitlebarUpdatePill(props: { state: TitlebarUpdatePillState }) {
|
||||
return (
|
||||
<Show when={props.state.visible}>
|
||||
<button
|
||||
type="button"
|
||||
class="h-5 shrink-0 rounded-[27px] bg-[var(--v2-background-bg-layer-03)] px-2.5 text-[11px] font-[530] leading-4 tracking-[0.05px] text-[var(--v2-text-text-base)] disabled:opacity-60"
|
||||
onClick={props.state.onInstall}
|
||||
disabled={props.state.installing}
|
||||
aria-label={props.state.ariaLabel}
|
||||
title={props.state.title}
|
||||
>
|
||||
{props.state.label}
|
||||
</button>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function TabNavItem(props: {
|
||||
href: string
|
||||
title: string
|
||||
project?: LocalProject
|
||||
directory: string
|
||||
onClose: () => void
|
||||
}) {
|
||||
const match = useMatch(() => props.href)
|
||||
const isActive = () => !!match()
|
||||
return (
|
||||
<div
|
||||
class="group flex flex-row items-center max-w-60 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] bg-[var(--tab-bg)] h-7 rounded-[6px] relative overflow-hidden"
|
||||
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
data-active={isActive()}
|
||||
>
|
||||
<a
|
||||
href={props.href}
|
||||
class="w-full h-full pl-1.5 flex-1 max-w-full flex flex-row items-center overflow-hidden font-medium"
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
|
||||
>
|
||||
{props.title}
|
||||
<ProjectTabAvatar project={props.project} directory={props.directory} />
|
||||
<span class="text-clip leading-5">{props.title}</span>
|
||||
</a>
|
||||
|
||||
<div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
|
||||
<div class="absolute not-group-hover:not-group-data-[active=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
|
||||
<div
|
||||
class="absolute inset-0 bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
|
||||
style={{
|
||||
@@ -528,25 +764,62 @@ function TabNavItem(props: { href: string; title: string; hideClose?: boolean; o
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100"
|
||||
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100 z-10"
|
||||
onClick={props.onClose}
|
||||
icon={
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
class="size-4"
|
||||
>
|
||||
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="currentColor" />
|
||||
</svg>
|
||||
}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectTabAvatar(props: { project?: LocalProject; directory: string }) {
|
||||
return (
|
||||
<AvatarV2
|
||||
fallback={displayName(props.project ?? { worktree: props.directory })}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
kind="org"
|
||||
size="small"
|
||||
{...getAvatarColors(props.project?.icon?.color)}
|
||||
class="size-4 rounded"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NewSessionTabItem(props: { href: string; title: string; onClose: () => void }) {
|
||||
return (
|
||||
<div class="group relative flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]">
|
||||
<a
|
||||
href={props.href}
|
||||
aria-current="page"
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-[var(--v2-text-text-base)]"
|
||||
>
|
||||
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
|
||||
<IconV2 name="edit" />
|
||||
</span>
|
||||
<span class="truncate leading-5">{props.title}</span>
|
||||
</a>
|
||||
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
aria-label="Close tab"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelIndicator() {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Show, type JSX } from "solid-js"
|
||||
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/components/icon-button-v2.jsx"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
|
||||
|
||||
import { useCommand } from "@/context/command"
|
||||
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
|
||||
@@ -10,6 +12,7 @@ import { usePlatform } from "@/context/platform"
|
||||
export function WindowsAppMenu(props: {
|
||||
command: ReturnType<typeof useCommand>
|
||||
platform: ReturnType<typeof usePlatform>
|
||||
variant?: "legacy" | "v2"
|
||||
}) {
|
||||
let lastFocused: HTMLElement | undefined
|
||||
|
||||
@@ -45,15 +48,32 @@ export function WindowsAppMenu(props: {
|
||||
|
||||
return (
|
||||
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="menu"
|
||||
variant="ghost"
|
||||
class="titlebar-icon rounded-md shrink-0"
|
||||
aria-label="OpenCode menu"
|
||||
onPointerDown={rememberFocus}
|
||||
onKeyDown={rememberFocus}
|
||||
/>
|
||||
{props.variant === "v2" ? (
|
||||
<div
|
||||
data-component="desktop-icon-button"
|
||||
class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1"
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButtonV2}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
icon={<IconV2 name="menu" />}
|
||||
aria-label="OpenCode menu"
|
||||
onPointerDown={rememberFocus}
|
||||
onKeyDown={rememberFocus}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="menu"
|
||||
variant="ghost"
|
||||
class="titlebar-icon rounded-md shrink-0"
|
||||
aria-label="OpenCode menu"
|
||||
onPointerDown={rememberFocus}
|
||||
onKeyDown={rememberFocus}
|
||||
/>
|
||||
)}
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="desktop-app-menu">
|
||||
<DropdownMenu.Group>
|
||||
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
getSessionPrefetchPromise,
|
||||
setSessionPrefetch,
|
||||
} from "./global-sync/session-prefetch"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { createServerSyncContext } from "./server-sync"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
||||
import { diffs as list, message as clean } from "@/utils/diffs"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
|
||||
@@ -33,6 +34,12 @@ const keyFor = (directory: string, id: string) => `${directory}\n${id}`
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
const isNotFound = (error: unknown) =>
|
||||
error instanceof Error &&
|
||||
typeof error.cause === "object" &&
|
||||
error.cause !== null &&
|
||||
(error.cause as { status?: unknown }).status === 404
|
||||
|
||||
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
|
||||
const map = new Map(a.map((item) => [item.id, item] as const))
|
||||
for (const item of b) map.set(item.id, item)
|
||||
@@ -164,16 +171,17 @@ function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: Opti
|
||||
})
|
||||
}
|
||||
|
||||
export const createDirSyncContext = (client: OpencodeClient, directory: string) => {
|
||||
const globalSync = useGlobalSync()
|
||||
export const createDirSyncContext = (directory: string, serverSync: ReturnType<typeof createServerSyncContext>) => {
|
||||
const serverSDK = useServerSDK()
|
||||
const client = serverSDK.createClient({ directory, throwOnError: true })
|
||||
|
||||
type Child = ReturnType<(typeof globalSync)["child"]>
|
||||
type Child = ReturnType<(typeof serverSync)["child"]>
|
||||
type Setter = Child[1]
|
||||
|
||||
const current = createMemo(() => globalSync.child(directory))
|
||||
const current = createMemo(() => serverSync.child(directory, { mcp: true }))
|
||||
const target = (directory?: string) => {
|
||||
if (!directory || directory === directory) return current()
|
||||
return globalSync.child(directory)
|
||||
return serverSync.child(directory)
|
||||
}
|
||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||
const initialMessagePageSize = 80
|
||||
@@ -239,7 +247,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
||||
if (!first) break
|
||||
const stale = [...(seen.get(first) ?? [])]
|
||||
seen.delete(first)
|
||||
const [, setStore] = globalSync.child(first, { bootstrap: false })
|
||||
const [, setStore] = serverSync.child(first, { bootstrap: false })
|
||||
evict(first, setStore, stale)
|
||||
}
|
||||
return created
|
||||
@@ -267,7 +275,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
||||
if (sessionIDs.length === 0) return
|
||||
clearSessionPrefetch(directory, sessionIDs)
|
||||
for (const sessionID of sessionIDs) {
|
||||
globalSync.todo.set(sessionID, undefined)
|
||||
serverSync.todo.set(sessionID, undefined)
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
@@ -324,7 +332,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
||||
for (const messageID of next.confirmed) {
|
||||
clearOptimistic(input.directory, input.sessionID, messageID)
|
||||
}
|
||||
const [store] = globalSync.child(input.directory, { bootstrap: false })
|
||||
const [store] = serverSync.child(input.directory, { bootstrap: false })
|
||||
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
|
||||
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
|
||||
batch(() => {
|
||||
@@ -345,6 +353,10 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
||||
})
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isNotFound(error) && !tracked(input.directory, input.sessionID)) return
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
setMeta(
|
||||
produce((draft) => {
|
||||
@@ -373,8 +385,8 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
||||
},
|
||||
get project() {
|
||||
const store = current()[0]
|
||||
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
|
||||
if (match.found) return globalSync.data.project[match.index]
|
||||
const match = Binary.search(serverSync.data.project, store.project, (p) => p.id)
|
||||
if (match.found) return serverSync.data.project[match.index]
|
||||
return undefined
|
||||
},
|
||||
session: {
|
||||
@@ -418,7 +430,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
||||
})
|
||||
},
|
||||
async sync(sessionID: string, opts?: { force?: boolean }) {
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
const [store, setStore] = serverSync.child(directory)
|
||||
const key = keyFor(directory, sessionID)
|
||||
|
||||
touch(directory, setStore, sessionID)
|
||||
@@ -456,22 +468,27 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
||||
const sessionReq =
|
||||
hasSession && !opts?.force
|
||||
? Promise.resolve()
|
||||
: retry(() => client.session.get({ sessionID })).then((session) => {
|
||||
if (!tracked(directory, sessionID)) return
|
||||
const data = session.data
|
||||
if (!data) return
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft, sessionID, (s) => s.id)
|
||||
if (match.found) {
|
||||
draft[match.index] = data
|
||||
return
|
||||
}
|
||||
draft.splice(match.index, 0, data)
|
||||
}),
|
||||
)
|
||||
})
|
||||
: retry(() => client.session.get({ sessionID }))
|
||||
.then((session) => {
|
||||
if (!tracked(directory, sessionID)) return
|
||||
const data = session.data
|
||||
if (!data) return
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft, sessionID, (s) => s.id)
|
||||
if (match.found) {
|
||||
draft[match.index] = data
|
||||
return
|
||||
}
|
||||
draft.splice(match.index, 0, data)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isNotFound(error) && !tracked(directory, sessionID)) return
|
||||
throw error
|
||||
})
|
||||
|
||||
const messagesReq =
|
||||
cached && !opts?.force
|
||||
@@ -488,7 +505,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
||||
})
|
||||
},
|
||||
async diff(sessionID: string, opts?: { force?: boolean }) {
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
const [store, setStore] = serverSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
|
||||
|
||||
@@ -501,13 +518,13 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
||||
)
|
||||
},
|
||||
async todo(sessionID: string, opts?: { force?: boolean }) {
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
const [store, setStore] = serverSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
const existing = store.todo[sessionID]
|
||||
const cached = globalSync.data.session_todo[sessionID]
|
||||
const cached = serverSync.data.session_todo[sessionID]
|
||||
if (existing !== undefined) {
|
||||
if (cached === undefined) {
|
||||
globalSync.todo.set(sessionID, existing)
|
||||
serverSync.todo.set(sessionID, existing)
|
||||
}
|
||||
if (!opts?.force) return
|
||||
}
|
||||
@@ -522,7 +539,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
||||
if (!tracked(directory, sessionID)) return
|
||||
const list = todo.data ?? []
|
||||
setStore("todo", sessionID, reconcile(list, { key: "id" }))
|
||||
globalSync.todo.set(sessionID, list)
|
||||
serverSync.todo.set(sessionID, list)
|
||||
}),
|
||||
)
|
||||
},
|
||||
@@ -540,7 +557,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
||||
return meta.loading[key] ?? false
|
||||
},
|
||||
async loadMore(sessionID: string, count?: number) {
|
||||
const [, setStore] = globalSync.child(directory)
|
||||
const [, setStore] = serverSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
const key = keyFor(directory, sessionID)
|
||||
const step = count ?? historyMessagePageSize
|
||||
@@ -561,12 +578,12 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
||||
},
|
||||
},
|
||||
evict(sessionID: string, _directory = directory) {
|
||||
const [, setStore] = globalSync.child(_directory)
|
||||
const [, setStore] = serverSync.child(_directory)
|
||||
seenFor(_directory).delete(sessionID)
|
||||
evict(_directory, setStore, [sessionID])
|
||||
},
|
||||
fetch: async (count = 10) => {
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
const [store, setStore] = serverSync.child(directory)
|
||||
setStore("limit", (x) => x + count)
|
||||
await client.session.list().then((x) => {
|
||||
const sessions = (x.data ?? [])
|
||||
@@ -578,7 +595,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
|
||||
},
|
||||
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
||||
archive: async (sessionID: string) => {
|
||||
const [, setStore] = globalSync.child(directory)
|
||||
const [, setStore] = serverSync.child(directory)
|
||||
await client.session.update({ sessionID, time: { archived: Date.now() } })
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { batch, onCleanup, onMount } from "solid-js"
|
||||
import { createSdkForServer } from "@/utils/server"
|
||||
import { useLanguage } from "./language"
|
||||
import { usePlatform } from "./platform"
|
||||
import { useServer } from "./server"
|
||||
|
||||
const isAbortError = (error: unknown) =>
|
||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||
|
||||
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
|
||||
name: "GlobalSDK",
|
||||
init: () => {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const abort = new AbortController()
|
||||
|
||||
const eventFetch = (() => {
|
||||
if (!platform.fetch || !server.current) return
|
||||
try {
|
||||
const url = new URL(server.current.http.url)
|
||||
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
|
||||
if (url.protocol === "http:" && !loopback) return platform.fetch
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
})()
|
||||
|
||||
const currentServer = server.current
|
||||
if (!currentServer) throw new Error(language.t("error.globalSDK.noServerAvailable"))
|
||||
|
||||
const eventSdk = createSdkForServer({
|
||||
signal: abort.signal,
|
||||
fetch: eventFetch,
|
||||
server: currentServer.http,
|
||||
})
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key: string]: Event
|
||||
}>()
|
||||
|
||||
type Queued = { directory: string; payload: Event }
|
||||
const FLUSH_FRAME_MS = 16
|
||||
const STREAM_YIELD_MS = 8
|
||||
const RECONNECT_DELAY_MS = 250
|
||||
|
||||
let queue: Queued[] = []
|
||||
let buffer: Queued[] = []
|
||||
const coalesced = new Map<string, number>()
|
||||
const staleDeltas = new Set<string>()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
|
||||
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
|
||||
|
||||
const key = (directory: string, payload: Event) => {
|
||||
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
|
||||
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
|
||||
if (payload.type === "message.part.updated") {
|
||||
const part = payload.properties.part
|
||||
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
|
||||
}
|
||||
}
|
||||
|
||||
const flush = () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
|
||||
if (queue.length === 0) return
|
||||
|
||||
const events = queue
|
||||
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
|
||||
queue = buffer
|
||||
buffer = events
|
||||
queue.length = 0
|
||||
coalesced.clear()
|
||||
staleDeltas.clear()
|
||||
|
||||
last = Date.now()
|
||||
batch(() => {
|
||||
for (const event of events) {
|
||||
if (skip && event.payload.type === "message.part.delta") {
|
||||
const props = event.payload.properties
|
||||
if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue
|
||||
}
|
||||
emitter.emit(event.directory, event.payload)
|
||||
}
|
||||
})
|
||||
|
||||
buffer.length = 0
|
||||
}
|
||||
|
||||
const schedule = () => {
|
||||
if (timer) return
|
||||
const elapsed = Date.now() - last
|
||||
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
|
||||
}
|
||||
|
||||
let streamErrorLogged = false
|
||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
const aborted = isAbortError
|
||||
|
||||
let attempt: AbortController | undefined
|
||||
let run: Promise<void> | undefined
|
||||
let started = false
|
||||
const HEARTBEAT_TIMEOUT_MS = 15_000
|
||||
let lastEventAt = Date.now()
|
||||
let heartbeat: ReturnType<typeof setTimeout> | undefined
|
||||
const resetHeartbeat = () => {
|
||||
lastEventAt = Date.now()
|
||||
if (heartbeat) clearTimeout(heartbeat)
|
||||
heartbeat = setTimeout(() => {
|
||||
attempt?.abort()
|
||||
}, HEARTBEAT_TIMEOUT_MS)
|
||||
}
|
||||
const clearHeartbeat = () => {
|
||||
if (!heartbeat) return
|
||||
clearTimeout(heartbeat)
|
||||
heartbeat = undefined
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (started) return run
|
||||
started = true
|
||||
run = (async () => {
|
||||
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
|
||||
while (!abort.signal.aborted && started) {
|
||||
attempt = new AbortController()
|
||||
lastEventAt = Date.now()
|
||||
const onAbort = () => {
|
||||
attempt?.abort()
|
||||
}
|
||||
abort.signal.addEventListener("abort", onAbort)
|
||||
try {
|
||||
const events = await eventSdk.global.event({
|
||||
signal: attempt.signal,
|
||||
onSseError: (error) => {
|
||||
if (aborted(error)) return
|
||||
if (streamErrorLogged) return
|
||||
streamErrorLogged = true
|
||||
console.error("[global-sdk] event stream error", {
|
||||
url: currentServer.http.url,
|
||||
fetch: eventFetch ? "platform" : "webview",
|
||||
error,
|
||||
})
|
||||
},
|
||||
})
|
||||
let yielded = Date.now()
|
||||
resetHeartbeat()
|
||||
for await (const event of events.stream) {
|
||||
resetHeartbeat()
|
||||
streamErrorLogged = false
|
||||
const directory = event.directory ?? "global"
|
||||
if (event.payload.type === "sync") {
|
||||
continue
|
||||
}
|
||||
|
||||
const payload = event.payload as Event
|
||||
|
||||
const k = key(directory, payload)
|
||||
if (k) {
|
||||
const i = coalesced.get(k)
|
||||
if (i !== undefined) {
|
||||
queue[i] = { directory, payload }
|
||||
if (payload.type === "message.part.updated") {
|
||||
const part = payload.properties.part
|
||||
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
|
||||
}
|
||||
continue
|
||||
}
|
||||
coalesced.set(k, queue.length)
|
||||
}
|
||||
queue.push({ directory, payload })
|
||||
schedule()
|
||||
|
||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||
yielded = Date.now()
|
||||
await wait(0)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!aborted(error) && !streamErrorLogged) {
|
||||
streamErrorLogged = true
|
||||
console.error("[global-sdk] event stream failed", {
|
||||
url: currentServer.http.url,
|
||||
fetch: eventFetch ? "platform" : "webview",
|
||||
error,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
abort.signal.removeEventListener("abort", onAbort)
|
||||
attempt = undefined
|
||||
clearHeartbeat()
|
||||
}
|
||||
|
||||
if (abort.signal.aborted || !started) return
|
||||
await wait(RECONNECT_DELAY_MS)
|
||||
}
|
||||
})().finally(() => {
|
||||
run = undefined
|
||||
flush()
|
||||
})
|
||||
return run
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
started = false
|
||||
attempt?.abort()
|
||||
clearHeartbeat()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
makeEventListener(document, "visibilitychange", () => {
|
||||
if (document.visibilityState !== "visible") return
|
||||
if (!started) return
|
||||
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
|
||||
attempt?.abort()
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
stop()
|
||||
abort.abort()
|
||||
flush()
|
||||
})
|
||||
|
||||
const sdk = createSdkForServer({
|
||||
server: server.current.http,
|
||||
fetch: platform.fetch,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
return {
|
||||
url: currentServer.http.url,
|
||||
client: sdk,
|
||||
event: {
|
||||
on: emitter.on.bind(emitter),
|
||||
listen: emitter.listen.bind(emitter),
|
||||
start,
|
||||
},
|
||||
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
|
||||
const s = server.current
|
||||
if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))
|
||||
return createSdkForServer({
|
||||
server: s.http,
|
||||
fetch: platform.fetch,
|
||||
...opts,
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||
import { bootstrapDirectory } from "./bootstrap"
|
||||
import type { State, VcsCache } from "./types"
|
||||
|
||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||
|
||||
describe("bootstrapDirectory", () => {
|
||||
test("marks a loading directory partial during bootstrap and complete after success", async () => {
|
||||
const mcpReads: string[] = []
|
||||
const [store, setStore] = createStore<State>({
|
||||
status: "loading",
|
||||
agent: [],
|
||||
command: [],
|
||||
project: "",
|
||||
projectMeta: undefined,
|
||||
icon: undefined,
|
||||
provider_ready: true,
|
||||
provider,
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
session: [],
|
||||
sessionTotal: 0,
|
||||
session_status: {},
|
||||
session_working(id: string) {
|
||||
return this.session_status[id]?.type !== "idle"
|
||||
},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp_ready: true,
|
||||
mcp: {},
|
||||
lsp_ready: true,
|
||||
lsp: [],
|
||||
vcs: undefined,
|
||||
limit: 5,
|
||||
message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
})
|
||||
|
||||
await bootstrapDirectory({
|
||||
directory: "/project",
|
||||
mcp: false,
|
||||
global: {
|
||||
config: {} satisfies Config,
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
project: [{ id: "project", worktree: "/project" } as Project],
|
||||
provider,
|
||||
},
|
||||
sdk: {
|
||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
||||
config: { get: async () => ({ data: {} }) },
|
||||
session: { status: async () => ({ data: {} }) },
|
||||
vcs: { get: async () => ({ data: undefined }) },
|
||||
command: {
|
||||
list: async () => {
|
||||
mcpReads.push("command")
|
||||
return { data: [] }
|
||||
},
|
||||
},
|
||||
permission: { list: async () => ({ data: [] }) },
|
||||
question: { list: async () => ({ data: [] }) },
|
||||
mcp: {
|
||||
status: async () => {
|
||||
mcpReads.push("status")
|
||||
return { data: {} }
|
||||
},
|
||||
},
|
||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||
} as unknown as OpencodeClient,
|
||||
store,
|
||||
setStore,
|
||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||
loadSessions() {},
|
||||
translate: (key) => key,
|
||||
queryClient: new QueryClient(),
|
||||
})
|
||||
|
||||
expect(store.status).toBe("partial")
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||
|
||||
expect(store.status).toBe("complete")
|
||||
expect(mcpReads).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
PermissionRequest,
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
ProviderListResponse,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
Todo,
|
||||
@@ -19,7 +18,8 @@ import type { State, VcsCache } from "./types"
|
||||
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||
import { loadMcpQuery } from "../global-sync"
|
||||
import { loadMcpQuery } from "../server-sync"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -28,7 +28,7 @@ type GlobalStore = {
|
||||
session_todo: {
|
||||
[sessionID: string]: Todo[]
|
||||
}
|
||||
provider: ProviderListResponse
|
||||
provider: NormalizedProviderListResponse
|
||||
provider_auth: ProviderAuthResponse
|
||||
config: Config
|
||||
reload: undefined | "pending" | "complete"
|
||||
@@ -105,7 +105,7 @@ export const loadProjectsQuery = (sdk: OpencodeClient) =>
|
||||
})
|
||||
|
||||
export async function bootstrapGlobal(input: {
|
||||
globalSDK: OpencodeClient
|
||||
serverSDK: OpencodeClient
|
||||
requestFailedTitle: string
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
formatMoreCount: (count: number) => string
|
||||
@@ -113,12 +113,12 @@ export async function bootstrapGlobal(input: {
|
||||
queryClient: QueryClient
|
||||
}) {
|
||||
const slow = [
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.globalSDK)),
|
||||
() => input.queryClient.fetchQuery(loadProvidersQuery(null, input.globalSDK)),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(null, input.globalSDK)),
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.serverSDK)),
|
||||
() => input.queryClient.fetchQuery(loadProvidersQuery(null, input.serverSDK)),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(null, input.serverSDK)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.fetchQuery(loadProjectsQuery(input.globalSDK))
|
||||
.fetchQuery(loadProjectsQuery(input.serverSDK))
|
||||
.then((data) => input.setGlobalStore("project", data)),
|
||||
]
|
||||
await runAll(slow)
|
||||
@@ -198,6 +198,7 @@ export const loadPathQuery = (directory: string | null, sdk: OpencodeClient) =>
|
||||
|
||||
export async function bootstrapDirectory(input: {
|
||||
directory: string
|
||||
mcp: boolean
|
||||
sdk: OpencodeClient
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
@@ -208,7 +209,7 @@ export async function bootstrapDirectory(input: {
|
||||
config: Config
|
||||
path: Path
|
||||
project: Project[]
|
||||
provider: ProviderListResponse
|
||||
provider: NormalizedProviderListResponse
|
||||
}
|
||||
queryClient: QueryClient
|
||||
}) {
|
||||
@@ -250,7 +251,7 @@ export async function bootstrapDirectory(input: {
|
||||
if (next) input.vcsCache.setStore("value", next)
|
||||
}),
|
||||
),
|
||||
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
||||
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.permission.list().then((x) => {
|
||||
@@ -304,7 +305,7 @@ export async function bootstrapDirectory(input: {
|
||||
}),
|
||||
),
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk)),
|
||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk))),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => {
|
||||
const project = getFilename(input.directory)
|
||||
|
||||
@@ -1,10 +1,67 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot, getOwner } from "solid-js"
|
||||
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
import { createRoot, getOwner, type Owner } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||
import type { State } from "./types"
|
||||
import { createChildStoreManager } from "./child-store"
|
||||
import type { QueryOptionsApi } from "../server-sync"
|
||||
|
||||
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
||||
const queryGroups: Array<() => { queries: Array<{ enabled?: boolean }> }> = []
|
||||
|
||||
const child = () => createStore({} as State)
|
||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||
|
||||
const queryOptionsApi = {
|
||||
globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }),
|
||||
projects: () => ({ queryKey: ["projects"], queryFn: async () => [] }),
|
||||
providers: (directory: string | null) => ({ queryKey: [directory, "providers"], queryFn: async () => provider }),
|
||||
path: (directory: string | null) => ({
|
||||
queryKey: [directory, "path"],
|
||||
queryFn: async () => ({
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: "",
|
||||
directory: directory ?? "",
|
||||
home: "",
|
||||
}),
|
||||
}),
|
||||
agents: (directory: string) => ({ queryKey: [directory, "agents"], queryFn: async () => [] }),
|
||||
mcp: (directory: string) => ({ queryKey: [directory, "mcp"], queryFn: async () => ({}) }),
|
||||
lsp: (directory: string) => ({ queryKey: [directory, "lsp"], queryFn: async () => [] }),
|
||||
sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }),
|
||||
} as unknown as QueryOptionsApi
|
||||
|
||||
function createOwner(callback: (owner: Owner) => void) {
|
||||
return createRoot((dispose) => {
|
||||
const owner = getOwner()
|
||||
if (!owner) throw new Error("owner required")
|
||||
callback(owner)
|
||||
|
||||
return dispose
|
||||
})
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@/utils/persist", () => ({
|
||||
Persist: {
|
||||
workspace: (...parts: string[]) => parts.join(":"),
|
||||
},
|
||||
persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true],
|
||||
}))
|
||||
mock.module("@tanstack/solid-query", () => ({
|
||||
useQueries: (options: () => { queries: Array<{ enabled?: boolean }> }) => {
|
||||
queryGroups.push(options)
|
||||
return [
|
||||
{ isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } },
|
||||
{ isLoading: false, data: {} },
|
||||
{ isLoading: false, data: [] },
|
||||
{ isLoading: false, data: provider },
|
||||
]
|
||||
},
|
||||
}))
|
||||
|
||||
createChildStoreManager = (await import("./child-store")).createChildStoreManager
|
||||
})
|
||||
|
||||
describe("createChildStoreManager", () => {
|
||||
test("does not evict the active directory during mark", () => {
|
||||
@@ -20,10 +77,11 @@ describe("createChildStoreManager", () => {
|
||||
isBooting: () => false,
|
||||
isLoadingSessions: () => false,
|
||||
onBootstrap() {},
|
||||
onMcp() {},
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: {} as any,
|
||||
global: { provider: null! },
|
||||
queryOptions: queryOptionsApi,
|
||||
global: { provider },
|
||||
})
|
||||
|
||||
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
|
||||
@@ -37,4 +95,77 @@ describe("createChildStoreManager", () => {
|
||||
|
||||
expect(manager.children[directory]).toBeDefined()
|
||||
})
|
||||
|
||||
test("starts new child stores as loading and bootstraps them on first access", () => {
|
||||
const bootstraps: string[] = []
|
||||
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
||||
|
||||
const dispose = createOwner((owner) => {
|
||||
manager = createChildStoreManager({
|
||||
owner,
|
||||
isBooting: () => false,
|
||||
isLoadingSessions: () => false,
|
||||
onBootstrap(directory) {
|
||||
bootstraps.push(directory)
|
||||
},
|
||||
onMcp() {},
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
global: { provider },
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
if (!manager) throw new Error("manager required")
|
||||
|
||||
const [store] = manager.child("/project")
|
||||
|
||||
expect(store.status).toBe("loading")
|
||||
expect(bootstraps).toEqual(["/project"])
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("enables MCP only when requested for the directory", () => {
|
||||
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
||||
const offset = queryGroups.length
|
||||
const mcpLoads: string[] = []
|
||||
|
||||
const dispose = createOwner((owner) => {
|
||||
manager = createChildStoreManager({
|
||||
owner,
|
||||
isBooting: () => false,
|
||||
isLoadingSessions: () => false,
|
||||
onBootstrap() {},
|
||||
onMcp(directory) {
|
||||
mcpLoads.push(directory)
|
||||
},
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
global: { provider },
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
if (!manager) throw new Error("manager required")
|
||||
const [, setStore] = manager.child("/project", { bootstrap: false })
|
||||
const queries = queryGroups[offset]
|
||||
if (!queries) throw new Error("queries required")
|
||||
expect(queries().queries[1]?.enabled).toBe(false)
|
||||
|
||||
setStore("status", "complete")
|
||||
manager.child("/project", { bootstrap: false, mcp: true })
|
||||
expect(queries().queries[1]?.enabled).toBe(true)
|
||||
expect(mcpLoads).toEqual(["/project"])
|
||||
|
||||
manager.disableMcp("/project")
|
||||
expect(queries().queries[1]?.enabled).toBe(false)
|
||||
expect(manager.mcp("/project")).toBe(false)
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
||||
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { ProviderListResponse, VcsInfo } from "@opencode-ai/sdk/v2/client"
|
||||
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
DIR_IDLE_TTL_MS,
|
||||
MAX_DIR_STORES,
|
||||
@@ -15,19 +15,21 @@ import {
|
||||
} from "./types"
|
||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
|
||||
import { useQueries } from "@tanstack/solid-query"
|
||||
import { QueryOptionsApi } from "../global-sync"
|
||||
import { QueryOptionsApi } from "../server-sync"
|
||||
import { directoryKey, type DirectoryKey } from "./utils"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||
|
||||
export function createChildStoreManager(input: {
|
||||
owner: Owner
|
||||
isBooting: (directory: string) => boolean
|
||||
isLoadingSessions: (directory: string) => boolean
|
||||
onBootstrap: (directory: string) => void
|
||||
onMcp: (directory: string, setStore: SetStoreFunction<State>) => void
|
||||
onDispose: (directory: string) => void
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
queryOptions: QueryOptionsApi
|
||||
global: {
|
||||
provider: ProviderListResponse
|
||||
provider: NormalizedProviderListResponse
|
||||
}
|
||||
}) {
|
||||
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
||||
@@ -38,6 +40,8 @@ export function createChildStoreManager(input: {
|
||||
const pins = new Map<string, number>()
|
||||
const ownerPins = new WeakMap<object, Set<string>>()
|
||||
const disposers = new Map<string, () => void>()
|
||||
const mcpDirectories = new Set<string>()
|
||||
const mcpToggles = new Map<string, (enabled: boolean) => void>()
|
||||
|
||||
const markKey = (key: DirectoryKey) => {
|
||||
if (!key) return
|
||||
@@ -91,6 +95,24 @@ export function createChildStoreManager(input: {
|
||||
})
|
||||
}
|
||||
|
||||
function disposeChild(key: DirectoryKey) {
|
||||
const dispose = disposers.get(key)
|
||||
if (!key || !children[key]) return false
|
||||
vcsCache.delete(key)
|
||||
metaCache.delete(key)
|
||||
iconCache.delete(key)
|
||||
lifecycle.delete(key)
|
||||
mcpDirectories.delete(key)
|
||||
mcpToggles.delete(key)
|
||||
disposers.delete(key)
|
||||
delete children[key]
|
||||
input.onDispose(key)
|
||||
if (dispose) {
|
||||
dispose()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function disposeDirectory(directory: DirectoryKey) {
|
||||
const key = directory
|
||||
if (
|
||||
@@ -104,19 +126,13 @@ export function createChildStoreManager(input: {
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return disposeChild(key)
|
||||
}
|
||||
|
||||
vcsCache.delete(key)
|
||||
metaCache.delete(key)
|
||||
iconCache.delete(key)
|
||||
lifecycle.delete(key)
|
||||
const dispose = disposers.get(key)
|
||||
if (dispose) {
|
||||
dispose()
|
||||
disposers.delete(key)
|
||||
function disposeAll() {
|
||||
for (const directory of Object.keys(children)) {
|
||||
disposeChild(directoryKey(directory))
|
||||
}
|
||||
delete children[key]
|
||||
input.onDispose(key)
|
||||
return true
|
||||
}
|
||||
|
||||
function runEviction(skip?: string) {
|
||||
@@ -172,11 +188,12 @@ export function createChildStoreManager(input: {
|
||||
createRoot((dispose) => {
|
||||
const initialMeta = meta[0].value
|
||||
const initialIcon = icon[0].value
|
||||
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
||||
|
||||
const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({
|
||||
queries: [
|
||||
input.queryOptions.path(key),
|
||||
input.queryOptions.mcp(key),
|
||||
{ ...input.queryOptions.mcp(key), enabled: mcpEnabled() },
|
||||
input.queryOptions.lsp(key),
|
||||
input.queryOptions.providers(key),
|
||||
],
|
||||
@@ -190,10 +207,9 @@ export function createChildStoreManager(input: {
|
||||
return !providerQuery.isLoading
|
||||
},
|
||||
get provider() {
|
||||
const EMPTY = { all: [], connected: [], default: {} }
|
||||
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||
if (providerQuery.isLoading) return EMPTY
|
||||
if (providerQuery.data?.all.length === 0 && input.global.provider.all.length > 0)
|
||||
return input.global.provider
|
||||
if (providerQuery.data?.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
|
||||
return providerQuery.data ?? EMPTY
|
||||
},
|
||||
config: {},
|
||||
@@ -236,6 +252,7 @@ export function createChildStoreManager(input: {
|
||||
})
|
||||
children[key] = child
|
||||
disposers.set(key, dispose)
|
||||
mcpToggles.set(key, setMcpEnabled)
|
||||
|
||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||
if (!(init instanceof Promise)) return
|
||||
@@ -274,6 +291,7 @@ export function createChildStoreManager(input: {
|
||||
const key = directoryKey(directory)
|
||||
const childStore = ensureChild(directory)
|
||||
pinForOwner(key)
|
||||
if (options.mcp) enableMcp(directory, key, childStore)
|
||||
const shouldBootstrap = options.bootstrap ?? true
|
||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||
input.onBootstrap(directory)
|
||||
@@ -284,6 +302,7 @@ export function createChildStoreManager(input: {
|
||||
function peek(directory: string, options: ChildOptions = {}) {
|
||||
const key = directoryKey(directory)
|
||||
const childStore = ensureChild(directory)
|
||||
if (options.mcp) enableMcp(directory, key, childStore)
|
||||
const shouldBootstrap = options.bootstrap ?? true
|
||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||
input.onBootstrap(directory)
|
||||
@@ -291,6 +310,19 @@ export function createChildStoreManager(input: {
|
||||
return childStore
|
||||
}
|
||||
|
||||
function enableMcp(directory: string, key: DirectoryKey, childStore: [Store<State>, SetStoreFunction<State>]) {
|
||||
if (mcpDirectories.has(key)) return
|
||||
mcpDirectories.add(key)
|
||||
mcpToggles.get(key)?.(true)
|
||||
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
||||
}
|
||||
|
||||
function disableMcp(directory: string) {
|
||||
const key = directoryKey(directory)
|
||||
if (!mcpDirectories.delete(key)) return
|
||||
mcpToggles.get(key)?.(false)
|
||||
}
|
||||
|
||||
function projectMeta(directory: string, patch: ProjectMeta) {
|
||||
const key = directoryKey(directory)
|
||||
const [store, setStore] = ensureChild(directory)
|
||||
@@ -330,7 +362,10 @@ export function createChildStoreManager(input: {
|
||||
pin,
|
||||
unpin,
|
||||
pinned,
|
||||
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
|
||||
disableMcp,
|
||||
disposeDirectory,
|
||||
disposeAll,
|
||||
runEviction,
|
||||
vcsCache,
|
||||
metaCache,
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
Part,
|
||||
Path,
|
||||
PermissionRequest,
|
||||
ProviderListResponse,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
@@ -16,6 +15,7 @@ import type {
|
||||
Todo,
|
||||
VcsInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||
|
||||
@@ -38,7 +38,7 @@ export type State = {
|
||||
projectMeta: ProjectMeta | undefined
|
||||
icon: string | undefined
|
||||
provider_ready: boolean
|
||||
provider: ProviderListResponse
|
||||
provider: NormalizedProviderListResponse
|
||||
config: Config
|
||||
path: Path
|
||||
session: Session[]
|
||||
@@ -98,6 +98,7 @@ export type IconCache = {
|
||||
|
||||
export type ChildOptions = {
|
||||
bootstrap?: boolean
|
||||
mcp?: boolean
|
||||
}
|
||||
|
||||
export type DirState = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
|
||||
|
||||
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
@@ -17,13 +18,23 @@ export function normalizeAgentList(input: unknown): Agent[] {
|
||||
return Object.values(input).filter(isAgent)
|
||||
}
|
||||
|
||||
export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
|
||||
export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse {
|
||||
return {
|
||||
...input,
|
||||
all: input.all.map((provider) => ({
|
||||
...provider,
|
||||
models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
|
||||
})),
|
||||
all: new Map(
|
||||
input.all.map(
|
||||
(provider) =>
|
||||
[
|
||||
provider.id,
|
||||
{
|
||||
...provider,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
||||
),
|
||||
},
|
||||
] as const,
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { createStore, produce } from "solid-js/store"
|
||||
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { useServer } from "./server"
|
||||
import { usePlatform } from "./platform"
|
||||
import { Project } from "@opencode-ai/sdk/v2"
|
||||
@@ -136,8 +136,8 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
|
||||
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
||||
name: "Layout",
|
||||
init: () => {
|
||||
const globalSdk = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const globalSdk = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
|
||||
@@ -386,11 +386,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||
const [childStore] = globalSync.child(project.worktree, { bootstrap: false })
|
||||
const [childStore] = serverSync.child(project.worktree, { bootstrap: false })
|
||||
const projectID = childStore.project
|
||||
const metadata = projectID
|
||||
? globalSync.data.project.find((x) => x.id === projectID)
|
||||
: globalSync.data.project.find((x) => x.worktree === project.worktree)
|
||||
? serverSync.data.project.find((x) => x.id === projectID)
|
||||
: serverSync.data.project.find((x) => x.worktree === project.worktree)
|
||||
|
||||
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
|
||||
// Without this, different subdirectories of the same git repo would share the same
|
||||
@@ -404,7 +404,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
|
||||
const roots = createMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const project of globalSync.data.project) {
|
||||
for (const project of serverSync.data.project) {
|
||||
const sandboxes = project.sandboxes ?? []
|
||||
for (const sandbox of sandboxes) {
|
||||
map.set(sandbox, project.worktree)
|
||||
@@ -470,12 +470,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
createEffect(() => {
|
||||
const projects = enriched()
|
||||
if (projects.length === 0) return
|
||||
if (!globalSync.ready) return
|
||||
if (!serverSync.ready) return
|
||||
|
||||
for (const project of projects) {
|
||||
if (!project.id) continue
|
||||
if (project.id === "global") continue
|
||||
globalSync.project.icon(project.worktree, project.icon?.override)
|
||||
serverSync.project.icon(project.worktree, project.icon?.override)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -509,7 +509,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
colorRequested.set(worktree, color)
|
||||
|
||||
if (project.id === "global") {
|
||||
globalSync.project.meta(worktree, { icon: { color } })
|
||||
serverSync.project.meta(worktree, { icon: { color } })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -531,7 +531,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
sessionTimer = undefined
|
||||
void Promise.all(
|
||||
server.projects.list().map((project) => {
|
||||
return globalSync.project.loadSessions(project.worktree)
|
||||
return serverSync.project.loadSessions(project.worktree)
|
||||
}),
|
||||
)
|
||||
}, 0)
|
||||
@@ -560,7 +560,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
open(directory: string) {
|
||||
const root = rootFor(directory)
|
||||
if (server.projects.list().find((x) => x.worktree === root)) return
|
||||
void globalSync.project.loadSessions(root)
|
||||
void serverSync.project.loadSessions(root)
|
||||
server.projects.open(root)
|
||||
},
|
||||
close(directory: string) {
|
||||
|
||||
@@ -90,7 +90,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
const validModel = (model: ModelKey) => {
|
||||
const provider = providers.all().find((item) => item.id === model.providerID)
|
||||
const provider = providers.all().get(model.providerID)
|
||||
return !!provider?.models[model.modelID] && connected().has(model.providerID)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { createStore, reconcile } from "solid-js/store"
|
||||
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
@@ -109,8 +109,8 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
name: "Notification",
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
@@ -207,10 +207,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
|
||||
const lookup = async (directory: string, sessionID?: string) => {
|
||||
if (!sessionID) return undefined
|
||||
const [syncStore] = globalSync.child(directory, { bootstrap: false })
|
||||
const [syncStore] = serverSync.child(directory, { bootstrap: false })
|
||||
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
||||
if (match.found) return syncStore.session[match.index]
|
||||
return globalSDK.client.session
|
||||
return serverSDK.client.session
|
||||
.get({ directory, sessionID })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
@@ -285,7 +285,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = globalSDK.event.listen((e) => {
|
||||
const unsub = serverSDK.event.listen((e) => {
|
||||
const event = e.details
|
||||
if (event.type !== "session.idle" && event.type !== "session.error") return
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import {
|
||||
@@ -48,13 +48,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
name: "Permission",
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
|
||||
const permissionsEnabled = createMemo(() => {
|
||||
const directory = decode64(params.dir)
|
||||
if (!directory) return false
|
||||
const [store] = globalSync.child(directory)
|
||||
const [store] = serverSync.child(directory)
|
||||
return hasPermissionPromptRules(store.config.permission)
|
||||
})
|
||||
|
||||
@@ -86,7 +86,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
if (!ready()) return
|
||||
const directory = decode64(params.dir)
|
||||
if (!directory) return
|
||||
const [childStore] = globalSync.child(directory)
|
||||
const [childStore] = serverSync.child(directory)
|
||||
const perm = childStore.config.permission
|
||||
if (typeof perm === "string" && perm === "allow") {
|
||||
const key = directoryAcceptKey(directory)
|
||||
@@ -118,7 +118,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
}
|
||||
|
||||
const respond: PermissionRespondFn = (input) => {
|
||||
globalSDK.client.permission.respond(input).catch(() => {
|
||||
serverSDK.client.permission.respond(input).catch(() => {
|
||||
responded.delete(input.permissionID)
|
||||
})
|
||||
}
|
||||
@@ -139,7 +139,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
}
|
||||
|
||||
function isAutoAccepting(sessionID: string, directory?: string) {
|
||||
const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
|
||||
const session = directory ? serverSync.child(directory, { bootstrap: false })[0].session : []
|
||||
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
}
|
||||
|
||||
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
||||
const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
|
||||
const session = directory ? serverSync.child(directory, { bootstrap: false })[0].session : []
|
||||
return autoRespondsPermission(store.autoAccept, session, permission, directory)
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
return next
|
||||
}
|
||||
|
||||
const unsubscribe = globalSDK.event.listen((e) => {
|
||||
const unsubscribe = serverSDK.event.listen((e) => {
|
||||
const event = e.details
|
||||
if (event?.type !== "permission.asked") return
|
||||
|
||||
@@ -178,7 +178,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
}),
|
||||
)
|
||||
|
||||
globalSDK.client.permission
|
||||
serverSDK.client.permission
|
||||
.list({ directory })
|
||||
.then((x) => {
|
||||
if (!isAutoAcceptingDirectory(directory)) return
|
||||
@@ -210,7 +210,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
}),
|
||||
)
|
||||
|
||||
globalSDK.client.permission
|
||||
serverSDK.client.permission
|
||||
.list({ directory })
|
||||
.then((x) => {
|
||||
if (enableVersion.get(key) !== version) return
|
||||
@@ -268,7 +268,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
},
|
||||
permissionsEnabled,
|
||||
isPermissionAllowAll(directory: string) {
|
||||
const [childStore] = globalSync.child(directory)
|
||||
const [childStore] = serverSync.child(directory)
|
||||
const perm = childStore.config.permission
|
||||
return typeof perm === "string" && perm === "allow"
|
||||
},
|
||||
|
||||
@@ -9,13 +9,105 @@ type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
|
||||
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
|
||||
type UpdateInfo = { updateAvailable: boolean; version?: string }
|
||||
type PlatformName = "web" | "desktop"
|
||||
type DesktopOS = "macos" | "windows" | "linux"
|
||||
|
||||
export type FatalRendererErrorLog = {
|
||||
error: string
|
||||
url: string
|
||||
version?: string
|
||||
platform: PlatformName
|
||||
os?: DesktopOS
|
||||
}
|
||||
|
||||
export type WslRuntimeCheck = {
|
||||
available: boolean
|
||||
version: string | null
|
||||
error: string | null
|
||||
}
|
||||
export type WslInstalledDistro = {
|
||||
name: string
|
||||
version: number | null
|
||||
isDefault: boolean
|
||||
}
|
||||
export type WslOnlineDistro = {
|
||||
name: string
|
||||
label: string
|
||||
}
|
||||
export type WslDistroProbe = {
|
||||
name: string
|
||||
canExecute: boolean
|
||||
hasBash: boolean
|
||||
hasCurl: boolean
|
||||
error: string | null
|
||||
}
|
||||
export type WslOpencodeCheck = {
|
||||
distro: string
|
||||
resolvedPath: string | null
|
||||
version: string | null
|
||||
expectedVersion: string | null
|
||||
matchesDesktop: boolean | null
|
||||
error: string | null
|
||||
}
|
||||
export type WslServerConfig = {
|
||||
id: string
|
||||
distro: string
|
||||
}
|
||||
|
||||
export type WslServerRuntime =
|
||||
| { kind: "starting" }
|
||||
| { kind: "ready"; url: string; username: string | null; password: string | null }
|
||||
| { kind: "failed"; message: string }
|
||||
| { kind: "stopped" }
|
||||
|
||||
export type WslServerItem = {
|
||||
config: WslServerConfig
|
||||
runtime: WslServerRuntime
|
||||
}
|
||||
|
||||
export type WslJob =
|
||||
| { kind: "runtime"; startedAt: number }
|
||||
| { kind: "distros"; startedAt: number }
|
||||
| { kind: "install-wsl"; startedAt: number }
|
||||
| { kind: "install-distro"; distro: string; startedAt: number }
|
||||
| { kind: "probe-distro"; distro: string; startedAt: number }
|
||||
| { kind: "probe-opencode"; distro: string; startedAt: number }
|
||||
| { kind: "install-opencode"; distro: string; startedAt: number }
|
||||
|
||||
export type WslServersState = {
|
||||
runtime: WslRuntimeCheck | null
|
||||
installed: WslInstalledDistro[]
|
||||
online: WslOnlineDistro[]
|
||||
distroProbes: Record<string, WslDistroProbe>
|
||||
opencodeChecks: Record<string, WslOpencodeCheck>
|
||||
pendingRestart: boolean
|
||||
servers: WslServerItem[]
|
||||
job: WslJob | null
|
||||
}
|
||||
export type WslServersEvent = { type: "state"; state: WslServersState }
|
||||
|
||||
export type WslServersPlatform = {
|
||||
getState(): Promise<WslServersState>
|
||||
subscribe(cb: (event: WslServersEvent) => void): () => void
|
||||
probeRuntime(): Promise<void>
|
||||
refreshDistros(): Promise<void>
|
||||
installWsl(): Promise<void>
|
||||
installDistro(name: string): Promise<void>
|
||||
probeDistro(name: string): Promise<void>
|
||||
probeOpencode(name: string): Promise<void>
|
||||
installOpencode(name: string): Promise<void>
|
||||
openTerminal(name: string): Promise<void>
|
||||
addServer(distro: string): Promise<WslServerConfig>
|
||||
removeServer(id: string): Promise<void>
|
||||
startServer(id: string): Promise<void>
|
||||
}
|
||||
|
||||
export type Platform = {
|
||||
/** Platform discriminator */
|
||||
platform: "web" | "desktop"
|
||||
platform: PlatformName
|
||||
|
||||
/** Desktop OS (Tauri only) */
|
||||
os?: "macos" | "windows" | "linux"
|
||||
os?: DesktopOS
|
||||
|
||||
/** App version */
|
||||
version?: string
|
||||
@@ -65,11 +157,8 @@ export type Platform = {
|
||||
/** Set the default server URL to use on app startup (platform-specific) */
|
||||
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
|
||||
|
||||
/** Get the configured WSL integration (desktop only) */
|
||||
getWslEnabled?(): Promise<boolean>
|
||||
|
||||
/** Set the configured WSL integration (desktop only) */
|
||||
setWslEnabled?(config: boolean): Promise<void> | void
|
||||
/** Manage WSL sidecar servers (Electron on Windows only) */
|
||||
wslServers?: WslServersPlatform
|
||||
|
||||
/** Get the preferred display backend (desktop only) */
|
||||
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null
|
||||
@@ -83,6 +172,12 @@ export type Platform = {
|
||||
/** Webview zoom level (desktop only) */
|
||||
webviewZoom?: Accessor<number>
|
||||
|
||||
/** Get whether native pinch/Ctrl-scroll zoom gestures are enabled (desktop only) */
|
||||
getPinchZoomEnabled?(): Promise<boolean> | boolean
|
||||
|
||||
/** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */
|
||||
setPinchZoomEnabled?(enabled: boolean): Promise<void> | void
|
||||
|
||||
/** Run a desktop-only menu action from the app chrome */
|
||||
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
|
||||
|
||||
@@ -91,6 +186,12 @@ export type Platform = {
|
||||
|
||||
/** Read image from clipboard (desktop only) */
|
||||
readClipboardImage?(): Promise<File | null>
|
||||
|
||||
/** Export collected diagnostic logs (desktop only) */
|
||||
exportDebugLogs?(): Promise<string>
|
||||
|
||||
/** Record a fatal renderer error in platform logs (desktop only) */
|
||||
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
|
||||
}
|
||||
|
||||
export type DisplayBackend = "auto" | "wayland"
|
||||
|
||||
@@ -1,49 +1,11 @@
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
|
||||
type SDKEventMap = {
|
||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
}
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
name: "SDK",
|
||||
init: (props: { directory: Accessor<string> }) => {
|
||||
const globalSDK = useGlobalSDK()
|
||||
init: (props: { directory: string }) => {
|
||||
const serverSDK = useServerSDK()
|
||||
|
||||
const directory = createMemo(props.directory)
|
||||
const client = createMemo(() =>
|
||||
globalSDK.createClient({
|
||||
directory: directory(),
|
||||
throwOnError: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const emitter = createGlobalEmitter<SDKEventMap>()
|
||||
|
||||
createEffect(() => {
|
||||
const unsub = globalSDK.event.on(directory(), (event) => {
|
||||
emitter.emit(event.type, event)
|
||||
})
|
||||
onCleanup(unsub)
|
||||
})
|
||||
|
||||
return {
|
||||
get directory() {
|
||||
return directory()
|
||||
},
|
||||
get client() {
|
||||
return client()
|
||||
},
|
||||
event: emitter,
|
||||
get url() {
|
||||
return globalSDK.url
|
||||
},
|
||||
createClient(opts: Parameters<typeof globalSDK.createClient>[0]) {
|
||||
return globalSDK.createClient(opts)
|
||||
},
|
||||
}
|
||||
return serverSDK.createDirSdkContext(props.directory)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { batch, onCleanup, onMount } from "solid-js"
|
||||
import { createSdkForServer } from "@/utils/server"
|
||||
import { useLanguage } from "./language"
|
||||
import { usePlatform } from "./platform"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { createRefCountMap } from "@/utils/refcount"
|
||||
|
||||
const isAbortError = (error: unknown) =>
|
||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||
|
||||
function createServerSdkContext(server: ServerConnection.Any) {
|
||||
const platform = usePlatform()
|
||||
const abort = new AbortController()
|
||||
|
||||
const eventFetch = (() => {
|
||||
if (!platform.fetch || !server) return
|
||||
try {
|
||||
const url = new URL(server.http.url)
|
||||
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
|
||||
if (url.protocol === "http:" && !loopback) return platform.fetch
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
})()
|
||||
|
||||
const eventSdk = createSdkForServer({
|
||||
signal: abort.signal,
|
||||
fetch: eventFetch,
|
||||
server: server.http,
|
||||
})
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key: string]: Event
|
||||
}>()
|
||||
|
||||
type Queued = { directory: string; payload: Event }
|
||||
const FLUSH_FRAME_MS = 16
|
||||
const STREAM_YIELD_MS = 8
|
||||
const RECONNECT_DELAY_MS = 250
|
||||
|
||||
let queue: Queued[] = []
|
||||
let buffer: Queued[] = []
|
||||
const coalesced = new Map<string, number>()
|
||||
const staleDeltas = new Set<string>()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
|
||||
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
|
||||
|
||||
const key = (directory: string, payload: Event) => {
|
||||
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
|
||||
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
|
||||
if (payload.type === "message.part.updated") {
|
||||
const part = payload.properties.part
|
||||
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
|
||||
}
|
||||
}
|
||||
|
||||
const flush = () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
|
||||
if (queue.length === 0) return
|
||||
|
||||
const events = queue
|
||||
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
|
||||
queue = buffer
|
||||
buffer = events
|
||||
queue.length = 0
|
||||
coalesced.clear()
|
||||
staleDeltas.clear()
|
||||
|
||||
last = Date.now()
|
||||
batch(() => {
|
||||
for (const event of events) {
|
||||
if (skip && event.payload.type === "message.part.delta") {
|
||||
const props = event.payload.properties
|
||||
if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue
|
||||
}
|
||||
emitter.emit(event.directory, event.payload)
|
||||
}
|
||||
})
|
||||
|
||||
buffer.length = 0
|
||||
}
|
||||
|
||||
const schedule = () => {
|
||||
if (timer) return
|
||||
const elapsed = Date.now() - last
|
||||
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
|
||||
}
|
||||
|
||||
let streamErrorLogged = false
|
||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
const aborted = isAbortError
|
||||
|
||||
let attempt: AbortController | undefined
|
||||
let run: Promise<void> | undefined
|
||||
let started = false
|
||||
const HEARTBEAT_TIMEOUT_MS = 15_000
|
||||
let lastEventAt = Date.now()
|
||||
let heartbeat: ReturnType<typeof setTimeout> | undefined
|
||||
const resetHeartbeat = () => {
|
||||
lastEventAt = Date.now()
|
||||
if (heartbeat) clearTimeout(heartbeat)
|
||||
heartbeat = setTimeout(() => {
|
||||
attempt?.abort()
|
||||
}, HEARTBEAT_TIMEOUT_MS)
|
||||
}
|
||||
const clearHeartbeat = () => {
|
||||
if (!heartbeat) return
|
||||
clearTimeout(heartbeat)
|
||||
heartbeat = undefined
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (started) return run
|
||||
started = true
|
||||
run = (async () => {
|
||||
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
|
||||
while (!abort.signal.aborted && started) {
|
||||
attempt = new AbortController()
|
||||
lastEventAt = Date.now()
|
||||
const onAbort = () => {
|
||||
attempt?.abort()
|
||||
}
|
||||
abort.signal.addEventListener("abort", onAbort)
|
||||
try {
|
||||
const events = await eventSdk.global.event({
|
||||
signal: attempt.signal,
|
||||
onSseError: (error) => {
|
||||
if (aborted(error)) return
|
||||
if (streamErrorLogged) return
|
||||
streamErrorLogged = true
|
||||
console.error("[global-sdk] event stream error", {
|
||||
url: server.http.url,
|
||||
fetch: eventFetch ? "platform" : "webview",
|
||||
error,
|
||||
})
|
||||
},
|
||||
})
|
||||
let yielded = Date.now()
|
||||
resetHeartbeat()
|
||||
for await (const event of events.stream) {
|
||||
resetHeartbeat()
|
||||
streamErrorLogged = false
|
||||
const directory = event.directory ?? "global"
|
||||
if (event.payload.type === "sync") {
|
||||
continue
|
||||
}
|
||||
|
||||
const payload = event.payload as Event
|
||||
|
||||
const k = key(directory, payload)
|
||||
if (k) {
|
||||
const i = coalesced.get(k)
|
||||
if (i !== undefined) {
|
||||
queue[i] = { directory, payload }
|
||||
if (payload.type === "message.part.updated") {
|
||||
const part = payload.properties.part
|
||||
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
|
||||
}
|
||||
continue
|
||||
}
|
||||
coalesced.set(k, queue.length)
|
||||
}
|
||||
queue.push({ directory, payload })
|
||||
schedule()
|
||||
|
||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||
yielded = Date.now()
|
||||
await wait(0)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!aborted(error) && !streamErrorLogged) {
|
||||
streamErrorLogged = true
|
||||
console.error("[global-sdk] event stream failed", {
|
||||
url: server.http.url,
|
||||
fetch: eventFetch ? "platform" : "webview",
|
||||
error,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
abort.signal.removeEventListener("abort", onAbort)
|
||||
attempt = undefined
|
||||
clearHeartbeat()
|
||||
}
|
||||
|
||||
if (abort.signal.aborted || !started) return
|
||||
await wait(RECONNECT_DELAY_MS)
|
||||
}
|
||||
})().finally(() => {
|
||||
run = undefined
|
||||
flush()
|
||||
})
|
||||
return run
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
started = false
|
||||
attempt?.abort()
|
||||
clearHeartbeat()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
makeEventListener(document, "visibilitychange", () => {
|
||||
if (document.visibilityState !== "visible") return
|
||||
if (!started) return
|
||||
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
|
||||
attempt?.abort()
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
stop()
|
||||
abort.abort()
|
||||
flush()
|
||||
})
|
||||
|
||||
const sdk = createSdkForServer({
|
||||
server: server.http,
|
||||
fetch: platform.fetch,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
return {
|
||||
url: server.http.url,
|
||||
client: sdk,
|
||||
event: {
|
||||
on: emitter.on.bind(emitter),
|
||||
listen: emitter.listen.bind(emitter),
|
||||
start,
|
||||
},
|
||||
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
|
||||
return createSdkForServer({
|
||||
server: server.http,
|
||||
fetch: platform.fetch,
|
||||
...opts,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleContext({
|
||||
name: "ServerSDK",
|
||||
init: () => {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
|
||||
if (!server.current) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||
const sdk = createServerSdkContext(server.current)
|
||||
return {
|
||||
...sdk,
|
||||
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
type SDKEventMap = {
|
||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
}
|
||||
|
||||
function createDirSdkContext(directory: string, serverSDK: ReturnType<typeof createServerSdkContext>) {
|
||||
const client = serverSDK.createClient({
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
const emitter = createGlobalEmitter<SDKEventMap>()
|
||||
|
||||
const unsub = serverSDK.event.on(directory, (event) => {
|
||||
emitter.emit(event.type, event)
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
return {
|
||||
directory,
|
||||
client,
|
||||
event: emitter,
|
||||
get url() {
|
||||
return serverSDK.url
|
||||
},
|
||||
createClient(opts: Parameters<typeof serverSDK.createClient>[0]) {
|
||||
return serverSDK.createClient(opts)
|
||||
},
|
||||
}
|
||||
}
|
||||
+50
-68
@@ -1,19 +1,11 @@
|
||||
import type {
|
||||
Config,
|
||||
OpencodeClient,
|
||||
Path,
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
ProviderListResponse,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import type { InitError } from "../pages/error"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import {
|
||||
bootstrapDirectory,
|
||||
bootstrapGlobal,
|
||||
@@ -37,6 +29,9 @@ import { createRefreshQueue } from "./global-sync/queue"
|
||||
import { directoryKey } from "./global-sync/utils"
|
||||
import { PathKey } from "@/utils/path-key"
|
||||
import { createDirSyncContext } from "./directory-sync"
|
||||
import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||
import { createRefCountMap } from "@/utils/refcount"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -46,7 +41,7 @@ type GlobalStore = {
|
||||
session_todo: {
|
||||
[sessionID: string]: Todo[]
|
||||
}
|
||||
provider: ProviderListResponse
|
||||
provider: NormalizedProviderListResponse
|
||||
provider_auth: ProviderAuthResponse
|
||||
config: Config
|
||||
reload: undefined | "pending" | "complete"
|
||||
@@ -64,13 +59,13 @@ export const loadLspQuery = (directory: string, sdk: OpencodeClient) =>
|
||||
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
|
||||
})
|
||||
|
||||
function makeQueryOptionsApi(globalSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) {
|
||||
function makeQueryOptionsApi(serverSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) {
|
||||
return {
|
||||
globalConfig: () => loadGlobalConfigQuery(globalSDK()),
|
||||
projects: () => loadProjectsQuery(globalSDK()),
|
||||
globalConfig: () => loadGlobalConfigQuery(serverSDK()),
|
||||
projects: () => loadProjectsQuery(serverSDK()),
|
||||
providers: (directory: PathKey | null) =>
|
||||
loadProvidersQuery(directory, directory === null ? globalSDK() : sdkFor(directory)),
|
||||
path: (directory: PathKey | null) => loadPathQuery(directory, directory === null ? globalSDK() : sdkFor(directory)),
|
||||
loadProvidersQuery(directory, directory === null ? serverSDK() : sdkFor(directory)),
|
||||
path: (directory: PathKey | null) => loadPathQuery(directory, directory === null ? serverSDK() : sdkFor(directory)),
|
||||
agents: (directory: PathKey) => loadAgentsQuery(directory, sdkFor(directory)),
|
||||
mcp: (directory: PathKey) => loadMcpQuery(directory, sdkFor(directory)),
|
||||
lsp: (directory: PathKey) => loadLspQuery(directory, sdkFor(directory)),
|
||||
@@ -79,11 +74,11 @@ function makeQueryOptionsApi(globalSDK: () => OpencodeClient, sdkFor: (dir: Path
|
||||
}
|
||||
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
|
||||
|
||||
function createGlobalSync() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
export function createServerSyncContext() {
|
||||
const serverSDK = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const owner = getOwner()
|
||||
if (!owner) throw new Error("GlobalSync must be created within owner")
|
||||
if (!owner) throw new Error("ServerSync must be created within owner")
|
||||
|
||||
const sdkCache = new Map<string, OpencodeClient>()
|
||||
const booting = new Map<string, Promise<void>>()
|
||||
@@ -94,7 +89,7 @@ function createGlobalSync() {
|
||||
const key = directoryKey(directory)
|
||||
const cached = sdkCache.get(key)
|
||||
if (cached) return cached
|
||||
const sdk = globalSDK.createClient({
|
||||
const sdk = serverSDK.createClient({
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
@@ -102,7 +97,7 @@ function createGlobalSync() {
|
||||
return sdk
|
||||
}
|
||||
|
||||
const queryOptionsApi = makeQueryOptionsApi(() => globalSDK.client, sdkFor)
|
||||
const queryOptionsApi = makeQueryOptionsApi(() => serverSDK.client, sdkFor)
|
||||
|
||||
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
|
||||
@@ -121,7 +116,7 @@ function createGlobalSync() {
|
||||
return pathQuery.data ?? EMPTY
|
||||
},
|
||||
get provider() {
|
||||
const EMPTY = { all: [], connected: [], default: {} }
|
||||
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||
if (providerQuery.isLoading) return EMPTY
|
||||
return providerQuery.data ?? EMPTY
|
||||
},
|
||||
@@ -161,7 +156,7 @@ function createGlobalSync() {
|
||||
queryKey: ["bootstrap"],
|
||||
queryFn: async () => {
|
||||
await bootstrapGlobal({
|
||||
globalSDK: globalSDK.client,
|
||||
serverSDK: serverSDK.client,
|
||||
requestFailedTitle: language.t("common.requestFailed"),
|
||||
translate: language.t,
|
||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||
@@ -211,6 +206,15 @@ function createGlobalSync() {
|
||||
onBootstrap: (directory) => {
|
||||
void bootstrapInstance(directory)
|
||||
},
|
||||
onMcp: (directory, setStore) => {
|
||||
void retry(() => sdkFor(directory).command.list().then((x) => setStore("command", x.data ?? []))).catch((err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
|
||||
description: formatServerError(err, language.t),
|
||||
})
|
||||
})
|
||||
},
|
||||
onDispose: (directory) => {
|
||||
const key = directoryKey(directory)
|
||||
queue.clear(key)
|
||||
@@ -255,7 +259,7 @@ function createGlobalSync() {
|
||||
loadRootSessionsWithFallback({
|
||||
directory,
|
||||
limit,
|
||||
list: (query) => globalSDK.client.session.list(query),
|
||||
list: (query) => serverSDK.client.session.list(query),
|
||||
})
|
||||
.then((x) => {
|
||||
const nonArchived = (x.data ?? [])
|
||||
@@ -317,6 +321,7 @@ function createGlobalSync() {
|
||||
const sdk = sdkFor(directory)
|
||||
await bootstrapDirectory({
|
||||
directory,
|
||||
mcp: children.mcp(key),
|
||||
global: {
|
||||
config: globalStore.config,
|
||||
path: globalStore.path,
|
||||
@@ -341,7 +346,7 @@ function createGlobalSync() {
|
||||
return promise
|
||||
}
|
||||
|
||||
const unsub = globalSDK.event.listen((e) => {
|
||||
const unsub = serverSDK.event.listen((e) => {
|
||||
const directory = e.name
|
||||
const key = directoryKey(directory)
|
||||
const event = e.details
|
||||
@@ -388,11 +393,7 @@ function createGlobalSync() {
|
||||
onCleanup(() => {
|
||||
queue.dispose()
|
||||
})
|
||||
onCleanup(() => {
|
||||
for (const directory of Object.keys(children.children)) {
|
||||
children.disposeDirectory(directoryKey(directory))
|
||||
}
|
||||
})
|
||||
onCleanup(children.disposeAll)
|
||||
|
||||
onMount(() => {
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
@@ -400,13 +401,13 @@ function createGlobalSync() {
|
||||
eventFrame = undefined
|
||||
eventTimer = setTimeout(() => {
|
||||
eventTimer = undefined
|
||||
void globalSDK.event.start()
|
||||
void serverSDK.event.start()
|
||||
}, 0)
|
||||
})
|
||||
} else {
|
||||
eventTimer = setTimeout(() => {
|
||||
eventTimer = undefined
|
||||
void globalSDK.event.start()
|
||||
void serverSDK.event.start()
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
@@ -422,7 +423,7 @@ function createGlobalSync() {
|
||||
}
|
||||
|
||||
const updateConfigMutation = useMutation(() => ({
|
||||
mutationFn: (config: Config) => globalSDK.client.global.config.update({ config }),
|
||||
mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }),
|
||||
onSuccess: () => {
|
||||
bootstrap.refetch()
|
||||
// Invalidate all provider queries so newly configured custom providers
|
||||
@@ -432,9 +433,6 @@ function createGlobalSync() {
|
||||
},
|
||||
}))
|
||||
|
||||
const dirSyncContexts = new Map<string, ReturnType<typeof createDirSyncContext>>()
|
||||
const dirSyncContextRefCounts = new Map<string, number>()
|
||||
|
||||
return {
|
||||
data: globalStore,
|
||||
set,
|
||||
@@ -446,6 +444,7 @@ function createGlobalSync() {
|
||||
},
|
||||
child: children.child,
|
||||
peek: children.peek,
|
||||
disableMcp: children.disableMcp,
|
||||
queryOptions: queryOptionsApi,
|
||||
// bootstrap,
|
||||
updateConfig: updateConfigMutation.mutateAsync,
|
||||
@@ -453,42 +452,25 @@ function createGlobalSync() {
|
||||
todo: {
|
||||
set: setSessionTodo,
|
||||
},
|
||||
createDirSyncContext: (directory: string) => {
|
||||
onCleanup(() => {
|
||||
dirSyncContextRefCounts.set(directory, (dirSyncContextRefCounts.get(directory) ?? 0) - 1)
|
||||
if (dirSyncContextRefCounts.get(directory) === 0) {
|
||||
dirSyncContexts.delete(directory)
|
||||
dirSyncContextRefCounts.delete(directory)
|
||||
}
|
||||
})
|
||||
|
||||
const cached = dirSyncContexts.get(directory)
|
||||
if (cached) {
|
||||
dirSyncContextRefCounts.set(directory, (dirSyncContextRefCounts.get(directory) ?? 0) + 1)
|
||||
return cached
|
||||
}
|
||||
const ctx = createDirSyncContext(globalSDK.createClient({ directory, throwOnError: true }), directory)
|
||||
dirSyncContexts.set(directory, ctx)
|
||||
dirSyncContextRefCounts.set(directory, 1)
|
||||
|
||||
return ctx
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
|
||||
export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
|
||||
name: "ServerSync",
|
||||
init: () => {
|
||||
const sync = createServerSyncContext()
|
||||
|
||||
export function GlobalSyncProvider(props: ParentProps) {
|
||||
const value = createGlobalSync()
|
||||
return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
|
||||
}
|
||||
|
||||
export function useGlobalSync() {
|
||||
const context = useContext(GlobalSyncContext)
|
||||
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
|
||||
return context
|
||||
}
|
||||
return {
|
||||
...sync,
|
||||
createDirSyncContext: createRefCountMap(
|
||||
(dir) => createDirSyncContext(dir, sync),
|
||||
(dir) => sync.disableMcp(dir),
|
||||
directoryKey,
|
||||
),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export function useQueryOptions() {
|
||||
return useGlobalSync().queryOptions
|
||||
return useServerSync().queryOptions
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { type Accessor, batch, createEffect, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useCheckServerHealth } from "@/utils/server-health"
|
||||
|
||||
type StoredProject = { worktree: string; expanded: boolean }
|
||||
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
|
||||
@@ -37,24 +36,30 @@ export function resolveServerList(input: {
|
||||
props?: Array<ServerConnection.Any>
|
||||
stored: StoredServer[]
|
||||
}): Array<ServerConnection.Any> {
|
||||
const servers = [
|
||||
...input.stored.map((value) =>
|
||||
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>(
|
||||
input.props?.map((v) => [ServerConnection.key(v), v]) ?? [],
|
||||
)
|
||||
|
||||
for (const value of input.stored) {
|
||||
const conn: ServerConnection.Http =
|
||||
typeof value === "string"
|
||||
? {
|
||||
type: "http" as const,
|
||||
http: { url: value },
|
||||
}
|
||||
: value,
|
||||
),
|
||||
...(input.props ?? []),
|
||||
]
|
||||
|
||||
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>()
|
||||
for (const value of servers) {
|
||||
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
|
||||
: "http" in value
|
||||
? value
|
||||
: { type: "http", http: value }
|
||||
const key = ServerConnection.key(conn)
|
||||
if (deduped.has(key) && conn.type === "http" && !conn.authToken) continue
|
||||
deduped.set(key, conn)
|
||||
|
||||
const existing = deduped.get(key)
|
||||
if (existing)
|
||||
deduped.set(key, {
|
||||
...existing,
|
||||
...conn,
|
||||
http: { ...existing.http, ...conn.http },
|
||||
})
|
||||
else deduped.set(key, conn)
|
||||
}
|
||||
|
||||
return [...deduped.values()]
|
||||
@@ -122,13 +127,7 @@ export namespace ServerConnection {
|
||||
|
||||
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
||||
name: "Server",
|
||||
init: (props: {
|
||||
defaultServer: ServerConnection.Key
|
||||
disableHealthCheck?: boolean
|
||||
servers?: Array<ServerConnection.Any>
|
||||
}) => {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
|
||||
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("server", ["server.v3"]),
|
||||
createStore({
|
||||
@@ -146,40 +145,18 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
|
||||
const [state, setState] = createStore({
|
||||
active: props.defaultServer,
|
||||
healthy: undefined as boolean | undefined,
|
||||
})
|
||||
|
||||
const healthy = () => state.healthy
|
||||
|
||||
function startHealthPolling(conn: ServerConnection.Any) {
|
||||
let alive = true
|
||||
let busy = false
|
||||
|
||||
const run = () => {
|
||||
if (busy) return
|
||||
busy = true
|
||||
void check(conn)
|
||||
.then((next) => {
|
||||
if (!alive) return
|
||||
setState("healthy", next)
|
||||
})
|
||||
.finally(() => {
|
||||
busy = false
|
||||
})
|
||||
}
|
||||
|
||||
run()
|
||||
const interval = setInterval(run, HEALTH_POLL_INTERVAL_MS)
|
||||
return () => {
|
||||
alive = false
|
||||
clearInterval(interval)
|
||||
}
|
||||
}
|
||||
|
||||
function setActive(input: ServerConnection.Key) {
|
||||
if (state.active !== input) setState("active", input)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
window.__OPENCODE__ ??= {}
|
||||
window.__OPENCODE__.activeServer = state.active
|
||||
})
|
||||
|
||||
function add(input: ServerConnection.Http) {
|
||||
const url_ = normalizeServerUrl(input.http.url)
|
||||
if (!url_) return
|
||||
@@ -209,20 +186,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
|
||||
const isReady = createMemo(() => ready() && !!state.active)
|
||||
|
||||
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy)
|
||||
|
||||
createEffect(() => {
|
||||
const current_ = current()
|
||||
if (!current_) return
|
||||
|
||||
if (props.disableHealthCheck) {
|
||||
setState("healthy", true)
|
||||
return
|
||||
}
|
||||
setState("healthy", undefined)
|
||||
onCleanup(startHealthPolling(current_))
|
||||
})
|
||||
|
||||
const origin = createMemo(() => projectsKey(state.active))
|
||||
const projectsList = createMemo(() => store.projects[origin()] ?? [])
|
||||
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
|
||||
@@ -230,12 +193,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
)
|
||||
const isLocal = createMemo(() => {
|
||||
const c = current()
|
||||
return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url))
|
||||
return c?.type === "sidecar" || (c?.type === "http" && isLocalHost(c.http.url))
|
||||
})
|
||||
|
||||
return {
|
||||
ready: isReady,
|
||||
healthy,
|
||||
isLocal,
|
||||
get key() {
|
||||
return state.active
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useServer } from "./server"
|
||||
import { useServerHealth } from "@/utils/server-health"
|
||||
|
||||
export const { use: useServers, provider: ServersProvider } = createSimpleContext({
|
||||
name: "Servers",
|
||||
init: () => {
|
||||
const server = useServer()
|
||||
|
||||
const health = useServerHealth(
|
||||
() => server.list,
|
||||
() => true,
|
||||
)
|
||||
|
||||
return {
|
||||
list: () => server.list,
|
||||
health,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -32,6 +32,8 @@ export interface Settings {
|
||||
shellToolPartsExpanded: boolean
|
||||
editToolPartsExpanded: boolean
|
||||
showSessionProgressBar: boolean
|
||||
showCustomAgents: boolean
|
||||
newLayoutDesigns?: boolean
|
||||
}
|
||||
updates: {
|
||||
startup: boolean
|
||||
@@ -53,6 +55,7 @@ export interface Settings {
|
||||
export const monoDefault = "System Mono"
|
||||
export const sansDefault = "System Sans"
|
||||
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
||||
export const newLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
const monoFallback =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
@@ -117,6 +120,7 @@ const defaultSettings: Settings = {
|
||||
shellToolPartsExpanded: false,
|
||||
editToolPartsExpanded: false,
|
||||
showSessionProgressBar: true,
|
||||
showCustomAgents: false,
|
||||
},
|
||||
updates: {
|
||||
startup: true,
|
||||
@@ -236,6 +240,14 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setShowSessionProgressBar(value: boolean) {
|
||||
setStore("general", "showSessionProgressBar", value)
|
||||
},
|
||||
showCustomAgents: withFallback(() => store.general?.showCustomAgents, defaultSettings.general.showCustomAgents),
|
||||
setShowCustomAgents(value: boolean) {
|
||||
setStore("general", "showCustomAgents", value)
|
||||
},
|
||||
newLayoutDesigns: withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault),
|
||||
setNewLayoutDesigns(value: boolean) {
|
||||
setStore("general", "newLayoutDesigns", value)
|
||||
},
|
||||
},
|
||||
updates: {
|
||||
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
@@ -10,26 +9,8 @@ function sortParts(parts: Part[]) {
|
||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
|
||||
const pending = map.get(key)
|
||||
if (pending) return pending
|
||||
const promise = task().finally(() => {
|
||||
map.delete(key)
|
||||
})
|
||||
map.set(key, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
|
||||
const map = new Map(a.map((item) => [item.id, item] as const))
|
||||
for (const item of b) map.set(item.id, item)
|
||||
return [...map.values()].sort((x, y) => cmp(x.id, y.id))
|
||||
}
|
||||
|
||||
type OptimisticStore = {
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
@@ -127,40 +108,9 @@ export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticR
|
||||
delete draft.part[input.messageID]
|
||||
}
|
||||
|
||||
function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: OptimisticAddInput) {
|
||||
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
|
||||
if (!messages) return [input.message]
|
||||
const result = Binary.search(messages, input.message.id, (m) => m.id)
|
||||
const next = [...messages]
|
||||
next.splice(result.index, 0, input.message)
|
||||
return next
|
||||
})
|
||||
setStore("part", input.message.id, sortParts(input.parts))
|
||||
export const useSync = () => {
|
||||
const serverSync = useServerSync()
|
||||
const sdk = useSDK()
|
||||
|
||||
return serverSync.createDirSyncContext(sdk.directory)
|
||||
}
|
||||
|
||||
function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: OptimisticRemoveInput) {
|
||||
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
|
||||
if (!messages) return messages
|
||||
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
||||
if (!result.found) return messages
|
||||
const next = [...messages]
|
||||
next.splice(result.index, 1)
|
||||
return next
|
||||
})
|
||||
setStore("part", (part: Record<string, Part[] | undefined>) => {
|
||||
if (!(input.messageID in part)) return part
|
||||
const next = { ...part }
|
||||
delete next[input.messageID]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
name: "Sync",
|
||||
init: () => {
|
||||
const globalSync = useGlobalSync()
|
||||
const sdk = useSDK()
|
||||
|
||||
return globalSync.createDirSyncContext(sdk.directory)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { queryOptions, skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import type { WslServersPlatform, WslServersState } from "./platform"
|
||||
import { usePlatform } from "./platform"
|
||||
|
||||
const wslServersQueryKey = ["platform", "wslServers"] as const
|
||||
|
||||
export const { use: useWslServers, provider: WslServersProvider } = createSimpleContext({
|
||||
name: "WslServers",
|
||||
init: () => {
|
||||
const platform = usePlatform()
|
||||
const queryClient = useQueryClient()
|
||||
const query = useQuery(() => {
|
||||
const api = platform.wslServers
|
||||
return queryOptions<WslServersState>({
|
||||
queryKey: wslServersQueryKey,
|
||||
queryFn: api ? () => api.getState() : skipToken,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
gcTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const api = platform.wslServers
|
||||
if (!api) return
|
||||
const off = api.subscribe((event) => {
|
||||
queryClient.setQueryData(wslServersQueryKey, event.state)
|
||||
})
|
||||
onCleanup(off)
|
||||
})
|
||||
|
||||
return query
|
||||
},
|
||||
})
|
||||
@@ -80,6 +80,7 @@ export const DESKTOP_MENU: DesktopMenu[] = [
|
||||
{ type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
|
||||
{ type: "item", label: "Reload Webview", action: "view.reload" },
|
||||
{ type: "item", label: "Restart", action: "app.relaunch" },
|
||||
{ type: "item", label: "Export Logs...", command: "logs.export" },
|
||||
{ type: "separator" },
|
||||
{ type: "item", role: "hide" },
|
||||
{ type: "item", role: "hideOthers" },
|
||||
@@ -201,6 +202,7 @@ export const DESKTOP_MENU: DesktopMenu[] = [
|
||||
items: [
|
||||
{ type: "item", label: "OpenCode Documentation", href: "https://opencode.ai/docs" },
|
||||
{ type: "item", label: "Support Forum", href: "https://discord.com/invite/opencode" },
|
||||
{ type: "item", label: "Export Logs...", command: "logs.export" },
|
||||
{ type: "separator" },
|
||||
{
|
||||
type: "item",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { Iterable, pipe } from "effect"
|
||||
import { createMemo } from "solid-js"
|
||||
|
||||
export const popularProviders = [
|
||||
@@ -16,29 +17,45 @@ export const popularProviders = [
|
||||
const popularProviderSet = new Set(popularProviders)
|
||||
|
||||
export function useProviders() {
|
||||
const globalSync = useGlobalSync()
|
||||
const serverSync = useServerSync()
|
||||
const params = useParams()
|
||||
const dir = createMemo(() => decode64(params.dir) ?? "")
|
||||
const providers = () => {
|
||||
if (dir()) {
|
||||
const [projectStore] = globalSync.child(dir())
|
||||
const [projectStore] = serverSync.child(dir())
|
||||
if (projectStore.provider_ready) return projectStore.provider
|
||||
}
|
||||
return globalSync.data.provider
|
||||
return serverSync.data.provider
|
||||
}
|
||||
return {
|
||||
all: () => providers().all,
|
||||
default: () => providers().default,
|
||||
popular: () => providers().all.filter((p) => popularProviderSet.has(p.id)),
|
||||
popular: () =>
|
||||
pipe(
|
||||
providers().all,
|
||||
Iterable.map(([, p]) => p),
|
||||
Iterable.filter((p) => popularProviderSet.has(p.id)),
|
||||
(v) => Array.from(v),
|
||||
),
|
||||
connected: () => {
|
||||
const connected = new Set(providers().connected)
|
||||
return providers().all.filter((p) => connected.has(p.id))
|
||||
return pipe(
|
||||
providers().all,
|
||||
Iterable.map(([, p]) => p),
|
||||
Iterable.filter((p) => connected.has(p.id)),
|
||||
(v) => Array.from(v),
|
||||
)
|
||||
},
|
||||
paid: () => {
|
||||
const connected = new Set(providers().connected)
|
||||
return providers().all.filter(
|
||||
(p) => connected.has(p.id) && (p.id !== "opencode" || Object.values(p.models).some((m) => m.cost?.input)),
|
||||
)
|
||||
return [
|
||||
...Iterable.filter(
|
||||
providers().all,
|
||||
([id]) =>
|
||||
connected.has(id) &&
|
||||
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
|
||||
),
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,7 +413,7 @@ export const dict = {
|
||||
"error.page.version": "الإصدار: {{version}}",
|
||||
"error.dev.rootNotFound":
|
||||
"لم يتم العثور على العنصر الجذري. هل نسيت إضافته إلى index.html؟ أو ربما تمت كتابة سمة id بشكل خاطئ؟",
|
||||
"error.globalSync.connectFailed": "تعذر الاتصال بالخادم. هل هناك خادم يعمل في `{{url}}`؟",
|
||||
"error.serverSync.connectFailed": "تعذر الاتصال بالخادم. هل هناك خادم يعمل في `{{url}}`؟",
|
||||
"directory.error.invalidUrl": "دليل غير صالح في عنوان URL.",
|
||||
"error.chain.unknown": "خطأ غير معروف",
|
||||
"error.chain.causedBy": "بسبب:",
|
||||
@@ -837,8 +837,8 @@ export const dict = {
|
||||
"common.key.insert": "Insert",
|
||||
"common.unknown": "غير معروف",
|
||||
"error.page.circular": "[دائري]",
|
||||
"error.globalSDK.noServerAvailable": "لا يوجد خادم متاح",
|
||||
"error.globalSDK.serverNotAvailable": "الخادم غير متاح",
|
||||
"error.serverSDK.noServerAvailable": "لا يوجد خادم متاح",
|
||||
"error.serverSDK.serverNotAvailable": "الخادم غير متاح",
|
||||
"error.childStore.persistedCacheCreateFailed": "فشل إنشاء ذاكرة التخزين المؤقت الدائمة",
|
||||
"error.childStore.persistedProjectMetadataCreateFailed": "فشل إنشاء بيانات تعريف المشروع الدائمة",
|
||||
"error.childStore.persistedProjectIconCreateFailed": "فشل إنشاء أيقونة المشروع الدائمة",
|
||||
|
||||
@@ -414,7 +414,7 @@ export const dict = {
|
||||
"error.page.version": "Versão: {{version}}",
|
||||
"error.dev.rootNotFound":
|
||||
"Elemento raiz não encontrado. Você esqueceu de adicioná-lo ao seu index.html? Ou talvez o atributo id foi escrito incorretamente?",
|
||||
"error.globalSync.connectFailed": "Não foi possível conectar ao servidor. Há um servidor executando em `{{url}}`?",
|
||||
"error.serverSync.connectFailed": "Não foi possível conectar ao servidor. Há um servidor executando em `{{url}}`?",
|
||||
"directory.error.invalidUrl": "Diretório inválido na URL.",
|
||||
"error.chain.unknown": "Erro desconhecido",
|
||||
"error.chain.causedBy": "Causado por:",
|
||||
@@ -850,8 +850,8 @@ export const dict = {
|
||||
"common.key.insert": "Insert",
|
||||
"common.unknown": "desconhecido",
|
||||
"error.page.circular": "[Circular]",
|
||||
"error.globalSDK.noServerAvailable": "Nenhum servidor disponível",
|
||||
"error.globalSDK.serverNotAvailable": "Servidor indisponível",
|
||||
"error.serverSDK.noServerAvailable": "Nenhum servidor disponível",
|
||||
"error.serverSDK.serverNotAvailable": "Servidor indisponível",
|
||||
"error.childStore.persistedCacheCreateFailed": "Falha ao criar cache persistente",
|
||||
"error.childStore.persistedProjectMetadataCreateFailed": "Falha ao criar metadados de projeto persistentes",
|
||||
"error.childStore.persistedProjectIconCreateFailed": "Falha ao criar ícone de projeto persistente",
|
||||
|
||||
@@ -462,7 +462,7 @@ export const dict = {
|
||||
"error.dev.rootNotFound":
|
||||
"Korijenski element nije pronađen. Da li si zaboravio da ga dodaš u index.html? Ili je možda id atribut pogrešno napisan?",
|
||||
|
||||
"error.globalSync.connectFailed": "Nije moguće povezati se na server. Da li server radi na `{{url}}`?",
|
||||
"error.serverSync.connectFailed": "Nije moguće povezati se na server. Da li server radi na `{{url}}`?",
|
||||
"directory.error.invalidUrl": "Nevažeći direktorij u URL-u.",
|
||||
|
||||
"error.chain.unknown": "Nepoznata greška",
|
||||
@@ -926,8 +926,8 @@ export const dict = {
|
||||
"common.key.insert": "Insert",
|
||||
"common.unknown": "nepoznato",
|
||||
"error.page.circular": "[Kružno]",
|
||||
"error.globalSDK.noServerAvailable": "Nema dostupnog servera",
|
||||
"error.globalSDK.serverNotAvailable": "Server nije dostupan",
|
||||
"error.serverSDK.noServerAvailable": "Nema dostupnog servera",
|
||||
"error.serverSDK.serverNotAvailable": "Server nije dostupan",
|
||||
"error.childStore.persistedCacheCreateFailed": "Nije uspjelo kreiranje trajnog keša",
|
||||
"error.childStore.persistedProjectMetadataCreateFailed": "Nije uspjelo kreiranje trajnih metapodataka projekta",
|
||||
"error.childStore.persistedProjectIconCreateFailed": "Nije uspjelo kreiranje trajne ikone projekta",
|
||||
|
||||
@@ -459,7 +459,7 @@ export const dict = {
|
||||
"error.dev.rootNotFound":
|
||||
"Rodelement ikke fundet. Har du glemt at tilføje det til din index.html? Eller måske er id-attributten stavet forkert?",
|
||||
|
||||
"error.globalSync.connectFailed": "Kunne ikke forbinde til server. Kører der en server på `{{url}}`?",
|
||||
"error.serverSync.connectFailed": "Kunne ikke forbinde til server. Kører der en server på `{{url}}`?",
|
||||
"directory.error.invalidUrl": "Ugyldig mappe i URL.",
|
||||
|
||||
"error.chain.unknown": "Ukendt fejl",
|
||||
@@ -920,8 +920,8 @@ export const dict = {
|
||||
"common.key.insert": "Insert",
|
||||
"common.unknown": "ukendt",
|
||||
"error.page.circular": "[Cirkulær]",
|
||||
"error.globalSDK.noServerAvailable": "Ingen server tilgængelig",
|
||||
"error.globalSDK.serverNotAvailable": "Server ikke tilgængelig",
|
||||
"error.serverSDK.noServerAvailable": "Ingen server tilgængelig",
|
||||
"error.serverSDK.serverNotAvailable": "Server ikke tilgængelig",
|
||||
"error.childStore.persistedCacheCreateFailed": "Kunne ikke oprette vedvarende cache",
|
||||
"error.childStore.persistedProjectMetadataCreateFailed": "Kunne ikke oprette vedvarende projektmetadata",
|
||||
"error.childStore.persistedProjectIconCreateFailed": "Kunne ikke oprette vedvarende projektikon",
|
||||
|
||||
@@ -421,7 +421,7 @@ export const dict = {
|
||||
"error.page.version": "Version: {{version}}",
|
||||
"error.dev.rootNotFound":
|
||||
"Wurzelelement nicht gefunden. Haben Sie vergessen, es in Ihre index.html aufzunehmen? Oder wurde das id-Attribut falsch geschrieben?",
|
||||
"error.globalSync.connectFailed": "Verbindung zum Server fehlgeschlagen. Läuft ein Server unter `{{url}}`?",
|
||||
"error.serverSync.connectFailed": "Verbindung zum Server fehlgeschlagen. Läuft ein Server unter `{{url}}`?",
|
||||
"directory.error.invalidUrl": "Ungültiges Verzeichnis in der URL.",
|
||||
"error.chain.unknown": "Unbekannter Fehler",
|
||||
"error.chain.causedBy": "Verursacht durch:",
|
||||
@@ -862,8 +862,8 @@ export const dict = {
|
||||
"common.key.insert": "Einfg",
|
||||
"common.unknown": "unbekannt",
|
||||
"error.page.circular": "[Zirkulär]",
|
||||
"error.globalSDK.noServerAvailable": "Kein Server verfügbar",
|
||||
"error.globalSDK.serverNotAvailable": "Server nicht verfügbar",
|
||||
"error.serverSDK.noServerAvailable": "Kein Server verfügbar",
|
||||
"error.serverSDK.serverNotAvailable": "Server nicht verfügbar",
|
||||
"error.childStore.persistedCacheCreateFailed": "Dauerhafter Cache konnte nicht erstellt werden",
|
||||
"error.childStore.persistedProjectMetadataCreateFailed": "Dauerhafte Projektmetadaten konnten nicht erstellt werden",
|
||||
"error.childStore.persistedProjectIconCreateFailed": "Dauerhaftes Projekticon konnte nicht erstellt werden",
|
||||
|
||||
@@ -221,6 +221,7 @@ export const dict = {
|
||||
"common.loading": "Loading",
|
||||
"common.loading.ellipsis": "...",
|
||||
"common.cancel": "Cancel",
|
||||
"common.clear": "Clear",
|
||||
"common.open": "Open",
|
||||
"common.connect": "Connect",
|
||||
"common.disconnect": "Disconnect",
|
||||
@@ -470,6 +471,7 @@ export const dict = {
|
||||
"error.page.action.restart": "Restart",
|
||||
"error.page.action.report": "Report Error",
|
||||
"error.page.action.reported": "Error Reported",
|
||||
"error.page.action.exportLogs": "Export Logs",
|
||||
"error.page.action.checking": "Checking...",
|
||||
"error.page.action.checkUpdates": "Check for updates",
|
||||
"error.page.action.updateTo": "Update to {{version}}",
|
||||
@@ -481,9 +483,9 @@ export const dict = {
|
||||
"error.dev.rootNotFound":
|
||||
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
|
||||
|
||||
"error.globalSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?",
|
||||
"error.globalSDK.noServerAvailable": "No server available",
|
||||
"error.globalSDK.serverNotAvailable": "Server not available",
|
||||
"error.serverSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?",
|
||||
"error.serverSDK.noServerAvailable": "No server available",
|
||||
"error.serverSDK.serverNotAvailable": "Server not available",
|
||||
"error.childStore.persistedCacheCreateFailed": "Failed to create persisted cache",
|
||||
"error.childStore.persistedProjectMetadataCreateFailed": "Failed to create persisted project metadata",
|
||||
"error.childStore.persistedProjectIconCreateFailed": "Failed to create persisted project icon",
|
||||
@@ -525,6 +527,15 @@ export const dict = {
|
||||
"home.recentProjects": "Recent projects",
|
||||
"home.empty.title": "No recent projects",
|
||||
"home.empty.description": "Get started by opening a local project",
|
||||
"home.title": "Home",
|
||||
"home.projects": "Projects",
|
||||
"home.project.add": "Add project",
|
||||
"home.sessions.search.placeholder": "Search sessions",
|
||||
"home.sessions.empty": "No sessions found",
|
||||
"home.sessions.empty.description": "Start a new session for this project",
|
||||
"home.sessions.group.today": "Today",
|
||||
"home.sessions.group.yesterday": "Yesterday",
|
||||
"home.sessions.group.older": "Older",
|
||||
|
||||
"session.tab.session": "Session",
|
||||
"session.tab.review": "Review",
|
||||
@@ -575,6 +586,9 @@ export const dict = {
|
||||
"session.revertDock.restore": "Restore message",
|
||||
|
||||
"session.new.title": "Build anything",
|
||||
"session.new.project.new": "New project",
|
||||
"session.new.project.search": "Search projects",
|
||||
"session.new.project.add": "Add project",
|
||||
"session.new.worktree.main": "Main branch",
|
||||
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
|
||||
"session.new.worktree.create": "Create new worktree",
|
||||
@@ -755,7 +769,7 @@ export const dict = {
|
||||
"settings.general.row.followup.option.queue": "Queue",
|
||||
"settings.general.row.followup.option.steer": "Steer",
|
||||
"settings.general.row.showFileTree.title": "File tree",
|
||||
"settings.general.row.showFileTree.description": "Show the file tree toggle and panel in desktop sessions",
|
||||
"settings.general.row.showFileTree.description": "Show the file tree panel in desktop sessions",
|
||||
"settings.general.row.showNavigation.title": "Navigation controls",
|
||||
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
|
||||
"settings.general.row.showSearch.title": "Command palette",
|
||||
@@ -764,6 +778,8 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
|
||||
"settings.general.row.showStatus.title": "Server status",
|
||||
"settings.general.row.showStatus.description": "Show the server status button in the desktop title bar",
|
||||
"settings.general.row.showCustomAgents.title": "Custom agents",
|
||||
"settings.general.row.showCustomAgents.description": "Show the agent picker in the v2 desktop composer",
|
||||
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
||||
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
||||
@@ -775,6 +791,10 @@ export const dict = {
|
||||
"settings.general.row.showSessionProgressBar.title": "Show session progress bar",
|
||||
"settings.general.row.showSessionProgressBar.description":
|
||||
"Display the animated progress bar at the top of the session when the agent is working",
|
||||
"settings.general.row.newLayoutDesigns.title": "New layout and designs",
|
||||
"settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI",
|
||||
"settings.general.row.pinchZoom.title": "Pinch to zoom",
|
||||
"settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
|
||||
|
||||
"settings.general.row.wayland.title": "Use native Wayland",
|
||||
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
|
||||
|
||||
@@ -462,7 +462,7 @@ export const dict = {
|
||||
"error.dev.rootNotFound":
|
||||
"Elemento raíz no encontrado. ¿Olvidaste añadirlo a tu index.html? ¿O tal vez el atributo id está mal escrito?",
|
||||
|
||||
"error.globalSync.connectFailed": "No se pudo conectar al servidor. ¿Hay un servidor ejecutándose en `{{url}}`?",
|
||||
"error.serverSync.connectFailed": "No se pudo conectar al servidor. ¿Hay un servidor ejecutándose en `{{url}}`?",
|
||||
"directory.error.invalidUrl": "URL de directorio inválida.",
|
||||
|
||||
"error.chain.unknown": "Error desconocido",
|
||||
@@ -933,8 +933,8 @@ export const dict = {
|
||||
"common.key.insert": "Insert",
|
||||
"common.unknown": "desconocido",
|
||||
"error.page.circular": "[Circular]",
|
||||
"error.globalSDK.noServerAvailable": "Ningún servidor disponible",
|
||||
"error.globalSDK.serverNotAvailable": "Servidor no disponible",
|
||||
"error.serverSDK.noServerAvailable": "Ningún servidor disponible",
|
||||
"error.serverSDK.serverNotAvailable": "Servidor no disponible",
|
||||
"error.childStore.persistedCacheCreateFailed": "Error al crear caché persistente",
|
||||
"error.childStore.persistedProjectMetadataCreateFailed": "Error al crear metadatos de proyecto persistentes",
|
||||
"error.childStore.persistedProjectIconCreateFailed": "Error al crear icono de proyecto persistente",
|
||||
|
||||
@@ -417,7 +417,7 @@ export const dict = {
|
||||
"error.page.version": "Version : {{version}}",
|
||||
"error.dev.rootNotFound":
|
||||
"Élément racine introuvable. Avez-vous oublié de l'ajouter à votre index.html ? Ou peut-être que l'attribut id est mal orthographié ?",
|
||||
"error.globalSync.connectFailed":
|
||||
"error.serverSync.connectFailed":
|
||||
"Impossible de se connecter au serveur. Y a-t-il un serveur en cours d'exécution à `{{url}}` ?",
|
||||
"directory.error.invalidUrl": "Répertoire invalide dans l'URL.",
|
||||
"error.chain.unknown": "Erreur inconnue",
|
||||
@@ -860,8 +860,8 @@ export const dict = {
|
||||
"common.key.insert": "Inser",
|
||||
"common.unknown": "inconnu",
|
||||
"error.page.circular": "[Circulaire]",
|
||||
"error.globalSDK.noServerAvailable": "Aucun serveur disponible",
|
||||
"error.globalSDK.serverNotAvailable": "Serveur non disponible",
|
||||
"error.serverSDK.noServerAvailable": "Aucun serveur disponible",
|
||||
"error.serverSDK.serverNotAvailable": "Serveur non disponible",
|
||||
"error.childStore.persistedCacheCreateFailed": "Échec de la création du cache persistant",
|
||||
"error.childStore.persistedProjectMetadataCreateFailed":
|
||||
"Échec de la création des métadonnées de projet persistantes",
|
||||
|
||||
@@ -413,7 +413,7 @@ export const dict = {
|
||||
"error.page.version": "バージョン: {{version}}",
|
||||
"error.dev.rootNotFound":
|
||||
"ルート要素が見つかりません。index.htmlに追加するのを忘れていませんか?またはid属性のスペルが間違っていませんか?",
|
||||
"error.globalSync.connectFailed": "サーバーに接続できませんでした。`{{url}}`でサーバーが実行されていますか?",
|
||||
"error.serverSync.connectFailed": "サーバーに接続できませんでした。`{{url}}`でサーバーが実行されていますか?",
|
||||
"directory.error.invalidUrl": "URL内のディレクトリが無効です。",
|
||||
"error.chain.unknown": "不明なエラー",
|
||||
"error.chain.causedBy": "原因:",
|
||||
@@ -844,8 +844,8 @@ export const dict = {
|
||||
"common.key.insert": "Insert",
|
||||
"common.unknown": "不明",
|
||||
"error.page.circular": "[循環]",
|
||||
"error.globalSDK.noServerAvailable": "利用可能なサーバーがありません",
|
||||
"error.globalSDK.serverNotAvailable": "サーバーが利用できません",
|
||||
"error.serverSDK.noServerAvailable": "利用可能なサーバーがありません",
|
||||
"error.serverSDK.serverNotAvailable": "サーバーが利用できません",
|
||||
"error.childStore.persistedCacheCreateFailed": "永続キャッシュの作成に失敗しました",
|
||||
"error.childStore.persistedProjectMetadataCreateFailed": "永続プロジェクトメタデータの作成に失敗しました",
|
||||
"error.childStore.persistedProjectIconCreateFailed": "永続プロジェクトアイコンの作成に失敗しました",
|
||||
|
||||
@@ -412,7 +412,7 @@ export const dict = {
|
||||
"error.page.version": "버전: {{version}}",
|
||||
"error.dev.rootNotFound":
|
||||
"루트 요소를 찾을 수 없습니다. index.html에 추가하는 것을 잊으셨나요? 또는 id 속성의 철자가 틀렸을 수 있습니다.",
|
||||
"error.globalSync.connectFailed": "서버에 연결할 수 없습니다. `{{url}}`에서 서버가 실행 중인가요?",
|
||||
"error.serverSync.connectFailed": "서버에 연결할 수 없습니다. `{{url}}`에서 서버가 실행 중인가요?",
|
||||
"directory.error.invalidUrl": "URL에 유효하지 않은 디렉터리가 있습니다.",
|
||||
"error.chain.unknown": "알 수 없는 오류",
|
||||
"error.chain.causedBy": "원인:",
|
||||
@@ -839,8 +839,8 @@ export const dict = {
|
||||
"common.key.insert": "Insert",
|
||||
"common.unknown": "알 수 없음",
|
||||
"error.page.circular": "[순환]",
|
||||
"error.globalSDK.noServerAvailable": "사용 가능한 서버 없음",
|
||||
"error.globalSDK.serverNotAvailable": "서버를 사용할 수 없음",
|
||||
"error.serverSDK.noServerAvailable": "사용 가능한 서버 없음",
|
||||
"error.serverSDK.serverNotAvailable": "서버를 사용할 수 없음",
|
||||
"error.childStore.persistedCacheCreateFailed": "영구 캐시 생성 실패",
|
||||
"error.childStore.persistedProjectMetadataCreateFailed": "영구 프로젝트 메타데이터 생성 실패",
|
||||
"error.childStore.persistedProjectIconCreateFailed": "영구 프로젝트 아이콘 생성 실패",
|
||||
|
||||
@@ -463,7 +463,7 @@ export const dict = {
|
||||
"error.dev.rootNotFound":
|
||||
"Rotelement ikke funnet. Glemte du å legge det til i index.html? Eller kanskje id-attributten er feilstavet?",
|
||||
|
||||
"error.globalSync.connectFailed": "Kunne ikke koble til server. Kjører det en server på `{{url}}`?",
|
||||
"error.serverSync.connectFailed": "Kunne ikke koble til server. Kjører det en server på `{{url}}`?",
|
||||
"directory.error.invalidUrl": "Invalid directory in URL.",
|
||||
|
||||
"error.chain.unknown": "Ukjent feil",
|
||||
@@ -927,8 +927,8 @@ export const dict = {
|
||||
"common.key.insert": "Insert",
|
||||
"common.unknown": "ukjent",
|
||||
"error.page.circular": "[Sirkulær]",
|
||||
"error.globalSDK.noServerAvailable": "Ingen server tilgjengelig",
|
||||
"error.globalSDK.serverNotAvailable": "Server ikke tilgjengelig",
|
||||
"error.serverSDK.noServerAvailable": "Ingen server tilgjengelig",
|
||||
"error.serverSDK.serverNotAvailable": "Server ikke tilgjengelig",
|
||||
"error.childStore.persistedCacheCreateFailed": "Kunne ikke opprette vedvarende hurtigbuffer",
|
||||
"error.childStore.persistedProjectMetadataCreateFailed": "Kunne ikke opprette vedvarende prosjektmetadata",
|
||||
"error.childStore.persistedProjectIconCreateFailed": "Kunne ikke opprette vedvarende prosjektikon",
|
||||
|
||||
@@ -414,7 +414,7 @@ export const dict = {
|
||||
"error.page.version": "Wersja: {{version}}",
|
||||
"error.dev.rootNotFound":
|
||||
"Nie znaleziono elementu głównego. Czy zapomniałeś dodać go do swojego index.html? A może atrybut id został błędnie wpisany?",
|
||||
"error.globalSync.connectFailed": "Nie można połączyć się z serwerem. Czy serwer działa pod adresem `{{url}}`?",
|
||||
"error.serverSync.connectFailed": "Nie można połączyć się z serwerem. Czy serwer działa pod adresem `{{url}}`?",
|
||||
"directory.error.invalidUrl": "Nieprawidłowy katalog w URL.",
|
||||
"error.chain.unknown": "Nieznany błąd",
|
||||
"error.chain.causedBy": "Spowodowany przez:",
|
||||
@@ -848,8 +848,8 @@ export const dict = {
|
||||
"common.key.insert": "Insert",
|
||||
"common.unknown": "nieznany",
|
||||
"error.page.circular": "[Cykliczne]",
|
||||
"error.globalSDK.noServerAvailable": "Brak dostępnego serwera",
|
||||
"error.globalSDK.serverNotAvailable": "Serwer niedostępny",
|
||||
"error.serverSDK.noServerAvailable": "Brak dostępnego serwera",
|
||||
"error.serverSDK.serverNotAvailable": "Serwer niedostępny",
|
||||
"error.childStore.persistedCacheCreateFailed": "Nie udało się utworzyć trwałej pamięci podręcznej",
|
||||
"error.childStore.persistedProjectMetadataCreateFailed": "Nie udało się utworzyć trwałych metadanych projektu",
|
||||
"error.childStore.persistedProjectIconCreateFailed": "Nie udało się utworzyć trwałej ikony projektu",
|
||||
|
||||
@@ -461,7 +461,7 @@ export const dict = {
|
||||
"error.dev.rootNotFound":
|
||||
"Корневой элемент не найден. Вы забыли добавить его в index.html? Или, может быть, атрибут id был написан неправильно?",
|
||||
|
||||
"error.globalSync.connectFailed": "Не удалось подключиться к серверу. Запущен ли сервер по адресу `{{url}}`?",
|
||||
"error.serverSync.connectFailed": "Не удалось подключиться к серверу. Запущен ли сервер по адресу `{{url}}`?",
|
||||
"directory.error.invalidUrl": "Недопустимая директория в URL.",
|
||||
|
||||
"error.chain.unknown": "Неизвестная ошибка",
|
||||
@@ -929,8 +929,8 @@ export const dict = {
|
||||
"common.key.insert": "Insert",
|
||||
"common.unknown": "неизвестно",
|
||||
"error.page.circular": "[Циклично]",
|
||||
"error.globalSDK.noServerAvailable": "Нет доступного сервера",
|
||||
"error.globalSDK.serverNotAvailable": "Сервер недоступен",
|
||||
"error.serverSDK.noServerAvailable": "Нет доступного сервера",
|
||||
"error.serverSDK.serverNotAvailable": "Сервер недоступен",
|
||||
"error.childStore.persistedCacheCreateFailed": "Не удалось создать постоянный кэш",
|
||||
"error.childStore.persistedProjectMetadataCreateFailed": "Не удалось создать постоянные метаданные проекта",
|
||||
"error.childStore.persistedProjectIconCreateFailed": "Не удалось создать постоянный значок проекта",
|
||||
|
||||
@@ -459,7 +459,7 @@ export const dict = {
|
||||
|
||||
"error.dev.rootNotFound": "ไม่พบองค์ประกอบรูท คุณลืมเพิ่มใน index.html หรือบางทีแอตทริบิวต์ id อาจสะกดผิด?",
|
||||
|
||||
"error.globalSync.connectFailed": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ มีเซิร์ฟเวอร์ทำงานอยู่ที่ `{{url}}` หรือไม่?",
|
||||
"error.serverSync.connectFailed": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ มีเซิร์ฟเวอร์ทำงานอยู่ที่ `{{url}}` หรือไม่?",
|
||||
"directory.error.invalidUrl": "ไดเรกทอรีใน URL ไม่ถูกต้อง",
|
||||
|
||||
"error.chain.unknown": "ข้อผิดพลาดที่ไม่รู้จัก",
|
||||
@@ -916,8 +916,8 @@ export const dict = {
|
||||
"common.key.insert": "Insert",
|
||||
"common.unknown": "ไม่ทราบ",
|
||||
"error.page.circular": "[วงกลม]",
|
||||
"error.globalSDK.noServerAvailable": "ไม่มีเซิร์ฟเวอร์",
|
||||
"error.globalSDK.serverNotAvailable": "เซิร์ฟเวอร์ไม่พร้อมใช้งาน",
|
||||
"error.serverSDK.noServerAvailable": "ไม่มีเซิร์ฟเวอร์",
|
||||
"error.serverSDK.serverNotAvailable": "เซิร์ฟเวอร์ไม่พร้อมใช้งาน",
|
||||
"error.childStore.persistedCacheCreateFailed": "ไม่สามารถสร้างแคชถาวร",
|
||||
"error.childStore.persistedProjectMetadataCreateFailed": "ไม่สามารถสร้างเมตาดาต้าโปรเจกต์ถาวร",
|
||||
"error.childStore.persistedProjectIconCreateFailed": "ไม่สามารถสร้างไอคอนโปรเจกต์ถาวร",
|
||||
|
||||
@@ -465,7 +465,7 @@ export const dict = {
|
||||
"error.dev.rootNotFound":
|
||||
"Kök eleman bulunamadı. index.html dosyanıza eklemeyi unuttunuz mu? Ya da id özelliği yanlış mı yazıldı?",
|
||||
|
||||
"error.globalSync.connectFailed": "Sunucuya bağlanılamadı. `{{url}}` adresinde çalışan bir sunucu var mı?",
|
||||
"error.serverSync.connectFailed": "Sunucuya bağlanılamadı. `{{url}}` adresinde çalışan bir sunucu var mı?",
|
||||
"directory.error.invalidUrl": "URL'de geçersiz dizin.",
|
||||
|
||||
"error.chain.unknown": "Bilinmeyen hata",
|
||||
@@ -935,8 +935,8 @@ export const dict = {
|
||||
"common.key.insert": "Insert",
|
||||
"common.unknown": "bilinmiyor",
|
||||
"error.page.circular": "[Döngüsel]",
|
||||
"error.globalSDK.noServerAvailable": "Sunucu yok",
|
||||
"error.globalSDK.serverNotAvailable": "Sunucu mevcut değil",
|
||||
"error.serverSDK.noServerAvailable": "Sunucu yok",
|
||||
"error.serverSDK.serverNotAvailable": "Sunucu mevcut değil",
|
||||
"error.childStore.persistedCacheCreateFailed": "Kalıcı önbellek oluşturulamadı",
|
||||
"error.childStore.persistedProjectMetadataCreateFailed": "Kalıcı proje meta verileri oluşturulamadı",
|
||||
"error.childStore.persistedProjectIconCreateFailed": "Kalıcı proje simgesi oluşturulamadı",
|
||||
|
||||
@@ -483,9 +483,9 @@ export const dict = {
|
||||
"error.dev.rootNotFound":
|
||||
"Кореневий елемент не знайдено. Ви забули додати його до index.html? Або, можливо, атрибут id було написано з помилкою?",
|
||||
|
||||
"error.globalSync.connectFailed": "Не вдалося підключитися до сервера. Чи працює сервер за адресою `{{url}}`?",
|
||||
"error.globalSDK.noServerAvailable": "Сервер недоступний",
|
||||
"error.globalSDK.serverNotAvailable": "Сервер недоступний",
|
||||
"error.serverSync.connectFailed": "Не вдалося підключитися до сервера. Чи працює сервер за адресою `{{url}}`?",
|
||||
"error.serverSDK.noServerAvailable": "Сервер недоступний",
|
||||
"error.serverSDK.serverNotAvailable": "Сервер недоступний",
|
||||
"error.childStore.persistedCacheCreateFailed": "Не вдалося створити постійний кеш",
|
||||
"error.childStore.persistedProjectMetadataCreateFailed": "Не вдалося створити постійні метадані проєкту",
|
||||
"error.childStore.persistedProjectIconCreateFailed": "Не вдалося створити постійну іконку проєкту",
|
||||
@@ -759,8 +759,7 @@ export const dict = {
|
||||
"settings.general.row.followup.option.queue": "Черга",
|
||||
"settings.general.row.followup.option.steer": "Керування",
|
||||
"settings.general.row.showFileTree.title": "Дерево файлів",
|
||||
"settings.general.row.showFileTree.description":
|
||||
"Показувати перемикач і панель дерева файлів у сесіях на робочому столі",
|
||||
"settings.general.row.showFileTree.description": "Показувати панель дерева файлів у сесіях на робочому столі",
|
||||
"settings.general.row.showNavigation.title": "Елементи навігації",
|
||||
"settings.general.row.showNavigation.description": "Показувати кнопки назад і вперед у заголовку робочого столу",
|
||||
"settings.general.row.showSearch.title": "Палітра команд",
|
||||
|
||||
@@ -462,7 +462,7 @@ export const dict = {
|
||||
"error.page.report.discord": "在 Discord 上",
|
||||
"error.page.version": "版本:{{version}}",
|
||||
"error.dev.rootNotFound": "未找到根元素。你是不是忘了把它添加到 index.html?或者 id 属性拼写错了?",
|
||||
"error.globalSync.connectFailed": "无法连接到服务器。是否有服务器正在 `{{url}}` 运行?",
|
||||
"error.serverSync.connectFailed": "无法连接到服务器。是否有服务器正在 `{{url}}` 运行?",
|
||||
|
||||
"directory.error.invalidUrl": "URL 中的目录无效。",
|
||||
|
||||
@@ -498,6 +498,14 @@ export const dict = {
|
||||
"home.recentProjects": "最近项目",
|
||||
"home.empty.title": "没有最近项目",
|
||||
"home.empty.description": "通过打开本地项目开始使用",
|
||||
"home.title": "主页",
|
||||
"home.projects": "项目",
|
||||
"home.project.add": "添加项目",
|
||||
"home.sessions.search.placeholder": "搜索会话",
|
||||
"home.sessions.empty": "未找到会话",
|
||||
"home.sessions.group.today": "今天",
|
||||
"home.sessions.group.yesterday": "昨天",
|
||||
"home.sessions.group.older": "更早",
|
||||
|
||||
"session.tab.session": "会话",
|
||||
"session.tab.review": "审查",
|
||||
@@ -911,8 +919,8 @@ export const dict = {
|
||||
"common.key.insert": "Insert",
|
||||
"common.unknown": "未知",
|
||||
"error.page.circular": "[循环]",
|
||||
"error.globalSDK.noServerAvailable": "无可用服务器",
|
||||
"error.globalSDK.serverNotAvailable": "服务器不可用",
|
||||
"error.serverSDK.noServerAvailable": "无可用服务器",
|
||||
"error.serverSDK.serverNotAvailable": "服务器不可用",
|
||||
"error.childStore.persistedCacheCreateFailed": "创建持久化缓存失败",
|
||||
"error.childStore.persistedProjectMetadataCreateFailed": "创建持久化项目元数据失败",
|
||||
"error.childStore.persistedProjectIconCreateFailed": "创建持久化项目图标失败",
|
||||
|
||||
@@ -457,7 +457,7 @@ export const dict = {
|
||||
|
||||
"error.dev.rootNotFound": "找不到根元素。你是不是忘了把它新增到 index.html? 或者 id 屬性拼錯了?",
|
||||
|
||||
"error.globalSync.connectFailed": "無法連線到伺服器。是否有伺服器正在 `{{url}}` 執行?",
|
||||
"error.serverSync.connectFailed": "無法連線到伺服器。是否有伺服器正在 `{{url}}` 執行?",
|
||||
"directory.error.invalidUrl": "URL 中的目錄無效。",
|
||||
|
||||
"error.chain.unknown": "未知錯誤",
|
||||
@@ -907,8 +907,8 @@ export const dict = {
|
||||
"common.key.insert": "Insert",
|
||||
"common.unknown": "未知",
|
||||
"error.page.circular": "[循環]",
|
||||
"error.globalSDK.noServerAvailable": "無可用的伺服器",
|
||||
"error.globalSDK.serverNotAvailable": "伺服器無法使用",
|
||||
"error.serverSDK.noServerAvailable": "無可用的伺服器",
|
||||
"error.serverSDK.serverNotAvailable": "伺服器無法使用",
|
||||
"error.childStore.persistedCacheCreateFailed": "建立持續性快取失敗",
|
||||
"error.childStore.persistedProjectMetadataCreateFailed": "建立持續性專案中繼資料失敗",
|
||||
"error.childStore.persistedProjectIconCreateFailed": "建立持續性專案圖示失敗",
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
src: url("/assets/Inter.ttf") format("truetype");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@keyframes session-progress-whip {
|
||||
0% {
|
||||
@@ -43,9 +50,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--session-progress-color);
|
||||
clip-path: inset(0 100% 0 0 round 999px);
|
||||
animation: session-progress-whip var(--session-progress-ms, 1800ms) infinite;
|
||||
will-change: clip-path;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,24 @@ export { AppBaseProviders, AppInterface } from "./app"
|
||||
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
|
||||
export { useCommand } from "./context/command"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
|
||||
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
|
||||
export { useWslServers } from "./context/wsl-servers"
|
||||
export {
|
||||
type DisplayBackend,
|
||||
type FatalRendererErrorLog,
|
||||
type Platform,
|
||||
PlatformProvider,
|
||||
type WslDistroProbe,
|
||||
type WslInstalledDistro,
|
||||
type WslJob,
|
||||
type WslOnlineDistro,
|
||||
type WslOpencodeCheck,
|
||||
type WslRuntimeCheck,
|
||||
type WslServerConfig,
|
||||
type WslServerItem,
|
||||
type WslServerRuntime,
|
||||
type WslServersEvent,
|
||||
type WslServersPlatform,
|
||||
type WslServersState,
|
||||
} from "./context/platform"
|
||||
export { ServerConnection } from "./context/server"
|
||||
export { handleNotificationClick } from "./utils/notification-click"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createEffect, createMemo, createResource, type ParentProps, Show } from
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { LocalProvider } from "@/context/local"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
import { SyncProvider, useSync } from "@/context/sync"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -81,10 +81,8 @@ export default function Layout(props: ParentProps) {
|
||||
return (
|
||||
<Show when={resolved()} keyed>
|
||||
{(resolved) => (
|
||||
<SDKProvider directory={() => resolved}>
|
||||
<SyncProvider>
|
||||
<DirectoryDataProvider directory={resolved}>{props.children}</DirectoryDataProvider>
|
||||
</SyncProvider>
|
||||
<SDKProvider directory={resolved}>
|
||||
<DirectoryDataProvider directory={resolved}>{props.children}</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import * as Sentry from "@sentry/solid"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Component, createSignal, Show } from "solid-js"
|
||||
import { Component, createSignal, onMount, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -221,12 +221,30 @@ interface ErrorPageProps {
|
||||
export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const formattedError = () => formatError(props.error, language.t)
|
||||
let recordedFatalError: Promise<void> | undefined
|
||||
const [store, setStore] = createStore({
|
||||
checking: false,
|
||||
version: undefined as string | undefined,
|
||||
actionError: undefined as string | undefined,
|
||||
})
|
||||
|
||||
function ensureFatalErrorRecorded() {
|
||||
recordedFatalError ??=
|
||||
platform.recordFatalRendererError?.({
|
||||
error: formattedError(),
|
||||
url: location.href,
|
||||
version: platform.version,
|
||||
platform: platform.platform,
|
||||
os: platform.os,
|
||||
}) ?? Promise.resolve()
|
||||
return recordedFatalError
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void ensureFatalErrorRecorded().catch(() => undefined)
|
||||
})
|
||||
|
||||
async function checkForUpdates() {
|
||||
if (!platform.checkUpdate) return
|
||||
setStore("checking", true)
|
||||
@@ -254,6 +272,17 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
})
|
||||
}
|
||||
|
||||
async function exportDebugLogs() {
|
||||
const exportLogs = platform.exportDebugLogs
|
||||
if (!exportLogs) return
|
||||
await ensureFatalErrorRecorded()
|
||||
.then(() => exportLogs())
|
||||
.then(() => setStore("actionError", undefined))
|
||||
.catch((err) => {
|
||||
setStore("actionError", formatError(err, language.t))
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
|
||||
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
|
||||
@@ -263,7 +292,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
<p class="text-sm text-text-weak">{language.t("error.page.description")}</p>
|
||||
</div>
|
||||
<TextField
|
||||
value={formatError(props.error, language.t)}
|
||||
value={formattedError()}
|
||||
readOnly
|
||||
copyable
|
||||
multiline
|
||||
@@ -275,6 +304,11 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
<Button size="large" onClick={platform.restart}>
|
||||
{language.t("error.page.action.restart")}
|
||||
</Button>
|
||||
<Show when={platform.platform === "desktop" && platform.exportDebugLogs}>
|
||||
<Button size="large" variant="ghost" onClick={exportDebugLogs}>
|
||||
{language.t("error.page.action.exportLogs")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={Sentry.isEnabled}>
|
||||
{(_) => {
|
||||
const [reported, setReported] = createSignal(false)
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { createMemo, For, Match, Switch } from "solid-js"
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/components/button-v2.jsx"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/components/menu-v2.jsx"
|
||||
import { getAvatarColors, useLayout, type LocalProject } from "@/context/layout"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -10,18 +19,674 @@ import { DateTime } from "luxon"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { displayName, getProjectAvatarSource, projectForSession, sortedRootSessions } from "@/pages/layout/helpers"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { useServers } from "@/context/servers"
|
||||
import { useSettings } from "@/context/settings"
|
||||
|
||||
const HOME_SESSION_LIMIT = 15
|
||||
const HOME_ROW =
|
||||
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] border-0 bg-transparent text-left text-v2-text-text-muted transition-colors duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
const HOME_PROJECT_NAV_ROW = `${HOME_ROW} h-7 gap-2 px-1.5 [&>span]:min-w-0 [&>span]:overflow-hidden [&>span]:text-ellipsis [&>span]:whitespace-nowrap`
|
||||
const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
|
||||
|
||||
type HomeSessionRecord = {
|
||||
session: Session
|
||||
project: LocalProject
|
||||
projectName: string
|
||||
}
|
||||
|
||||
type HomeSessionGroup = {
|
||||
id: "today" | "yesterday" | "older"
|
||||
title: string
|
||||
sessions: HomeSessionRecord[]
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const sync = useGlobalSync()
|
||||
const settings = useSettings()
|
||||
return (
|
||||
<Show when={settings.general.newLayoutDesigns()} fallback={<LegacyHome />}>
|
||||
<HomeDesign />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeDesign() {
|
||||
const sync = useServerSync()
|
||||
const layout = useLayout()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const notification = useNotification()
|
||||
const [state, setState] = createStore({ search: "", project: undefined as string | undefined })
|
||||
|
||||
const projects = createMemo(() => layout.projects.list())
|
||||
const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.project))
|
||||
const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])]
|
||||
const projectDirectories = createMemo(() => {
|
||||
const project = selectedProject()
|
||||
if (!project) return [...projects().flatMap((project) => directories(project))]
|
||||
return directories(project)
|
||||
})
|
||||
const search = createMemo(() => state.search.trim())
|
||||
const sessionLoad = useQuery(() => ({
|
||||
queryKey: ["home", "sessions", ...projectDirectories()] as const,
|
||||
queryFn: async () => {
|
||||
await Promise.all(projectDirectories().map((directory) => sync.project.loadSessions(directory)))
|
||||
return null
|
||||
},
|
||||
}))
|
||||
|
||||
const projectByID = createMemo(
|
||||
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
||||
)
|
||||
const records = createMemo(() => {
|
||||
return [
|
||||
...new Map(
|
||||
projectDirectories()
|
||||
.flatMap((directory) => sortedRootSessions(sync.child(directory, { bootstrap: false })[0], Date.now()))
|
||||
.map((session) => [`${pathKey(session.directory)}:${session.id}`, session] as const),
|
||||
).values(),
|
||||
]
|
||||
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||
.flatMap((session) => {
|
||||
const project = projectForSession(session, projects(), projectByID())
|
||||
if (!project) return []
|
||||
return {
|
||||
session,
|
||||
project,
|
||||
projectName: displayName(project),
|
||||
}
|
||||
})
|
||||
.filter((record) => {
|
||||
const value = search().toLowerCase()
|
||||
if (!value) return true
|
||||
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(value)
|
||||
})
|
||||
.slice(0, HOME_SESSION_LIMIT)
|
||||
})
|
||||
const groups = createMemo(() => groupSessions(records(), language))
|
||||
|
||||
function selectProject(directory: string) {
|
||||
if (!projects().some((project) => project.worktree === directory)) return
|
||||
setState("project", directory)
|
||||
}
|
||||
|
||||
function addProject(directory: string) {
|
||||
layout.projects.open(directory)
|
||||
server.projects.touch(directory)
|
||||
setState("project", directory)
|
||||
}
|
||||
|
||||
function openNewSession() {
|
||||
const project = selectedProject()
|
||||
if (!project) {
|
||||
void chooseProject()
|
||||
return
|
||||
}
|
||||
layout.projects.open(project.worktree)
|
||||
server.projects.touch(project.worktree)
|
||||
navigate(`/${base64Encode(project.worktree)}/session`)
|
||||
}
|
||||
|
||||
function openProjectNewSession(directory: string) {
|
||||
layout.projects.open(directory)
|
||||
server.projects.touch(directory)
|
||||
navigate(`/${base64Encode(directory)}/session`)
|
||||
}
|
||||
|
||||
const showEditProjectDialog = (project: LocalProject) => {
|
||||
void import("@/components/dialog-edit-project").then((x) => {
|
||||
dialog.show(() => <x.DialogEditProject project={project} />)
|
||||
})
|
||||
}
|
||||
|
||||
const unseenCount = (project: LocalProject) =>
|
||||
directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
|
||||
|
||||
const clearNotifications = (project: LocalProject) =>
|
||||
directories(project)
|
||||
.filter((directory) => notification.project.unseenCount(directory) > 0)
|
||||
.forEach((directory) => notification.project.markViewed(directory))
|
||||
|
||||
function openSession(session: Session) {
|
||||
const project = projectForSession(session, projects(), projectByID())
|
||||
layout.projects.open(project?.worktree ?? session.directory)
|
||||
server.projects.touch(project?.worktree ?? session.directory)
|
||||
navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
|
||||
}
|
||||
|
||||
async function chooseProject() {
|
||||
function resolve(result: string | string[] | null) {
|
||||
if (Array.isArray(result)) {
|
||||
result.forEach(addProject)
|
||||
if (result[0]) setState("project", result[0])
|
||||
return
|
||||
}
|
||||
if (result) addProject(result)
|
||||
}
|
||||
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
})
|
||||
resolve(result)
|
||||
return
|
||||
}
|
||||
|
||||
dialog.show(
|
||||
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
void import("@/components/dialog-settings").then((x) => {
|
||||
dialog.show(() => <x.DialogSettings />)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="mx-auto grid w-full h-full max-w-[1080px] gap-8 px-6 pb-16 lg:grid-cols-[280px_minmax(0,720px)]">
|
||||
<HomeProjectColumn
|
||||
selectedProject={state.project}
|
||||
selectProject={selectProject}
|
||||
openNewSession={openProjectNewSession}
|
||||
chooseProject={() => void chooseProject()}
|
||||
editProject={showEditProjectDialog}
|
||||
closeProject={(directory) => {
|
||||
layout.projects.close(directory)
|
||||
if (state.project === directory) setState("project", undefined)
|
||||
}}
|
||||
clearNotifications={clearNotifications}
|
||||
unseenCount={unseenCount}
|
||||
openSettings={openSettings}
|
||||
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
language={language}
|
||||
/>
|
||||
|
||||
<section
|
||||
class="min-w-0 flex-1 flex flex-col overflow-y-hidden pt-12"
|
||||
aria-label={language.t("sidebar.project.recentSessions")}
|
||||
>
|
||||
<Show
|
||||
when={projectDirectories().length > 0}
|
||||
fallback={
|
||||
<HomeEmptyState
|
||||
icon="folder-add-left"
|
||||
title={language.t("home.empty.title")}
|
||||
description={language.t("home.empty.description")}
|
||||
action={language.t("home.project.add")}
|
||||
onAction={() => void chooseProject()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<HomeSessionSearch
|
||||
value={state.search}
|
||||
placeholder={language.t("home.sessions.search.placeholder")}
|
||||
onInput={(value) => setState("search", value)}
|
||||
clearLabel={language.t("common.clear")}
|
||||
onClear={() => setState("search", "")}
|
||||
/>
|
||||
<div class="mt-3 overflow-auto flex-1">
|
||||
<div class="pt-3 flex flex-col gap-6">
|
||||
<Show
|
||||
when={!sessionLoad.isLoading}
|
||||
fallback={<HomeSessionSkeleton label={language.t("common.loading")} />}
|
||||
>
|
||||
<Show
|
||||
when={groups().length > 0}
|
||||
fallback={
|
||||
<HomeEmptyState
|
||||
icon="edit"
|
||||
title={language.t("home.sessions.empty")}
|
||||
description={language.t("home.sessions.empty.description")}
|
||||
action={language.t("command.session.new")}
|
||||
onAction={openNewSession}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<For each={groups()}>
|
||||
{(group, index) => (
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
<HomeSessionGroupHeader
|
||||
title={group.title}
|
||||
onNewSession={index() === 0 ? openNewSession : undefined}
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col gap-px">
|
||||
<For each={group.sessions}>
|
||||
{(record) => <HomeSessionRow record={record} openSession={openSession} />}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectColumn(props: {
|
||||
selectedProject?: string
|
||||
selectProject: (directory: string) => void
|
||||
openNewSession: (directory: string) => void
|
||||
chooseProject: () => void
|
||||
editProject: (project: LocalProject) => void
|
||||
closeProject: (directory: string) => void
|
||||
clearNotifications: (project: LocalProject) => void
|
||||
unseenCount: (project: LocalProject) => number
|
||||
openSettings: () => void
|
||||
openHelp: () => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const servers = useServers()
|
||||
const layout = useLayout()
|
||||
const projects = createMemo(() => layout.projects.list())
|
||||
return (
|
||||
<aside class="flex min-w-0 flex-col lg:pt-[52px] gap-4" aria-label={props.language.t("home.projects")}>
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
|
||||
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
icon={<IconV2 name="folder-add-left" />}
|
||||
onClick={props.chooseProject}
|
||||
aria-label={props.language.t("home.project.add")}
|
||||
/>
|
||||
</div>
|
||||
<Show
|
||||
when={servers.list().length > 1}
|
||||
fallback={
|
||||
<ProjectList
|
||||
projects={projects()}
|
||||
selectedProject={props.selectedProject}
|
||||
onSelectedProjectChange={props.selectProject}
|
||||
onChooseProject={props.chooseProject}
|
||||
openNewSession={props.openNewSession}
|
||||
editProject={props.editProject}
|
||||
closeProject={props.closeProject}
|
||||
clearNotifications={props.clearNotifications}
|
||||
unseenCount={props.unseenCount}
|
||||
language={props.language}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<For each={servers.list()}>
|
||||
{(server) => {
|
||||
const key = ServerConnection.key(server)
|
||||
const healthy = () => !!servers.health[key]?.healthy
|
||||
const [open, setOpen] = createSignal(true)
|
||||
|
||||
return (
|
||||
<div class="max-h-[min(572px,calc(100vh_-_300px))] min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div class="relative h-7 group">
|
||||
<button
|
||||
class="w-full h-full px-1.5 gap-2 flex flex-row items-center hover:not-disabled:bg-v2-overlay-simple-overlay-hover rounded-[4px]"
|
||||
disabled={!healthy()}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<div class="size-4 flex items-center justify-center">
|
||||
<ServerHealthIndicator health={servers.health[key]} />
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<span>{server.displayName ?? new URL(server.http.url).host}</span>
|
||||
<Show when={healthy()}>
|
||||
<IconV2
|
||||
name="outline-chevron-down"
|
||||
class="text-v2-icon-icon-muted data-[open=false]:-rotate-90"
|
||||
data-open={open()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<IconButtonV2
|
||||
class="absolute right-1 inset-y-1 opacity-0 group-hover:opacity-100"
|
||||
name="out"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="outline-dots" class="text-v2-icon-icon-muted" />}
|
||||
/>
|
||||
</div>
|
||||
<Show when={healthy() && open()}>
|
||||
<div class="h-px bg-v2-border-border-base mx-3 my-1" />
|
||||
<ProjectList
|
||||
projects={projects()}
|
||||
selectedProject={props.selectedProject}
|
||||
onSelectedProjectChange={props.selectProject}
|
||||
onChooseProject={props.chooseProject}
|
||||
openNewSession={props.openNewSession}
|
||||
editProject={props.editProject}
|
||||
closeProject={props.closeProject}
|
||||
clearNotifications={props.clearNotifications}
|
||||
unseenCount={props.unseenCount}
|
||||
language={props.language}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
||||
onClick={props.openSettings}
|
||||
>
|
||||
<IconV2 name="settings-gear" size="small" />
|
||||
<span>{props.language.t("sidebar.settings")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
||||
onClick={props.openHelp}
|
||||
>
|
||||
<IconV2 name="help" size="small" />
|
||||
<span>{props.language.t("sidebar.help")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectRow(props: {
|
||||
project: LocalProject
|
||||
selected: boolean
|
||||
unseenCount: number
|
||||
selectProject: (directory: string) => void
|
||||
openNewSession: (directory: string) => void
|
||||
editProject: (project: LocalProject) => void
|
||||
closeProject: (directory: string) => void
|
||||
clearNotifications: (project: LocalProject) => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const name = createMemo(() => displayName(props.project))
|
||||
const [menuOpen, setMenuOpen] = createSignal(false)
|
||||
|
||||
return (
|
||||
<div class="group/project relative flex h-8 min-w-0 items-center rounded-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-project-row"
|
||||
class={`${HOME_PROJECT_NAV_ROW} pr-16 peer`}
|
||||
classList={{ "bg-v2-overlay-simple-overlay-hover": props.selected }}
|
||||
data-selected={props.selected ? "" : undefined}
|
||||
aria-current={props.selected ? "page" : undefined}
|
||||
onClick={() => props.selectProject(props.project.worktree)}
|
||||
>
|
||||
<HomeProjectAvatar project={props.project} />
|
||||
<span>{name()}</span>
|
||||
</button>
|
||||
<div
|
||||
class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 peer-focus-visible:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
|
||||
data-menu={menuOpen()}
|
||||
>
|
||||
<MenuV2 gutter={4} modal={false} placement="bottom-end" open={menuOpen()} onOpenChange={setMenuOpen}>
|
||||
<MenuV2.Trigger
|
||||
as={IconButtonV2}
|
||||
data-action="home-project-menu"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="outline-dots" />}
|
||||
aria-label={props.language.t("common.moreOptions")}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.Item onSelect={() => props.openNewSession(props.project.worktree)}>
|
||||
{props.language.t("command.session.new")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => props.editProject(props.project)}>
|
||||
{props.language.t("common.edit")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item disabled={props.unseenCount === 0} onSelect={() => props.clearNotifications(props.project)}>
|
||||
{props.language.t("sidebar.project.clearNotifications")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={() => props.closeProject(props.project.worktree)}>
|
||||
{props.language.t("common.close")}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<IconButtonV2
|
||||
data-action="home-project-new-session"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="edit" />}
|
||||
aria-label={props.language.t("command.session.new")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.openNewSession(props.project.worktree)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectAvatar(props: { project: LocalProject }) {
|
||||
const name = createMemo(() => displayName(props.project))
|
||||
return (
|
||||
<AvatarV2
|
||||
fallback={name()}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
kind="org"
|
||||
size="small"
|
||||
{...getAvatarColors(props.project.icon?.color)}
|
||||
class="size-4 rounded"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionSearch(props: {
|
||||
value: string
|
||||
placeholder: string
|
||||
clearLabel: string
|
||||
onInput: (value: string) => void
|
||||
onClear: () => void
|
||||
}) {
|
||||
return (
|
||||
<label class="ml-4 flex h-9 w-[calc(100%_-_48px)] sticky top-0 inset-x-0 items-center gap-2 rounded-[6px] bg-v2-background-bg-deep px-3 py-1 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out focus-within:bg-v2-background-bg-base focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]">
|
||||
<IconV2 name="magnifying-glass" size="small" />
|
||||
<input
|
||||
class="min-w-0 flex-1 border-0 bg-transparent text-v2-text-text-base outline-0 [font-weight:440] placeholder:text-v2-text-text-faint"
|
||||
value={props.value}
|
||||
placeholder={props.placeholder}
|
||||
aria-label={props.placeholder}
|
||||
onInput={(event) => props.onInput(event.currentTarget.value)}
|
||||
/>
|
||||
<Show when={props.value.trim()}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 shrink-0 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
aria-label={props.clearLabel}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
props.onClear()
|
||||
}}
|
||||
>
|
||||
<Icon name="close-small" size="small" />
|
||||
</button>
|
||||
</Show>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeEmptyState(props: {
|
||||
icon: Parameters<typeof IconV2>[0]["name"]
|
||||
title: string
|
||||
description: string
|
||||
action: string
|
||||
onAction: () => void
|
||||
}) {
|
||||
return (
|
||||
<div class="flex min-h-[320px] flex-1 flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<div class="flex size-10 items-center justify-center rounded-[10px] bg-v2-background-bg-deep text-v2-icon-icon-muted shadow-[var(--v2-elevation-raised)]">
|
||||
<IconV2 name={props.icon} />
|
||||
</div>
|
||||
<div class="flex max-w-[320px] flex-col gap-1">
|
||||
<div class="text-v2-text-text-base [font-weight:530]">{props.title}</div>
|
||||
<div class="text-v2-text-text-muted [font-weight:440]">{props.description}</div>
|
||||
</div>
|
||||
<ButtonV2 variant="neutral" size="normal" icon={props.icon} onClick={props.onAction}>
|
||||
{props.action}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div class="flex h-7 min-w-0 items-center justify-between px-4">
|
||||
<div class={HOME_SECTION_LABEL}>{props.title}</div>
|
||||
<Show when={props.onNewSession}>
|
||||
{(onNewSession) => (
|
||||
<ButtonV2
|
||||
data-action="home-new-session"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
icon="edit"
|
||||
class="h-7 px-2 text-v2-text-text-muted"
|
||||
onClick={onNewSession()}
|
||||
>
|
||||
{language.t("command.session.new")}
|
||||
</ButtonV2>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (session: Session) => void }) {
|
||||
const serverSync = useServerSync()
|
||||
const notification = useNotification()
|
||||
const permission = usePermission()
|
||||
const [sessionStore] = serverSync.child(props.record.session.directory, { bootstrap: false })
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id))
|
||||
const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id))
|
||||
const hasPermissions = createMemo(
|
||||
() =>
|
||||
!!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.record.session.id, (item) => {
|
||||
return !permission.autoResponds(item, props.record.session.directory)
|
||||
}),
|
||||
)
|
||||
const isWorking = createMemo(() => {
|
||||
if (hasPermissions()) return false
|
||||
return sessionStore.session_working(props.record.session.id)
|
||||
})
|
||||
const tint = createMemo(() => messageAgentColor(sessionStore.message[props.record.session.id], sessionStore.agent))
|
||||
const showStatus = createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-session-row"
|
||||
class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
|
||||
onClick={() => props.openSession(props.record.session)}
|
||||
>
|
||||
<Show when={showStatus()}>
|
||||
<div
|
||||
class="flex size-4 shrink-0 items-center justify-center"
|
||||
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={isWorking()}>
|
||||
<Spinner class="size-[15px]" />
|
||||
</Match>
|
||||
<Match when={hasPermissions()}>
|
||||
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
||||
</Match>
|
||||
<Match when={hasError()}>
|
||||
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
||||
</Match>
|
||||
<Match when={unseenCount() > 0}>
|
||||
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</Show>
|
||||
<span
|
||||
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
||||
>
|
||||
{title()}
|
||||
</span>
|
||||
<Show when={props.record.projectName}>
|
||||
<span class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]">
|
||||
{props.record.projectName}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionSkeleton(props: { label: string }) {
|
||||
return (
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
<div class="flex h-7 min-w-0 items-center justify-between px-4">
|
||||
<div class={HOME_SECTION_LABEL}>{props.label}</div>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-px" aria-hidden="true">
|
||||
<For each={[0, 1, 2, 3]}>{() => <div class="h-10 rounded-[6px] bg-v2-background-bg-deep opacity-70" />}</For>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
|
||||
const now = DateTime.local()
|
||||
const yesterday = now.minus({ days: 1 })
|
||||
const todaySessions = records.filter((record) =>
|
||||
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"),
|
||||
)
|
||||
const yesterdaySessions = records.filter((record) =>
|
||||
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"),
|
||||
)
|
||||
const olderSessions = records.filter((record) => {
|
||||
const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created)
|
||||
return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day")
|
||||
})
|
||||
const olderTitle =
|
||||
todaySessions.length === 0 && yesterdaySessions.length === 0
|
||||
? language.t("sidebar.project.recentSessions")
|
||||
: language.t("home.sessions.group.older")
|
||||
|
||||
return [
|
||||
{ id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions },
|
||||
{ id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions },
|
||||
{ id: "older" as const, title: olderTitle, sessions: olderSessions },
|
||||
].filter((group) => group.sessions.length > 0)
|
||||
}
|
||||
|
||||
function LegacyHome() {
|
||||
const sync = useServerSync()
|
||||
const layout = useLayout()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const servers = useServers()
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const homedir = createMemo(() => sync.data.path.home)
|
||||
const recent = createMemo(() => {
|
||||
return sync.data.project
|
||||
@@ -31,11 +696,12 @@ export default function Home() {
|
||||
})
|
||||
|
||||
const serverDotClass = createMemo(() => {
|
||||
const healthy = server.healthy()
|
||||
const healthy = 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"
|
||||
})
|
||||
const useWebDirectoryPicker = createMemo(() => server.current?.type === "sidecar" && server.current.variant === "wsl")
|
||||
|
||||
function openProject(directory: string) {
|
||||
layout.projects.open(directory)
|
||||
@@ -54,7 +720,7 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal() && !useWebDirectoryPicker()) {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
@@ -75,7 +741,7 @@ export default function Home() {
|
||||
size="large"
|
||||
variant="ghost"
|
||||
class="mt-4 mx-auto text-14-regular text-text-weak"
|
||||
onClick={() => dialog.show(() => <DialogSelectServer />)}
|
||||
onClick={() => dialog.show(() => <DialogSelectServer onNavigateHome={() => navigate("/")} />)}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
@@ -137,3 +803,50 @@ export default function Home() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectList(props: {
|
||||
projects: LocalProject[]
|
||||
selectedProject?: string
|
||||
onSelectedProjectChange?(project: string): void
|
||||
onChooseProject?(): void
|
||||
openNewSession: (directory: string) => void
|
||||
editProject: (project: LocalProject) => void
|
||||
closeProject: (directory: string) => void
|
||||
clearNotifications: (project: LocalProject) => void
|
||||
unseenCount: (project: LocalProject) => number
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
return (
|
||||
<Show
|
||||
when={props.projects.length > 0}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
||||
onClick={() => props.onChooseProject?.()}
|
||||
>
|
||||
<IconV2 name="folder-add-left" size="small" />
|
||||
<span>{props.language.t("home.project.add")}</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<For each={props.projects}>
|
||||
{(project) => (
|
||||
<HomeProjectRow
|
||||
project={project}
|
||||
selected={props.selectedProject === project.worktree}
|
||||
unseenCount={props.unseenCount(project)}
|
||||
selectProject={(directory) => props.onSelectedProjectChange?.(directory)}
|
||||
openNewSession={props.openNewSession}
|
||||
editProject={props.editProject}
|
||||
closeProject={props.closeProject}
|
||||
clearNotifications={props.clearNotifications}
|
||||
language={props.language}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
+301
-247
@@ -14,8 +14,9 @@ import {
|
||||
} from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { useLayout, LocalProject } from "@/context/layout"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
@@ -34,7 +35,7 @@ import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, close
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { clearWorkspaceTerminals, getTerminalServerScope } from "@/context/terminal"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
|
||||
import {
|
||||
@@ -61,7 +62,7 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { Titlebar } from "@/components/titlebar"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useLanguage, type Locale } from "@/context/language"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
@@ -110,8 +111,8 @@ export default function Layout(props: ParentProps) {
|
||||
let dialogDead = false
|
||||
|
||||
const params = useParams()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const layout = useLayout()
|
||||
const layoutReady = createMemo(() => layout.ready())
|
||||
const platform = usePlatform()
|
||||
@@ -126,6 +127,7 @@ export default function Layout(props: ParentProps) {
|
||||
const command = useCommand()
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
const newDesign = createMemo(() => settings.general.newLayoutDesigns())
|
||||
const initialDirectory = decode64(params.dir)
|
||||
const location = useLocation()
|
||||
const route = createMemo(() => {
|
||||
@@ -133,7 +135,7 @@ export default function Layout(props: ParentProps) {
|
||||
if (!slug) return { slug, dir: "" }
|
||||
const dir = decode64(slug)
|
||||
if (!dir) return { slug, dir: "" }
|
||||
const store = globalSync.peek(dir, { bootstrap: false })
|
||||
const store = serverSync.peek(dir, { bootstrap: false })
|
||||
return {
|
||||
slug,
|
||||
store,
|
||||
@@ -149,9 +151,10 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
|
||||
const currentDir = createMemo(() => route().dir)
|
||||
const useWebDirectoryPicker = createMemo(() => server.current?.type === "sidecar" && server.current.variant === "wsl")
|
||||
|
||||
const [state, setState] = createStore({
|
||||
autoselect: !initialDirectory,
|
||||
autoselect: !initialDirectory && !newDesign(),
|
||||
busyWorkspaces: {} as Record<string, boolean>,
|
||||
hoverProject: undefined as string | undefined,
|
||||
scrollSessionKey: undefined as string | undefined,
|
||||
@@ -162,6 +165,35 @@ export default function Layout(props: ParentProps) {
|
||||
peeked: false,
|
||||
})
|
||||
|
||||
const [update, setUpdate] = createStore({
|
||||
installing: false,
|
||||
})
|
||||
const updateQuery = useQuery(() => ({
|
||||
queryKey: ["desktop", "update"] as const,
|
||||
enabled: () =>
|
||||
!!platform.checkUpdate && !!platform.updateAndRestart && settings.ready() && settings.updates.startup(),
|
||||
queryFn: () => platform.checkUpdate?.() ?? Promise.resolve({ updateAvailable: false, version: undefined }),
|
||||
refetchInterval: (query) => (query.state.data?.updateAvailable ? false : 10 * 60 * 1000),
|
||||
}))
|
||||
const updateVersion = () => {
|
||||
if (!settings.ready()) return
|
||||
if (!settings.updates.startup()) return
|
||||
if (!updateQuery.data?.updateAvailable) return
|
||||
return updateQuery.data.version ?? ""
|
||||
}
|
||||
const installUpdate = () => {
|
||||
if (!platform.updateAndRestart) return
|
||||
setUpdate("installing", true)
|
||||
void platform.updateAndRestart().catch(() => {
|
||||
setUpdate("installing", false)
|
||||
})
|
||||
}
|
||||
const titlebarUpdate: TitlebarUpdate = {
|
||||
version: updateVersion,
|
||||
installing: () => update.installing,
|
||||
install: installUpdate,
|
||||
}
|
||||
|
||||
const editor = createInlineEditorController()
|
||||
const setBusy = (directory: string, value: boolean) => {
|
||||
const key = pathKey(directory)
|
||||
@@ -194,7 +226,7 @@ export default function Layout(props: ParentProps) {
|
||||
active: () => state.hoverProject,
|
||||
el: () => state.nav?.querySelector<HTMLElement>("[data-component='sidebar-rail']") ?? state.nav,
|
||||
onActivate: (directory) => {
|
||||
globalSync.child(directory)
|
||||
serverSync.child(directory)
|
||||
setState("hoverProject", directory)
|
||||
},
|
||||
})
|
||||
@@ -364,58 +396,6 @@ export default function Layout(props: ParentProps) {
|
||||
setLocale(next)
|
||||
}
|
||||
|
||||
const useUpdatePolling = () =>
|
||||
onMount(() => {
|
||||
if (!platform.checkUpdate || !platform.updateAndRestart) return
|
||||
|
||||
let toastId: number | undefined
|
||||
let interval: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const pollUpdate = () =>
|
||||
platform.checkUpdate!().then(({ updateAvailable, version }) => {
|
||||
if (!updateAvailable) return
|
||||
if (toastId !== undefined) return
|
||||
toastId = showToast({
|
||||
persistent: true,
|
||||
icon: "download",
|
||||
title: language.t("toast.update.title"),
|
||||
description: language.t("toast.update.description", { version: version ?? "" }),
|
||||
actions: [
|
||||
{
|
||||
label: language.t("toast.update.action.installRestart"),
|
||||
onClick: async () => {
|
||||
await platform.updateAndRestart!()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: language.t("toast.update.action.notYet"),
|
||||
onClick: "dismiss",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!settings.ready()) return
|
||||
|
||||
if (!settings.updates.startup()) {
|
||||
if (interval === undefined) return
|
||||
clearInterval(interval)
|
||||
interval = undefined
|
||||
return
|
||||
}
|
||||
|
||||
if (interval !== undefined) return
|
||||
void pollUpdate()
|
||||
interval = setInterval(pollUpdate, 10 * 60 * 1000)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (interval === undefined) return
|
||||
clearInterval(interval)
|
||||
})
|
||||
})
|
||||
|
||||
const useSDKNotificationToasts = () =>
|
||||
onMount(() => {
|
||||
const toastBySession = new Map<string, number>()
|
||||
@@ -430,7 +410,7 @@ export default function Layout(props: ParentProps) {
|
||||
alertedAtBySession.delete(sessionKey)
|
||||
}
|
||||
|
||||
const unsub = globalSDK.event.listen((e) => {
|
||||
const unsub = serverSDK.event.listen((e) => {
|
||||
if (e.details?.type === "worktree.ready") {
|
||||
setBusy(e.name, false)
|
||||
WorktreeState.ready(e.name)
|
||||
@@ -464,7 +444,7 @@ export default function Layout(props: ParentProps) {
|
||||
const props = e.details.properties
|
||||
if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
|
||||
|
||||
const [store] = globalSync.child(directory, { bootstrap: false })
|
||||
const [store] = serverSync.child(directory, { bootstrap: false })
|
||||
const session = store.session.find((s) => s.id === props.sessionID)
|
||||
const sessionKey = `${directory}:${props.sessionID}`
|
||||
|
||||
@@ -527,7 +507,7 @@ export default function Layout(props: ParentProps) {
|
||||
if (!currentDir() || !currentSession) return
|
||||
const sessionKey = `${currentDir()}:${currentSession}`
|
||||
dismissSessionAlert(sessionKey)
|
||||
const [store] = globalSync.child(currentDir(), { bootstrap: false })
|
||||
const [store] = serverSync.child(currentDir(), { bootstrap: false })
|
||||
const childSessions = store.session.filter((s) => s.parentID === currentSession)
|
||||
for (const child of childSessions) {
|
||||
dismissSessionAlert(`${currentDir()}:${child.id}`)
|
||||
@@ -535,7 +515,6 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
})
|
||||
|
||||
useUpdatePolling()
|
||||
useSDKNotificationToasts()
|
||||
|
||||
function scrollToSession(sessionId: string, sessionKey: string) {
|
||||
@@ -566,11 +545,11 @@ export default function Layout(props: ParentProps) {
|
||||
const direct = projects.find((p) => pathKey(p.worktree) === key)
|
||||
if (direct) return direct
|
||||
|
||||
const [child] = globalSync.child(directory, { bootstrap: false })
|
||||
const [child] = serverSync.child(directory, { bootstrap: false })
|
||||
const id = child.project
|
||||
if (!id) return
|
||||
|
||||
const meta = globalSync.data.project.find((p) => p.id === id)
|
||||
const meta = serverSync.data.project.find((p) => p.id === id)
|
||||
const root = meta?.worktree
|
||||
if (!root) return
|
||||
|
||||
@@ -661,7 +640,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const result: Session[] = []
|
||||
for (const dir of dirs) {
|
||||
const [dirStore] = globalSync.child(dir, { bootstrap: true })
|
||||
const [dirStore] = serverSync.child(dir, { bootstrap: true })
|
||||
const dirSessions = sortedRootSessions(dirStore, now)
|
||||
result.push(...dirSessions)
|
||||
}
|
||||
@@ -713,7 +692,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
createEffect(() => {
|
||||
route()
|
||||
globalSDK.url
|
||||
serverSDK.url
|
||||
|
||||
prefetchToken.value += 1
|
||||
clearSessionPrefetchInflight()
|
||||
@@ -760,13 +739,13 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
async function prefetchMessages(directory: string, sessionID: string, token: number) {
|
||||
const [store, setStore] = globalSync.child(directory, { bootstrap: false })
|
||||
const [store, setStore] = serverSync.child(directory, { bootstrap: false })
|
||||
|
||||
return runSessionPrefetch({
|
||||
directory,
|
||||
sessionID,
|
||||
task: (rev) =>
|
||||
retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
||||
retry(() => serverSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
||||
.then((messages) => {
|
||||
if (prefetchToken.value !== token) return
|
||||
if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return
|
||||
@@ -786,7 +765,7 @@ export default function Layout(props: ParentProps) {
|
||||
if (stale.length > 0) {
|
||||
clearSessionPrefetch(directory, stale)
|
||||
for (const id of stale) {
|
||||
globalSync.todo.set(id, undefined)
|
||||
serverSync.todo.set(id, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -851,7 +830,7 @@ export default function Layout(props: ParentProps) {
|
||||
const directory = session.directory
|
||||
if (!directory) return
|
||||
|
||||
const [store] = globalSync.child(directory, { bootstrap: false })
|
||||
const [store] = serverSync.child(directory, { bootstrap: false })
|
||||
const cached = untrack(() => {
|
||||
const info = getSessionPrefetch(directory, session.id)
|
||||
return shouldSkipSessionPrefetch({
|
||||
@@ -956,7 +935,7 @@ export default function Layout(props: ParentProps) {
|
||||
if (!target) return
|
||||
|
||||
// warm up child store to prevent flicker
|
||||
globalSync.child(target.worktree)
|
||||
serverSync.child(target.worktree)
|
||||
void openProject(target.worktree)
|
||||
}
|
||||
|
||||
@@ -965,7 +944,7 @@ export default function Layout(props: ParentProps) {
|
||||
const target = projects[index]
|
||||
if (!target) return
|
||||
|
||||
globalSync.child(target.worktree)
|
||||
serverSync.child(target.worktree)
|
||||
void openProject(target.worktree)
|
||||
}
|
||||
|
||||
@@ -994,12 +973,12 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
async function archiveSession(session: Session) {
|
||||
const [store, setStore] = globalSync.child(session.directory)
|
||||
const [store, setStore] = serverSync.child(session.directory)
|
||||
const sessions = store.session ?? []
|
||||
const index = sessions.findIndex((s) => s.id === session.id)
|
||||
const nextSession = sessions[index + 1] ?? sessions[index - 1]
|
||||
|
||||
await globalSDK.client.session.update({
|
||||
await serverSDK.client.session.update({
|
||||
directory: session.directory,
|
||||
sessionID: session.id,
|
||||
time: { archived: Date.now() },
|
||||
@@ -1049,19 +1028,6 @@ export default function Layout(props: ParentProps) {
|
||||
keybind: "mod+alt+arrowdown",
|
||||
onSelect: () => navigateProjectByOffset(1),
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => {
|
||||
const index = i
|
||||
const number = index + 1
|
||||
return {
|
||||
id: `project.${number}`,
|
||||
category: language.t("command.category.project"),
|
||||
title: `Open Project {number}`,
|
||||
keybind: `mod+${number}`,
|
||||
disabled: layout.projects.list().length <= index,
|
||||
hidden: true,
|
||||
onSelect: () => navigateToProjectIndex(index),
|
||||
}
|
||||
}),
|
||||
{
|
||||
id: "provider.connect",
|
||||
title: language.t("command.provider.connect"),
|
||||
@@ -1081,6 +1047,18 @@ export default function Layout(props: ParentProps) {
|
||||
keybind: "mod+comma",
|
||||
onSelect: () => openSettings(),
|
||||
},
|
||||
...(platform.platform === "desktop" && platform.exportDebugLogs
|
||||
? [
|
||||
{
|
||||
id: "logs.export",
|
||||
title: "Export logs",
|
||||
category: language.t("command.category.settings"),
|
||||
onSelect: () => {
|
||||
void platform.exportDebugLogs?.()
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "session.previous",
|
||||
title: language.t("command.session.previous"),
|
||||
@@ -1164,6 +1142,21 @@ export default function Layout(props: ParentProps) {
|
||||
},
|
||||
]
|
||||
|
||||
if (!newDesign())
|
||||
Array.from({ length: 9 }, (_, i) => {
|
||||
const index = i
|
||||
const number = index + 1
|
||||
commands.push({
|
||||
id: `project.${number}`,
|
||||
category: language.t("command.category.project"),
|
||||
title: `Open Project {number}`,
|
||||
keybind: `mod+${number}`,
|
||||
disabled: layout.projects.list().length <= index,
|
||||
hidden: true,
|
||||
onSelect: () => navigateToProjectIndex(index),
|
||||
})
|
||||
})
|
||||
|
||||
for (const [id] of availableThemeEntries()) {
|
||||
commands.push({
|
||||
id: `theme.set.${id}`,
|
||||
@@ -1229,7 +1222,7 @@ export default function Layout(props: ParentProps) {
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-select-server").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSelectServer />)
|
||||
dialog.show(() => <x.DialogSelectServer onNavigateHome={() => navigate("/")} />)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1253,11 +1246,11 @@ export default function Layout(props: ParentProps) {
|
||||
)
|
||||
if (known) return known[0]
|
||||
|
||||
const [child] = globalSync.child(directory, { bootstrap: false })
|
||||
const [child] = serverSync.child(directory, { bootstrap: false })
|
||||
const id = child.project
|
||||
if (!id) return directory
|
||||
|
||||
const meta = globalSync.data.project.find((item) => item.id === id)
|
||||
const meta = serverSync.data.project.find((item) => item.id === id)
|
||||
return meta?.worktree ?? directory
|
||||
}
|
||||
|
||||
@@ -1305,7 +1298,7 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
const refreshDirs = async (target?: string) => {
|
||||
if (!target || target === root || canOpen(target)) return canOpen(target)
|
||||
const listed = await globalSDK.client.worktree
|
||||
const listed = await serverSDK.client.worktree
|
||||
.list({ directory: root })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [] as string[])
|
||||
@@ -1314,13 +1307,13 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
const openSession = async (target: { directory: string; id: string }) => {
|
||||
if (!canOpen(target.directory)) return false
|
||||
const [data] = globalSync.child(target.directory, { bootstrap: false })
|
||||
const [data] = serverSync.child(target.directory, { bootstrap: false })
|
||||
if (data.session.some((item) => item.id === target.id)) {
|
||||
setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
|
||||
navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
|
||||
return true
|
||||
}
|
||||
const resolved = await globalSDK.client.session
|
||||
const resolved = await serverSDK.client.session
|
||||
.get({ sessionID: target.id })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
@@ -1340,7 +1333,7 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
const latest = latestRootSession(
|
||||
dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]),
|
||||
dirs.map((item) => serverSync.child(item, { bootstrap: false })[0]),
|
||||
Date.now(),
|
||||
)
|
||||
if (latest && (await openSession(latest))) {
|
||||
@@ -1351,7 +1344,7 @@ export default function Layout(props: ParentProps) {
|
||||
await Promise.all(
|
||||
dirs.map(async (item) => ({
|
||||
path: { directory: item },
|
||||
session: await globalSDK.client.session
|
||||
session: await serverSDK.client.session
|
||||
.list({ directory: item })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => []),
|
||||
@@ -1412,11 +1405,11 @@ export default function Layout(props: ParentProps) {
|
||||
const name = next === getFilename(project.worktree) ? "" : next
|
||||
|
||||
if (project.id && project.id !== "global") {
|
||||
await globalSDK.client.project.update({ projectID: project.id, directory: project.worktree, name })
|
||||
await serverSDK.client.project.update({ projectID: project.id, directory: project.worktree, name })
|
||||
return
|
||||
}
|
||||
|
||||
globalSync.project.meta(project.worktree, { name })
|
||||
serverSync.project.meta(project.worktree, { name })
|
||||
}
|
||||
|
||||
const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
|
||||
@@ -1482,7 +1475,7 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal() && !useWebDirectoryPicker()) {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
@@ -1513,7 +1506,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
setBusy(directory, true)
|
||||
|
||||
const result = await globalSDK.client.worktree
|
||||
const result = await serverSDK.client.worktree
|
||||
.remove({ directory: root, worktreeRemoveInput: { directory } })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
@@ -1532,7 +1525,7 @@ export default function Layout(props: ParentProps) {
|
||||
clearLastProjectSession(root)
|
||||
}
|
||||
|
||||
globalSync.set(
|
||||
serverSync.set(
|
||||
"project",
|
||||
produce((draft) => {
|
||||
const project = draft.find((item) => item.worktree === root)
|
||||
@@ -1571,7 +1564,7 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
const dismiss = () => toaster.dismiss(progress)
|
||||
|
||||
const sessions: Session[] = await globalSDK.client.session
|
||||
const sessions: Session[] = await serverSDK.client.session
|
||||
.list({ directory })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
@@ -1582,9 +1575,9 @@ export default function Layout(props: ParentProps) {
|
||||
platform,
|
||||
getTerminalServerScope(server.current, server.key),
|
||||
)
|
||||
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
|
||||
await serverSDK.client.instance.dispose({ directory }).catch(() => undefined)
|
||||
|
||||
const result = await globalSDK.client.worktree
|
||||
const result = await serverSDK.client.worktree
|
||||
.reset({ directory: root, worktreeResetInput: { directory } })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
@@ -1606,7 +1599,7 @@ export default function Layout(props: ParentProps) {
|
||||
sessions
|
||||
.filter((session) => session.time.archived === undefined)
|
||||
.map((session) =>
|
||||
globalSDK.client.session
|
||||
serverSDK.client.session
|
||||
.update({
|
||||
sessionID: session.id,
|
||||
directory: session.directory,
|
||||
@@ -1647,7 +1640,7 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
globalSDK.client.file
|
||||
serverSDK.client.file
|
||||
.status({ directory: props.directory })
|
||||
.then((x) => {
|
||||
const files = x.data ?? []
|
||||
@@ -1706,7 +1699,7 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
|
||||
const refresh = async () => {
|
||||
const sessions = await globalSDK.client.session
|
||||
const sessions = await serverSDK.client.session
|
||||
.list({ directory: props.directory })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
@@ -1715,7 +1708,7 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
globalSDK.client.file
|
||||
serverSDK.client.file
|
||||
.status({ directory: props.directory })
|
||||
.then((x) => {
|
||||
const files = x.data ?? []
|
||||
@@ -1826,8 +1819,10 @@ export default function Layout(props: ParentProps) {
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
|
||||
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
|
||||
document.documentElement.style.setProperty(
|
||||
"--dialog-left-margin",
|
||||
newDesign() ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
|
||||
)
|
||||
})
|
||||
|
||||
const side = createMemo(() => Math.max(layout.sidebar.width(), 244))
|
||||
@@ -1847,7 +1842,7 @@ export default function Layout(props: ParentProps) {
|
||||
const next = new Set(dirs)
|
||||
for (const directory of next) {
|
||||
if (loadedSessionDirs.has(directory)) continue
|
||||
void globalSync.project.loadSessions(directory)
|
||||
void serverSync.project.loadSessions(directory)
|
||||
}
|
||||
|
||||
loadedSessionDirs.clear()
|
||||
@@ -1944,7 +1939,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const createWorkspace = async (project: LocalProject) => {
|
||||
clearSidebarHoverState()
|
||||
const created = await globalSDK.client.worktree
|
||||
const created = await serverSDK.client.worktree
|
||||
.create({ directory: project.worktree })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
@@ -1978,7 +1973,7 @@ export default function Layout(props: ParentProps) {
|
||||
return [created.directory, ...next]
|
||||
})
|
||||
|
||||
globalSync.child(created.directory)
|
||||
serverSync.child(created.directory)
|
||||
navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`)
|
||||
}
|
||||
|
||||
@@ -2083,7 +2078,7 @@ export default function Layout(props: ParentProps) {
|
||||
if (!item) return false
|
||||
return item.vcs === "git" || layout.sidebar.workspaces(item.worktree)()
|
||||
})
|
||||
const homedir = createMemo(() => globalSync.data.path.home)
|
||||
const homedir = createMemo(() => serverSync.data.path.home)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -2310,7 +2305,7 @@ export default function Layout(props: ParentProps) {
|
||||
<div
|
||||
class="shrink-0 px-3 py-3"
|
||||
classList={{
|
||||
hidden: store.gettingStartedDismissed || !(providers.all().length > 0 && providers.paid().length === 0),
|
||||
hidden: store.gettingStartedDismissed || !(providers.all().size > 0 && providers.paid().length === 0),
|
||||
}}
|
||||
>
|
||||
<div class="rounded-xl bg-background-base shadow-xs-border-base" data-component="getting-started">
|
||||
@@ -2369,153 +2364,212 @@ export default function Layout(props: ParentProps) {
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
{autoselecting() ?? ""}
|
||||
<Titlebar />
|
||||
<div class="flex-1 min-h-0 min-w-0 flex">
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<div class="size-full relative overflow-x-hidden">
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-desktop"
|
||||
classList={{
|
||||
"hidden xl:block": true,
|
||||
"absolute inset-y-0 left-0": true,
|
||||
"z-10": true,
|
||||
}}
|
||||
style={{ width: `${side()}px` }}
|
||||
ref={(el) => {
|
||||
setState("nav", el)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
aim.reset()
|
||||
if (!sidebarHovering()) return
|
||||
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
|
||||
</nav>
|
||||
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div
|
||||
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
|
||||
style={{ left: `${side()}px` }}
|
||||
onPointerDown={() => setState("sizing", true)}
|
||||
>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
onResize={(w) => {
|
||||
setState("sizing", true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setState("sizing", false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Show
|
||||
when={!newDesign()}
|
||||
fallback={
|
||||
<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">
|
||||
{autoselecting() ?? ""}
|
||||
<Titlebar update={titlebarUpdate} />
|
||||
<main
|
||||
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"m-2 mt-0 rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden": !!params.id || !params.dir,
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
|
||||
<div
|
||||
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||
style={{ left: "calc(4rem + 12px)" }}
|
||||
/>
|
||||
|
||||
<div class="xl:hidden">
|
||||
<div
|
||||
classList={{
|
||||
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
|
||||
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
|
||||
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
<Toast.Region />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
{autoselecting() ?? ""}
|
||||
<Titlebar update={titlebarUpdate} />
|
||||
<Show when={updateVersion() !== undefined}>
|
||||
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 min-w-0 flex">
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<div class="size-full relative overflow-x-hidden">
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-mobile"
|
||||
data-component="sidebar-nav-desktop"
|
||||
classList={{
|
||||
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
||||
"translate-x-0": layout.mobileSidebar.opened(),
|
||||
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||
"hidden xl:block": true,
|
||||
"absolute inset-y-0 left-0": true,
|
||||
"z-10": true,
|
||||
}}
|
||||
style={{ width: `${side()}px` }}
|
||||
ref={(el) => {
|
||||
setState("nav", el)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
aim.reset()
|
||||
if (!sidebarHovering()) return
|
||||
|
||||
arm()
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sidebarContent(true)}
|
||||
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"absolute inset-0": true,
|
||||
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
|
||||
"z-20": true,
|
||||
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
|
||||
!state.sizing,
|
||||
}}
|
||||
style={{
|
||||
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
|
||||
}}
|
||||
>
|
||||
<main
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div
|
||||
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
|
||||
style={{ left: `${side()}px` }}
|
||||
onPointerDown={() => setState("sizing", true)}
|
||||
>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
onResize={(w) => {
|
||||
setState("sizing", true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setState("sizing", false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||
style={{ left: "calc(4rem + 12px)" }}
|
||||
/>
|
||||
|
||||
<div class="xl:hidden">
|
||||
<div
|
||||
classList={{
|
||||
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
|
||||
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
|
||||
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
|
||||
}}
|
||||
/>
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-mobile"
|
||||
classList={{
|
||||
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
||||
"translate-x-0": layout.mobileSidebar.opened(),
|
||||
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sidebarContent(true)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||
"absolute inset-0": true,
|
||||
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
|
||||
"z-20": true,
|
||||
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
|
||||
!state.sizing,
|
||||
}}
|
||||
style={{
|
||||
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
<main
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
|
||||
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
onMouseMove={disarm}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
aim.reset()
|
||||
}}
|
||||
onPointerDown={disarm}
|
||||
onMouseLeave={() => {
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<Show when={peekProject()}>
|
||||
<SidebarPanel project={peekProject} merged={false} />
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
|
||||
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
onMouseMove={disarm}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
aim.reset()
|
||||
}}
|
||||
onPointerDown={disarm}
|
||||
onMouseLeave={() => {
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<Show when={peekProject()}>
|
||||
<SidebarPanel project={peekProject} merged={false} />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
|
||||
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
style={{ left: `calc(4rem + ${panel()}px)` }}
|
||||
>
|
||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
|
||||
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
style={{ left: `calc(4rem + ${panel()}px)` }}
|
||||
>
|
||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
</div>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
<Toast.Region />
|
||||
</div>
|
||||
<Toast.Region />
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function UpdateAvailableToast(props: {
|
||||
version: string
|
||||
install: () => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
let toastId: number | undefined
|
||||
|
||||
onMount(() => {
|
||||
toastId = showToast({
|
||||
persistent: true,
|
||||
icon: "download",
|
||||
title: props.language.t("toast.update.title"),
|
||||
description: props.language.t("toast.update.description", { version: props.version }),
|
||||
actions: [
|
||||
{
|
||||
label: props.language.t("toast.update.action.installRestart"),
|
||||
onClick: props.install,
|
||||
},
|
||||
{
|
||||
label: props.language.t("toast.update.action.notYet"),
|
||||
onClick: "dismiss",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (toastId === undefined) return
|
||||
toaster.dismiss(toastId)
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -55,6 +55,29 @@ export const childSessionOnPath = (sessions: Session[] | undefined, rootID: stri
|
||||
export const displayName = (project: { name?: string; worktree: string }) =>
|
||||
project.name || getFilename(project.worktree)
|
||||
|
||||
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
||||
|
||||
export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
|
||||
if (id === OPENCODE_PROJECT_ID) return "https://opencode.ai/favicon.svg"
|
||||
if (icon?.override) return icon.override
|
||||
if (icon?.color) return undefined
|
||||
return icon?.url
|
||||
}
|
||||
|
||||
export function projectForSession<T extends { id?: string; worktree: string; sandboxes?: string[] }>(
|
||||
session: Session,
|
||||
projects: T[],
|
||||
byID: Map<string, T>,
|
||||
) {
|
||||
const direct = byID.get(session.projectID)
|
||||
if (direct) return direct
|
||||
const directory = pathKey(session.directory)
|
||||
return projects.find(
|
||||
(project) =>
|
||||
pathKey(project.worktree) === directory || project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
||||
)
|
||||
}
|
||||
|
||||
export const errorMessage = (err: unknown, fallback: string) => {
|
||||
if (err && typeof err === "object" && "data" in err) {
|
||||
const data = (err as { data?: { message?: string } }).data
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 { useGlobalSync } from "@/context/global-sync"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
||||
import { useNotification } from "@/context/notification"
|
||||
@@ -15,16 +15,7 @@ import { usePermission } from "@/context/permission"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
||||
import { childSessionOnPath, hasProjectPermissions } from "./helpers"
|
||||
|
||||
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
||||
|
||||
export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
|
||||
if (id === OPENCODE_PROJECT_ID) return "https://opencode.ai/favicon.svg"
|
||||
if (icon?.override) return icon?.override
|
||||
if (icon?.color) return undefined
|
||||
return icon?.url
|
||||
}
|
||||
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
|
||||
|
||||
export const ProjectIcon = (props: {
|
||||
project: LocalProject
|
||||
@@ -32,7 +23,7 @@ export const ProjectIcon = (props: {
|
||||
notify?: boolean
|
||||
working?: boolean
|
||||
}): JSX.Element => {
|
||||
const globalSync = useGlobalSync()
|
||||
const serverSync = useServerSync()
|
||||
const notification = useNotification()
|
||||
const permission = usePermission()
|
||||
const dirs = createMemo(() => [props.project.worktree, ...(props.project.sandboxes ?? [])])
|
||||
@@ -42,7 +33,7 @@ export const ProjectIcon = (props: {
|
||||
const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory)))
|
||||
const hasPermissions = createMemo(() =>
|
||||
dirs().some((directory) => {
|
||||
const [store] = globalSync.child(directory, { bootstrap: false })
|
||||
const [store] = serverSync.child(directory, { bootstrap: false })
|
||||
return hasProjectPermissions(store.permission, (item) => !permission.autoResponds(item, directory))
|
||||
}),
|
||||
)
|
||||
@@ -155,10 +146,10 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
const language = useLanguage()
|
||||
const notification = useNotification()
|
||||
const permission = usePermission()
|
||||
const globalSync = useGlobalSync()
|
||||
const serverSync = useServerSync()
|
||||
const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
|
||||
const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
|
||||
const [sessionStore] = globalSync.child(props.session.directory)
|
||||
const [sessionStore] = serverSync.child(props.session.directory)
|
||||
const hasPermissions = createMemo(() => {
|
||||
return !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.session.id, (item) => {
|
||||
return !permission.autoResponds(item, props.session.directory)
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 { useGlobalSync } from "@/context/global-sync"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
|
||||
@@ -274,7 +274,7 @@ export const SortableProject = (props: {
|
||||
ctx: ProjectSidebarContext
|
||||
sortNow: Accessor<number>
|
||||
}): JSX.Element => {
|
||||
const globalSync = useGlobalSync()
|
||||
const serverSync = useServerSync()
|
||||
const language = useLanguage()
|
||||
const sortable = createSortable(props.project.worktree)
|
||||
const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree)
|
||||
@@ -294,23 +294,23 @@ export const SortableProject = (props: {
|
||||
const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
|
||||
|
||||
const label = (directory: string) => {
|
||||
const [data] = globalSync.child(directory, { bootstrap: false })
|
||||
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(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
|
||||
const projectStore = createMemo(() => serverSync.child(props.project.worktree, { bootstrap: false })[0])
|
||||
const isWorking = createMemo(() =>
|
||||
dirs().some((directory) => {
|
||||
const [store] = globalSync.child(directory, { bootstrap: false })
|
||||
const [store] = serverSync.child(directory, { bootstrap: false })
|
||||
return Object.keys(store.session_status).some((id) => store.session_working(id))
|
||||
}),
|
||||
)
|
||||
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
|
||||
const workspaceSessions = (directory: string) => {
|
||||
const [data] = globalSync.child(directory, { bootstrap: false })
|
||||
const [data] = serverSync.child(directory, { bootstrap: false })
|
||||
return sortedRootSessions(data, props.sortNow())
|
||||
}
|
||||
const tile = () => (
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { type LocalProject } from "@/context/layout"
|
||||
import { useGlobalSync, useQueryOptions } from "@/context/global-sync"
|
||||
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"
|
||||
@@ -60,7 +60,7 @@ export const WorkspaceDragOverlay = (props: {
|
||||
activeWorkspace: Accessor<string | undefined>
|
||||
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
|
||||
}): JSX.Element => {
|
||||
const globalSync = useGlobalSync()
|
||||
const serverSync = useServerSync()
|
||||
const language = useLanguage()
|
||||
const label = createMemo(() => {
|
||||
const project = props.sidebarProject()
|
||||
@@ -68,7 +68,7 @@ export const WorkspaceDragOverlay = (props: {
|
||||
const directory = props.activeWorkspace()
|
||||
if (!directory) return
|
||||
|
||||
const [workspaceStore] = globalSync.child(directory, { bootstrap: false })
|
||||
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)
|
||||
@@ -299,11 +299,11 @@ export const SortableWorkspace = (props: {
|
||||
}): JSX.Element => {
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const globalSync = useGlobalSync()
|
||||
const serverSync = useServerSync()
|
||||
const queryOptions = useQueryOptions()
|
||||
const language = useLanguage()
|
||||
const sortable = createSortable(props.directory)
|
||||
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false })
|
||||
const [workspaceStore, setWorkspaceStore] = serverSync.child(props.directory, { bootstrap: false })
|
||||
const [menu, setMenu] = createStore({
|
||||
open: false,
|
||||
pendingRename: false,
|
||||
@@ -328,7 +328,7 @@ export const SortableWorkspace = (props: {
|
||||
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
|
||||
const loadMore = async () => {
|
||||
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
|
||||
await globalSync.project.loadSessions(props.directory)
|
||||
await serverSync.project.loadSessions(props.directory)
|
||||
}
|
||||
|
||||
const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`))
|
||||
@@ -357,7 +357,7 @@ export const SortableWorkspace = (props: {
|
||||
|
||||
createEffect(() => {
|
||||
if (!boot()) return
|
||||
globalSync.child(props.directory, { bootstrap: true })
|
||||
serverSync.child(props.directory, { bootstrap: true })
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -446,11 +446,11 @@ export const LocalWorkspace = (props: {
|
||||
sortNow: Accessor<number>
|
||||
mobile?: boolean
|
||||
}): JSX.Element => {
|
||||
const globalSync = useGlobalSync()
|
||||
const serverSync = useServerSync()
|
||||
const queryOptions = useQueryOptions()
|
||||
const language = useLanguage()
|
||||
const workspace = createMemo(() => {
|
||||
const [store, setStore] = globalSync.child(props.project.worktree)
|
||||
const [store, setStore] = serverSync.child(props.project.worktree)
|
||||
return { store, setStore }
|
||||
})
|
||||
const slug = createMemo(() => base64Encode(props.project.worktree))
|
||||
@@ -461,7 +461,7 @@ export const LocalWorkspace = (props: {
|
||||
const loading = () => fetching() > 0 && count() === 0
|
||||
const loadMore = async () => {
|
||||
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
|
||||
await globalSync.project.loadSessions(props.project.worktree)
|
||||
await serverSync.project.loadSessions(props.project.worktree)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user