Compare commits

..

5 Commits

Author SHA1 Message Date
Brendan Allan 723176ae15 Merge branch 'dev' into new-session-controller 2026-07-28 14:53:15 +08:00
Brendan Allan 979bb65c25 refactor(app): simplify workspace selection state 2026-07-28 14:49:35 +08:00
Brendan Allan 6c2c3c92ab refactor(app): separate new session commands 2026-07-28 14:46:11 +08:00
Brendan Allan 5e2e4a1da7 refactor(app): simplify new session view 2026-07-28 14:42:36 +08:00
Brendan Allan fbdcffb352 refactor(app): thin new session composition 2026-07-28 12:18:47 +08:00
275 changed files with 2430 additions and 9475 deletions
+6 -1
View File
@@ -219,6 +219,7 @@ jobs:
build-electron:
needs:
- build-cli
- version
if: github.repository == 'anomalyco/opencode'
continue-on-error: false
@@ -315,7 +316,10 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
- name: Build
run: bun run build
@@ -339,7 +343,8 @@ jobs:
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
GH_TOKEN: ${{ steps.committer.outputs.token }}
CSC_KEYCHAIN: build.keychain
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
+27
View File
@@ -252,6 +252,33 @@ These are not strictly enforced, they are just general guidelines:
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly.
## Trust & Vouch System
This project uses [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. The vouch list is maintained in [`.github/VOUCHED.td`](.github/VOUCHED.td).
### How it works
- **Vouched users** are explicitly trusted contributors.
- **Denounced users** are explicitly blocked. Issues and pull requests from denounced users are automatically closed. If you have been denounced, you can request to be unvouched by reaching out to a maintainer on [Discord](https://opencode.ai/discord)
- **Everyone else** can participate normally — you don't need to be vouched to open issues or PRs.
### For maintainers
Collaborators with write access can manage the vouch list by commenting on any issue:
- `vouch` — vouch for the issue author
- `vouch @username` — vouch for a specific user
- `denounce` — denounce the issue author
- `denounce @username` — denounce a specific user
- `denounce @username <reason>` — denounce with a reason
- `unvouch` / `unvouch @username` — remove someone from the list
Changes are committed automatically to `.github/VOUCHED.td`.
### Denouncement policy
Denouncement is reserved for users who repeatedly submit low-quality AI-generated contributions, spam, or otherwise act in bad faith. It is not used for disagreements or honest mistakes.
## Issue Requirements
All issues **must** use one of our issue templates:
+53 -104
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -96,7 +96,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.18.12",
"version": "1.18.8",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -144,7 +144,7 @@
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -158,7 +158,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -194,7 +194,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -221,7 +221,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -243,7 +243,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -267,7 +267,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -287,7 +287,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.18.12",
"version": "1.18.8",
"bin": {
"opencode": "./bin/opencode",
},
@@ -339,7 +339,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.12.1",
"gitlab-ai-provider": "6.12.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -381,16 +381,16 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron-context-menu": "4.1.2",
"electron-log": "^5",
"electron-store": "11.0.2",
"electron-updater": "6.8.9",
"electron-window-state": "^5.0.3",
"marked": "^15",
},
"devDependencies": {
"@actions/artifact": "4.0.0",
@@ -435,7 +435,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -449,7 +449,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"effect": "catalog:",
},
@@ -461,7 +461,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -493,7 +493,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -509,7 +509,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83",
@@ -540,7 +540,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
@@ -559,7 +559,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.18.12",
"version": "1.18.8",
"bin": {
"opencode": "./bin/opencode",
},
@@ -592,7 +592,7 @@
"@effect/platform-node": "catalog:",
"@ff-labs/fff-bun": "0.9.4",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@modelcontextprotocol/sdk": "1.29.0",
"@modelcontextprotocol/client": "2.0.0-beta.5",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -632,7 +632,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.12.1",
"gitlab-ai-provider": "6.12.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -666,6 +666,7 @@
},
"devDependencies": {
"@babel/core": "7.28.4",
"@modelcontextprotocol/server": "2.0.0-beta.5",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/http-recorder": "workspace:*",
@@ -690,7 +691,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*",
@@ -766,7 +767,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -781,7 +782,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@@ -796,7 +797,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
@@ -811,6 +812,7 @@
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"diff": "catalog:",
"dompurify": "3.3.1",
"fuzzysort": "catalog:",
@@ -840,7 +842,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -853,7 +855,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -887,7 +889,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -906,7 +908,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -948,7 +950,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -975,7 +977,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1001,7 +1003,6 @@
"remend": "catalog:",
"shiki": "catalog:",
"solid-list": "catalog:",
"solid-sonner": "catalog:",
"strip-ansi": "7.1.2",
},
"devDependencies": {
@@ -1027,7 +1028,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.18.12",
"version": "1.18.8",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1070,9 +1071,9 @@
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@modelcontextprotocol/client@2.0.0-beta.5": "patches/@modelcontextprotocol%2Fclient@2.0.0-beta.5.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
@@ -1082,7 +1083,6 @@
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
},
"overrides": {
"@opentui/core": "catalog:",
@@ -1146,7 +1146,6 @@
"shiki": "4.2.0",
"solid-js": "1.9.10",
"solid-list": "0.3.0",
"solid-sonner": "0.3.1",
"sst": "4.13.1",
"tailwindcss": "4.1.11",
"typescript": "5.8.2",
@@ -1676,8 +1675,6 @@
"@hey-api/types": ["@hey-api/types@0.1.2", "", {}, "sha512-uNNtiVAWL7XNrV/tFXx7GLY9lwaaDazx1173cGW3+UEaw4RUPsHEmiB4DSpcjNxMIcrctfz2sGKLnVx5PBG2RA=="],
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
"@hono/standard-validator": ["@hono/standard-validator@0.2.0", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-pFq0UVAnjzXcDAgqFpDeVL3MOUPrlIh/kPqBDvbCYoThVhhS+Vf37VcdsakdOFFGiqoiYVxp3LifXFhGhp/rgQ=="],
"@ibm/plex": ["@ibm/plex@6.4.1", "", { "dependencies": { "@ibm/telemetry-js": "^1.5.1" } }, "sha512-fnsipQywHt3zWvsnlyYKMikcVI7E2fEwpiPnIHFqlbByXVfQfANAAeJk1IV4mNnxhppUIDlhU0TzwYwL++Rn2g=="],
@@ -1832,7 +1829,11 @@
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.0.0-beta.5", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0-beta.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-YuuNm5f2TMoFQRje1UqVP8TJRjijCXMz4ckvoVpx1cUXuBEmykWQ2d8R536pek6UKcXT41T5nWc4qR1JFIbEmg=="],
"@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0-beta.5", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew=="],
"@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0-beta.5", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0-beta.5", "zod": "^4.2.0" } }, "sha512-i1E5l75rQKsgY/AKAIspgMBH1vEL7dqiK7tHr0L+raYcb0SWOziqNGJXGIG6NY4AlXDWIKGJQGB7Nqfs3oUi5g=="],
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
@@ -3392,7 +3393,7 @@
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
@@ -3404,8 +3405,6 @@
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="],
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
@@ -3710,8 +3709,6 @@
"express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="],
"express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="],
"expressive-code": ["expressive-code@0.41.7", "", { "dependencies": { "@expressive-code/core": "^0.41.7", "@expressive-code/plugin-frames": "^0.41.7", "@expressive-code/plugin-shiki": "^0.41.7", "@expressive-code/plugin-text-markers": "^0.41.7" } }, "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA=="],
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
@@ -3856,7 +3853,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.12.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Qn5iHqvjG8yktI5MWaUgdRR94l7O4WtYW0CAbhsCh1Tj0Fei/DeprOYPVyf4Nht1Ix6U2PXSYM32QOHI6Z2TDw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.12.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-uGan7MQaNfmV5SH9wiMJkZ7D5QxM4KVPRcjIHtPCM5icZjgXl5mrHMjr3/e7TAHa9OjEJKT0a2FxzPgwRtN3PQ=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -4096,8 +4093,6 @@
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
@@ -5006,8 +5001,6 @@
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
@@ -5128,8 +5121,6 @@
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
"solid-sonner": ["solid-sonner@0.3.1", "", { "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-F/+zi9yKJTHh5hX1UGJfkDvyC+F34Vi3jgy44NJwOKCgic1QtAon0b1iT9OsDO77RTgR+PCil+3Y5B8T2Owy1Q=="],
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
"solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="],
@@ -5958,15 +5949,13 @@
"@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"@modelcontextprotocol/client/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
"@modelcontextprotocol/sdk/hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="],
"@modelcontextprotocol/client/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
"@modelcontextprotocol/core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@modelcontextprotocol/server/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@npmcli/config/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
@@ -6044,6 +6033,8 @@
"@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="],
"@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
"@opencode-ai/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="],
@@ -6240,6 +6231,8 @@
"babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
"body-parser/content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
@@ -6322,6 +6315,8 @@
"execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
"express/content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@@ -6470,8 +6465,6 @@
"roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
@@ -6800,58 +6793,28 @@
"@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"@modelcontextprotocol/sdk/express/content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
"@modelcontextprotocol/sdk/express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"@modelcontextprotocol/sdk/express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"@modelcontextprotocol/sdk/express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"@modelcontextprotocol/sdk/express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"@modelcontextprotocol/sdk/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"@modelcontextprotocol/sdk/express/type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
"@octokit/auth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
"@octokit/auth-app/@octokit/request/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
"@octokit/auth-app/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@octokit/auth-app/@octokit/request-error/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
"@octokit/auth-oauth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
"@octokit/auth-oauth-app/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
"@octokit/auth-oauth-app/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@octokit/auth-oauth-app/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/auth-oauth-device/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
"@octokit/auth-oauth-device/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
"@octokit/auth-oauth-device/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@octokit/auth-oauth-device/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/auth-oauth-user/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
"@octokit/auth-oauth-user/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
"@octokit/auth-oauth-user/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@octokit/auth-oauth-user/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
@@ -6864,14 +6827,10 @@
"@octokit/graphql/@octokit/request/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
"@octokit/graphql/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@octokit/graphql/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@26.0.0", "", {}, "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA=="],
"@octokit/oauth-methods/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
"@octokit/oauth-methods/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@octokit/oauth-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/plugin-paginate-rest/@octokit/core/@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
@@ -7380,10 +7339,6 @@
"@jsx-email/cli/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@modelcontextprotocol/sdk/express/type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"@octokit/auth-app/@octokit/request-error/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/auth-app/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
@@ -7392,20 +7347,14 @@
"@octokit/plugin-paginate-rest/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
"@octokit/plugin-paginate-rest/@octokit/core/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@octokit/plugin-paginate-rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/rest/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
"@octokit/rest/@octokit/core/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+2 -6
View File
@@ -288,12 +288,8 @@ new sst.cloudflare.x.SolidStart("Console", {
server: {
placement: { region: "aws:us-east-2" },
transform: {
worker: (args) => {
args.compatibilityFlags = $resolve(args.compatibilityFlags).apply((flags) => [
...(flags ?? []),
"global_fetch_strictly_public",
])
args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }]
worker: {
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
},
},
},
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-a7NyYa9vRUEqDfZNDPXXmFO58RDEgioyuGSl5CPBvxo=",
"aarch64-linux": "sha256-l4OJtSEllHvRhktjcaJYwkXBSaJvsIoyoLusbZfYMcM=",
"aarch64-darwin": "sha256-IIl0BQGs1/HLFh0auiQjiwfSQ2nfHcK2G2BAphYW59c=",
"x86_64-darwin": "sha256-vVeuPyd4ZIRYrHouphTuEb4rkRZLKKTAHe840jNh9rU="
"x86_64-linux": "sha256-RFek0QoEEjsgbqmTE/SxQAmPtYyzs0IPR2ugFn5Okrs=",
"aarch64-linux": "sha256-BmAxapY1YrAFn7mVq3/6A9+6Au5UIvSqBboHMkyJH3I=",
"aarch64-darwin": "sha256-Sx3bGWQqLlgoa/RudJxanjSzhFRNklckT2ffnO2I5F4=",
"x86_64-darwin": "sha256-CMOhiisHNowg06qadvgg4K+60zrynglwiT0qKYQ4NiA="
}
}
+2 -4
View File
@@ -90,7 +90,6 @@
"@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0",
"solid-js": "1.9.10",
"solid-sonner": "0.3.1",
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.12"
}
@@ -144,7 +143,6 @@
"@types/node": "catalog:"
},
"patchedDependencies": {
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
@@ -156,8 +154,8 @@
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@modelcontextprotocol/client@2.0.0-beta.5": "patches/@modelcontextprotocol%2Fclient@2.0.0-beta.5.patch"
}
}
@@ -1,60 +0,0 @@
import { expect, test } from "@playwright/test"
import type { Page } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"]
const worktrees = NAMES.map((name) => `/opencode-demo/${name}`)
// The sixth project sits outside the five-item recent cap, so it is only reachable if the
// dialog hands every recent project to the list filter instead of a pre-truncated slice.
const OUTSIDE_CAP = "foxtrot-docs"
// Dialog rows carry data-directory-path; the sidebar project list does not, so this
// scopes assertions to the picker instead of matching the sidebar entry of the same name.
const rows = (page: Page) => page.locator("[data-directory-path]")
const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`)
async function openProjectDialog(page: Page) {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
fileList: () => [],
findFiles: () => [],
})
await page.addInitScript((dirs) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) },
lastProject: {},
}),
)
}, worktrees)
await page.goto("/")
const add = page.getByRole("button", { name: "Add project" }).first()
await expectAppVisible(add)
await add.click()
await expect(rows(page)).toHaveCount(5)
return page.getByRole("textbox").last()
}
test("searches every recent project, not just the five most recent", async ({ page }) => {
const search = await openProjectDialog(page)
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
await search.fill("foxtrot")
await expect(row(page, OUTSIDE_CAP)).toHaveCount(1)
})
test("still caps the idle recent list at five projects", async ({ page }) => {
await openProjectDialog(page)
await expect(row(page, NAMES[4])).toHaveCount(1)
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
})
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.18.12",
"version": "1.18.8",
"description": "",
"type": "module",
"exports": {
@@ -19,9 +19,9 @@
"build": "vite build",
"serve": "vite preview",
"test": "bun run test:unit && bun run test:browser",
"test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src",
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:local": "playwright test",
"test:e2e:ui": "playwright test --ui",
+18 -36
View File
@@ -66,7 +66,7 @@ function Cell(props: {
<div
classList={{
"flex min-w-0 items-center": true,
"min-h-[20px] w-fit justify-start px-1.5 py-0.5 text-left": !!props.inline,
"min-h-[20px] w-fit flex-row justify-start gap-1.5 px-1.5 py-0.5 text-left": !!props.inline,
"justify-center text-center": !props.inline,
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
"col-span-2": !!props.wide && !props.inline,
@@ -74,29 +74,21 @@ function Cell(props: {
>
<div
classList={{
"flex min-w-0": true,
"-translate-y-px items-baseline gap-1.5": !!props.inline,
"flex-col items-center": !props.inline,
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
}}
>
<div
classList={{
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
}}
>
{props.label}
</div>
<div
classList={{
"uppercase leading-none font-bold tabular-nums": true,
"text-[11px]": !!props.inline,
"text-[13px] sm:text-[14px]": !props.inline,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
>
{props.value}
</div>
{props.label}
</div>
<div
classList={{
"uppercase leading-none font-bold tabular-nums": true,
"text-[11px]": !!props.inline,
"text-[13px] sm:text-[14px]": !props.inline,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
>
{props.value}
</div>
</div>
)
@@ -124,25 +116,15 @@ function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => vo
aria-pressed={props.active}
classList={{
"flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
"min-h-[20px] w-fit justify-start rounded px-1.5 py-0.5 text-left": !!props.inline,
"min-h-[20px] w-fit flex-row justify-start gap-1.5 rounded px-1.5 py-0.5 text-left": !!props.inline,
"min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline,
"bg-surface-raised-base text-text-strong": props.active,
}}
onClick={props.onClick}
>
<span
classList={{
flex: true,
"-translate-y-px items-baseline gap-1.5": !!props.inline,
"flex-col items-center": !props.inline,
}}
>
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span>
<span
classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}
>
{props.active ? "ON" : "OFF"}
</span>
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span>
<span classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}>
{props.active ? "ON" : "OFF"}
</span>
</button>
)
@@ -29,7 +29,7 @@ import {
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useParams } from "@solidjs/router"
import { ExternalLink } from "@/components/external-link"
import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
@@ -836,12 +836,12 @@ function ProviderConnection(props: {
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
<div>
{language.t("provider.connect.opencodeZen.visit.prefix")}
<ExternalLink
<Link
href="https://opencode.ai/zen"
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
>
{language.t("provider.connect.opencodeZen.visit.link")}
</ExternalLink>
</Link>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
@@ -886,9 +886,9 @@ function ProviderConnection(props: {
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.opencodeZen.visit.prefix")}
<ExternalLink href="https://opencode.ai/zen" tabIndex={-1}>
<Link href="https://opencode.ai/zen" tabIndex={-1}>
{language.t("provider.connect.opencodeZen.visit.link")}
</ExternalLink>
</Link>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
@@ -967,9 +967,9 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>
{language.t("provider.connect.oauth.code.visit.prefix")}
<ExternalLink href={store.authorization!.url} class="text-v2-text-text-base">
<Link href={store.authorization!.url} class="text-v2-text-text-base">
{language.t("provider.connect.oauth.code.visit.link")}
</ExternalLink>
</Link>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
@@ -1006,9 +1006,7 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.code.visit.prefix")}
<ExternalLink href={store.authorization!.url}>
{language.t("provider.connect.oauth.code.visit.link")}
</ExternalLink>
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
@@ -1079,9 +1077,7 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")}
<ExternalLink href={store.authorization!.url}>
{language.t("provider.connect.oauth.auto.visit.link")}
</ExternalLink>
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
</div>
<TextField
@@ -8,7 +8,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast"
import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { ExternalLink } from "@/components/external-link"
import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
@@ -185,9 +185,9 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
<p class="text-14-regular text-text-base">
{language.t("provider.custom.description.prefix")}
<ExternalLink href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
{language.t("provider.custom.description.link")}
</ExternalLink>
</Link>
{language.t("provider.custom.description.suffix")}
</p>
@@ -18,8 +18,6 @@ interface DialogSelectDirectoryProps {
server: ServerConnection.Any
}
const RECENT_PROJECT_LIMIT = 5
type Row = {
absolute: string
search: string
@@ -59,9 +57,9 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const [filter, setFilter] = createSignal("")
let list: ListRef | undefined
const missingHome = createMemo(() => !sync.data.path.home)
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource(
() => (missingHome() ? true : undefined),
() => (missingBase() ? true : undefined),
async (): Promise<Path | undefined> => {
if ((await sdk.protocol) !== "v1") return
return sdk.client.path
@@ -104,6 +102,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
return projects
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
.sort((a, b) => b.at - a.at || a.index - b.index)
.slice(0, 5)
.map(({ project }) => {
const row = toRow(project.worktree, home(), "recent")
const name = project.name || getFilename(project.worktree)
@@ -117,10 +116,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const items = async (value: string) => {
const results = await directories(value)
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
// Cap the idle list only. Once a query narrows the results, every project stays searchable.
const recent = recentProjects()
const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT)
return uniqueRows([...visible, ...directoryRows])
return uniqueRows([...recentProjects(), ...directoryRows])
}
function resolve(absolute: string) {
@@ -17,7 +17,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) {
const platform = usePlatform()
const runAction = () => {
if (props.link) platform.openExternal(props.link)
if (props.link) platform.openLink(props.link)
props.onClose?.()
dialog.close()
}
@@ -1,21 +0,0 @@
import { ComponentProps, splitProps } from "solid-js"
export interface ExternalLinkProps extends Omit<ComponentProps<"a">, "href"> {
href: string
}
export function ExternalLink(props: ExternalLinkProps) {
const [local, rest] = splitProps(props, ["href", "children", "class", "target", "rel"])
return (
<a
href={local.href}
class={`text-text-strong underline ${local.class ?? ""}`}
target={local.target ?? "_blank"}
rel={local.rel ?? "noopener noreferrer"}
{...rest}
>
{local.children}
</a>
)
}
+26
View File
@@ -0,0 +1,26 @@
import { ComponentProps, splitProps } from "solid-js"
import { usePlatform } from "@/context/platform"
export interface LinkProps extends Omit<ComponentProps<"a">, "href"> {
href: string
}
export function Link(props: LinkProps) {
const platform = usePlatform()
const [local, rest] = splitProps(props, ["href", "children", "class"])
return (
<a
href={local.href}
class={`text-text-strong underline ${local.class ?? ""}`}
onClick={(event) => {
if (!local.href) return
event.preventDefault()
platform.openLink(local.href)
}}
{...rest}
>
{local.children}
</a>
)
}
@@ -55,7 +55,6 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
controller={props.controller}
borderUnderlay={props.borderUnderlay}
class={props.class}
variantControlVisible={!props.controller.model.loading}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
modelControl={
@@ -344,7 +343,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
if (item?.commentID) comments.remove(item.path, item.commentID)
},
openAttachment: (attachment) =>
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />),
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />),
openContext(key) {
const item = controller.contextItem(key)
if (item) openComment(item, props, sync, layout, files, comments)
@@ -368,7 +367,6 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
}),
duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
onError: (error) =>
showToast({
variant: "error",
@@ -377,7 +375,6 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
}),
readClipboardImage: platform.readClipboardImage,
getPathForFile: platform.getPathForFile,
store: platform.draftStore?.putBlob,
},
view: {
placeholder: designPlaceholder,
+1 -1
View File
@@ -1489,7 +1489,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<PromptImageAttachments
attachments={imageAttachments()}
onOpen={(attachment) =>
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />)
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
@@ -3,13 +3,28 @@ import { makeEventListener } from "@solid-primitives/event-listener"
import { showToast } from "@/utils/toast"
import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { uuid } from "@/utils/uuid"
import { getCursorPosition } from "./editor-dom"
import { createBlobReference, type DraftStore } from "@/utils/draft-store"
import { attachmentMime } from "./files"
import { normalizePaste, pasteMode } from "./paste"
function dataUrl(file: File, mime: string) {
return new Promise<string>((resolve) => {
const reader = new FileReader()
reader.addEventListener("error", () => resolve(""))
reader.addEventListener("load", () => {
const value = typeof reader.result === "string" ? reader.result : ""
const idx = value.indexOf(",")
if (idx === -1) {
resolve(value)
return
}
resolve(`data:${mime};base64,${value.slice(idx + 1)}`)
})
reader.readAsDataURL(file)
})
}
type PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
@@ -21,7 +36,6 @@ type PromptAttachmentsCoreInput = {
warn?: () => void
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
draftStore?: DraftStore
}
export type PromptAttachmentsInput = {
@@ -51,13 +65,16 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
return false
}
const url = await dataUrl(file, mime)
if (!url) return false
const attachment: ImageAttachmentPart = {
type: "image",
id: uuid(),
filename: file.name,
sourcePath: input.getPathForFile?.(file) || undefined,
mime,
blob: input.draftStore ? await input.draftStore.putBlob(file) : await createBlobReference(file),
dataUrl: url,
}
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
return true
@@ -149,10 +166,8 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
export function createPromptAttachments(input: PromptAttachmentsInput) {
const language = useLanguage()
const platform = usePlatform()
const attachments = createPromptAttachmentsCore({
...input,
draftStore: platform.draftStore,
capture: input.prompt.capture,
warn: () => {
showToast({
@@ -22,7 +22,7 @@ type ContextFile = {
type BuildRequestPartsInput = {
prompt: Prompt
context: ContextFile[]
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
images: ImageAttachmentPart[]
text: string
messageID: string
sessionID: string
@@ -1,13 +1,7 @@
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import type { Prompt } from "@/context/prompt"
import { Persist, persisted } from "@/utils/persist"
import {
clonePromptHistoryComments,
clonePromptParts,
prependHistoryEntry,
type PromptHistoryComment,
type PromptHistoryStoredEntry,
} from "./history"
import { prependHistoryEntry, type PromptHistoryComment, type PromptHistoryStoredEntry } from "./history"
export type PromptInputHistory = {
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
@@ -41,23 +35,13 @@ export function createPromptInputHistory(): PromptInputHistory {
}
export function createPersistedPromptInputHistory() {
const [normal, setNormal, normalInit] = persisted(
Persist.prompt(Persist.global("prompt-history", ["prompt-history.v1"])),
const [normal, setNormal] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<PromptHistoryState>({ entries: [] }),
)
const [shell, setShell, shellInit] = persisted(
Persist.prompt(Persist.global("prompt-history-shell", ["prompt-history-shell.v1"])),
const [shell, setShell] = persisted(
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
createStore<PromptHistoryState>({ entries: [] }),
)
const history = createPromptInputHistoryStore(normal, setNormal, shell, setShell)
return {
...history,
add(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
const ready = mode === "shell" ? shellInit : normalInit
if (!(ready instanceof Promise)) return history.add(prompt, mode, comments)
const saved = clonePromptParts(prompt)
const metadata = clonePromptHistoryComments(comments)
void ready.then(() => history.add(saved, mode, metadata))
},
}
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
}
@@ -112,7 +112,7 @@ describe("prompt-input history", () => {
end: 12,
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
},
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
{ type: "image", id: "1", filename: "img.png", mime: "image/png", dataUrl: "data:image/png;base64,abc" },
]
const copy = clonePromptParts(original)
expect(copy).not.toBe(original)
@@ -100,7 +100,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
}
>
<img
src={attachment.blob.url}
src={attachment.dataUrl}
alt={attachment.filename}
class={props.newLayoutDesigns ? imageClassV2 : imageClass}
onClick={() => props.onOpen(attachment)}
@@ -22,7 +22,6 @@ import { ScopedKey } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state"
import { normalizeSessionInfo } from "@/utils/session"
import { Event } from "@opencode-ai/schema/event"
import { blobDataUrl } from "@/utils/draft-store"
type PendingPrompt = {
abort: AbortController
@@ -96,12 +95,10 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
providerID: input.draft.model.providerID,
variant: input.draft.variant,
},
files: await Promise.all(
images.map(async (attachment) => ({
uri: await blobDataUrl(attachment.blob, attachment.mime),
name: attachment.filename,
})),
),
files: images.map((attachment) => ({
uri: attachment.dataUrl,
name: attachment.filename,
})),
})
return true
} catch (err) {
@@ -111,16 +108,10 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
}
const messageID = input.messageID ?? Identifier.ascending("message")
const encodedImages = await Promise.all(
images.map(async (attachment) => ({
...attachment,
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
})),
)
const { requestParts, optimisticParts } = buildRequestParts({
prompt: input.draft.prompt,
context: input.draft.context,
images: encodedImages,
images,
text,
sessionID: input.draft.sessionID,
messageID,
@@ -525,12 +516,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
arguments: args.join(" "),
agent,
model: { id: model.modelID, providerID: model.providerID, variant },
files: await Promise.all(
images.map(async (attachment) => ({
uri: await blobDataUrl(attachment.blob, attachment.mime),
name: attachment.filename,
})),
),
files: images.map((attachment) => ({
uri: attachment.dataUrl,
name: attachment.filename,
})),
})
.catch((err) => {
serverSync().session.set("session_status", session.id, { type: "idle" })
@@ -289,9 +289,9 @@ export function SessionHeader() {
return (
<>
<Show when={search() && centerMount()} keyed>
<Show when={search() && centerMount()}>
{(mount) => (
<Portal mount={mount}>
<Portal mount={mount()}>
<Button
type="button"
variant="ghost"
@@ -308,10 +308,10 @@ export function SessionHeader() {
</span>
</div>
<Show when={hotkey()} keyed>
<Show when={hotkey()}>
{(keybind) => (
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
{keybind}
{keybind()}
</Keybind>
)}
</Show>
@@ -319,9 +319,9 @@ export function SessionHeader() {
</Portal>
)}
</Show>
<Show when={rightMount()} keyed>
<Show when={rightMount()}>
{(mount) => (
<Portal mount={mount}>
<Portal mount={mount()}>
<Show
when={isV2}
fallback={
@@ -29,7 +29,7 @@ import {
} from "@/context/settings"
import { decode64 } from "@/utils/base64"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { ExternalLink } from "./external-link"
import { Link } from "./link"
import { SettingsList } from "./settings-list"
let demoSoundState = {
@@ -482,7 +482,7 @@ export const SettingsGeneral: Component = () => {
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<ExternalLink href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</ExternalLink>
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
</>
}
>
@@ -1,70 +0,0 @@
import { onCleanup } from "solid-js"
export type ShellOption = {
path: string
name: string
acceptable: boolean
}
export type ShellSelectOption = {
id: string
value: string
name: string
terminalOnly: boolean
}
export function createShellOptions(input: { shells: ShellOption[]; current: string | undefined }) {
const counts = input.shells.reduce((result, shell) => {
result.set(shell.name, (result.get(shell.name) ?? 0) + 1)
return result
}, new Map<string, number>())
const options: ShellSelectOption[] = [
{ id: "auto", value: "", name: "", terminalOnly: false },
...input.shells.map((shell) => {
const ambiguous = (counts.get(shell.name) ?? 0) > 1
const name = ambiguous ? shell.path : shell.name
return {
id: shell.path,
value: ambiguous ? shell.path : shell.name,
name,
terminalOnly: !shell.acceptable,
}
}),
]
if (input.current && !options.some((option) => option.value === input.current)) {
options.push({ id: input.current, value: input.current, name: input.current, terminalOnly: false })
}
return options
}
export function createSoundPreviewController(player: (id: string | undefined) => Promise<(() => void) | undefined>) {
let cleanup: (() => void) | undefined
let timeout: ReturnType<typeof setTimeout> | undefined
let run = 0
const stop = () => {
run += 1
cleanup?.()
clearTimeout(timeout)
cleanup = undefined
timeout = undefined
}
const play = (id: string | undefined) => {
stop()
if (!id) return
const current = ++run
timeout = setTimeout(() => {
timeout = undefined
void player(id).then((next) => {
if (run === current) {
cleanup = next
return
}
next?.()
})
}, 100)
}
onCleanup(stop)
return { play, stop }
}
@@ -1,53 +0,0 @@
import { describe, expect, test, vi } from "bun:test"
import { createRoot } from "solid-js"
import { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
describe("settings v2 controllers", () => {
test("normalizes shell names and preserves an unavailable configured shell", () => {
expect(
createShellOptions({
shells: [
{ path: "/bin/bash", name: "bash", acceptable: true },
{ path: "/opt/bash", name: "bash", acceptable: false },
{ path: "/bin/zsh", name: "zsh", acceptable: true },
],
current: "fish",
}),
).toEqual([
{ id: "auto", value: "", name: "", terminalOnly: false },
{ id: "/bin/bash", value: "/bin/bash", name: "/bin/bash", terminalOnly: false },
{ id: "/opt/bash", value: "/opt/bash", name: "/opt/bash", terminalOnly: true },
{ id: "/bin/zsh", value: "zsh", name: "zsh", terminalOnly: false },
{ id: "fish", value: "fish", name: "fish", terminalOnly: false },
])
})
test("debounces previews and stops owned audio on disposal", async () => {
vi.useFakeTimers()
try {
const played: string[] = []
const stopped: string[] = []
const owned = createRoot((dispose) => ({
dispose,
preview: createSoundPreviewController(async (id) => {
played.push(id ?? "")
return () => stopped.push(id ?? "")
}),
}))
owned.preview.play("first")
vi.advanceTimersByTime(99)
expect(played).toEqual([])
owned.preview.play("second")
vi.advanceTimersByTime(100)
await Promise.resolve()
expect(played).toEqual(["second"])
owned.dispose()
expect(stopped).toEqual(["second"])
} finally {
vi.useRealTimers()
}
})
})
@@ -1,173 +0,0 @@
import { createMemo, createResource, onMount, type Accessor } from "solid-js"
import type { ColorScheme } from "@opencode-ai/ui/theme/context"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { usePermission } from "@/context/permission"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import {
monoDefault,
monoFontFamily,
monoInput,
sansDefault,
sansFontFamily,
sansInput,
terminalDefault,
terminalFontFamily,
terminalInput,
useSettings,
} from "@/context/settings"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior"
export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
export type { ShellOption, ShellSelectOption } from "./general-controller-behavior"
export function createPermissionScopeController(sessionID: Accessor<string | undefined>) {
const permission = usePermission()
const serverSync = useServerSync()
const directory = createMemo(() => {
const id = sessionID()
if (!id) return undefined
return serverSync().session.lineage.peek(id)?.session.directory
})
return {
accepting: createMemo(() => {
const id = sessionID()
const dir = directory()
if (!id || !dir) return false
return permission.isAutoAccepting(id, dir)
}),
enabled: createMemo(() => !!directory()),
set: (checked: boolean) => {
const id = sessionID()
const dir = directory()
if (!id || !dir) return
if (checked) return permission.enableAutoAccept(id, dir)
permission.disableAutoAccept(id, dir)
},
}
}
export function createShellSettingsController() {
const serverSdk = useServerSDK()
const serverSync = useServerSync()
const [shells] = createResource(
async () => {
const sdk = serverSdk()
if ((await sdk.protocol) === "v1") return (await sdk.client.pty.shells()).data ?? []
return [] as ShellOption[]
},
{ initialValue: [] as ShellOption[] },
)
const current = createMemo(() => serverSync().data.config.shell ?? "")
return {
shells: () => shells.latest,
current,
select: (value: string) => {
if (value === current()) return
void serverSync().updateConfig({ shell: value })
},
}
}
export function createAppearanceSettingsController() {
const settings = useSettings()
const theme = useTheme()
const themes = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
onMount(() => void theme.loadThemes())
return {
scheme: {
current: theme.colorScheme,
select: (value: ColorScheme) => theme.setColorScheme(value),
},
theme: {
options: themes,
current: createMemo(() => themes().find((option) => option.id === theme.themeId())),
select: (option: { id: string } | null) => option && theme.setTheme(option.id),
},
fonts: {
ui: createMemo(() => ({
value: sansInput(settings.appearance.uiFont()),
family: sansFontFamily(settings.appearance.uiFont()),
placeholder: sansDefault,
})),
code: createMemo(() => ({
value: monoInput(settings.appearance.font()),
family: monoFontFamily(settings.appearance.font()),
placeholder: monoDefault,
})),
terminal: createMemo(() => ({
value: terminalInput(settings.appearance.terminalFont()),
family: terminalFontFamily(settings.appearance.terminalFont()),
placeholder: terminalDefault,
})),
setUI: (value: string) => settings.appearance.setUIFont(value),
setCode: (value: string) => settings.appearance.setFont(value),
setTerminal: (value: string) => settings.appearance.setTerminalFont(value),
},
}
}
const noneSound = { id: "none", label: "sound.option.none" } as const
export const soundOptions = [noneSound, ...SOUND_OPTIONS]
export type SoundSelectOption = (typeof soundOptions)[number]
export function createSoundSettingsController() {
const settings = useSettings()
const preview = createSoundPreviewController(playSoundById)
const channel = (
enabled: Accessor<boolean>,
current: Accessor<string>,
setEnabled: (value: boolean) => void,
set: (id: string) => void,
) => ({
current: createMemo(() =>
enabled() ? (soundOptions.find((option) => option.id === current()) ?? noneSound) : noneSound,
),
highlight: (option: SoundSelectOption | undefined) => {
if (!option) return
preview.play(option.id === "none" ? undefined : option.id)
},
select: (option: SoundSelectOption | null) => {
if (!option) return
if (option.id === "none") {
setEnabled(false)
preview.stop()
return
}
setEnabled(true)
set(option.id)
preview.play(option.id)
},
})
return {
agent: channel(
settings.sounds.agentEnabled,
settings.sounds.agent,
(value) => settings.sounds.setAgentEnabled(value),
(id) => settings.sounds.setAgent(id),
),
permissions: channel(
settings.sounds.permissionsEnabled,
settings.sounds.permissions,
(value) => settings.sounds.setPermissionsEnabled(value),
(id) => settings.sounds.setPermissions(id),
),
errors: channel(
settings.sounds.errorsEnabled,
settings.sounds.errors,
(value) => settings.sounds.setErrorsEnabled(value),
(id) => settings.sounds.setErrors(id),
),
}
}
export type PermissionScopeController = ReturnType<typeof createPermissionScopeController>
export type ShellSettingsController = ReturnType<typeof createShellSettingsController>
export type AppearanceSettingsController = ReturnType<typeof createAppearanceSettingsController>
export type SoundSettingsController = ReturnType<typeof createSoundSettingsController>
@@ -1,297 +1,182 @@
import { Component, Show, createMemo, createResource } from "solid-js"
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "../updater-action"
import { useSettings } from "@/context/settings"
import { ExternalLink } from "../external-link"
import {
monoDefault,
monoFontFamily,
monoInput,
sansDefault,
sansFontFamily,
sansInput,
terminalDefault,
terminalFontFamily,
terminalInput,
useSettings,
} from "@/context/settings"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "../link"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
import {
createAppearanceSettingsController,
createPermissionScopeController,
createShellOptions,
createShellSettingsController,
createSoundSettingsController,
soundOptions,
type AppearanceSettingsController,
type PermissionScopeController,
type ShellSettingsController,
type SoundSettingsController,
} from "./general-controllers"
import "./settings-v2.css"
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
const fontSettings = {
ui: {
action: "settings-ui-font",
title: "settings.general.row.uiFont.title",
description: "settings.general.row.uiFont.description",
font: "ui",
input: "setUI",
},
code: {
action: "settings-code-font",
title: "settings.general.row.font.title",
description: "settings.general.row.font.description",
font: "code",
input: "setCode",
},
terminal: {
action: "settings-terminal-font",
title: "settings.general.row.terminalFont.title",
description: "settings.general.row.terminalFont.description",
font: "terminal",
input: "setTerminal",
},
} as const
const soundSettings = {
agent: {
action: "settings-sounds-agent",
title: "settings.general.sounds.agent.title",
description: "settings.general.sounds.agent.description",
},
permissions: {
action: "settings-sounds-permissions",
title: "settings.general.sounds.permissions.title",
description: "settings.general.sounds.permissions.description",
},
errors: {
action: "settings-sounds-errors",
title: "settings.general.sounds.errors.title",
description: "settings.general.sounds.errors.description",
},
} as const
const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => {
const language = useLanguage()
return (
<SettingsRowV2
title={language.t("command.permissions.autoaccept.enable")}
description={language.t("toast.permissions.autoaccept.on.description")}
>
<div data-action="settings-auto-accept-permissions">
<Switch
checked={props.controller.accepting()}
disabled={!props.controller.enabled()}
onChange={props.controller.set}
/>
</div>
</SettingsRowV2>
)
let demoSoundState = {
cleanup: undefined as (() => void) | undefined,
timeout: undefined as NodeJS.Timeout | undefined,
run: 0,
}
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
const language = useLanguage()
const options = createMemo(() =>
createShellOptions({
shells: props.controller.shells(),
current: props.controller.current(),
}),
)
return (
<SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<SelectV2
appearance="inline"
data-action="settings-shell"
options={options()}
current={options().find((option) => option.value === props.controller.current()) ?? options()[0]}
placement="bottom-end"
gutter={6}
value={(option) => option.id}
label={(option) => {
if (option.id === "auto") return language.t("settings.general.row.shell.autoDefault")
if (!option.terminalOnly) return option.name
return `${option.name} (${language.t("settings.general.row.shell.terminalOnly")})`
}}
onSelect={(option) => option && props.controller.select(option.value)}
/>
</SettingsRowV2>
)
type ThemeOption = {
id: string
name: string
}
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
const language = useLanguage()
return (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.appearance")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")}
>
<SelectV2
appearance="inline"
data-action="settings-color-scheme"
options={schemeOptions}
current={schemeOptions.find((option) => option === props.controller.scheme.current())}
placement="bottom-end"
gutter={6}
label={(option) => {
if (option === "system") return language.t("theme.scheme.system")
if (option === "light") return language.t("theme.scheme.light")
return language.t("theme.scheme.dark")
}}
onSelect={(option) => option && props.controller.scheme.select(option)}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<ExternalLink class="settings-v2-link" href="https://opencode.ai/docs/themes/">
{language.t("common.learnMore")}
</ExternalLink>
</>
}
>
<SelectV2
appearance="inline"
data-action="settings-theme"
options={props.controller.theme.options()}
current={props.controller.theme.current()}
placement="bottom-end"
gutter={6}
value={(option) => option.id}
label={(option) => option.name}
onSelect={props.controller.theme.select}
/>
</SettingsRowV2>
<FontSetting kind="ui" fonts={props.controller.fonts} />
<FontSetting kind="code" fonts={props.controller.fonts} />
<FontSetting kind="terminal" fonts={props.controller.fonts} />
</SettingsListV2>
</div>
)
type ShellOption = {
path: string
name: string
acceptable: boolean
}
const FontSetting: Component<{
kind: "ui" | "code" | "terminal"
fonts: AppearanceSettingsController["fonts"]
}> = (props) => {
const language = useLanguage()
const config = () => fontSettings[props.kind]
return (
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action={config().action}
type="text"
appearance="base"
value={props.fonts[config().font]().value}
onInput={(event) => props.fonts[config().input](event.currentTarget.value)}
placeholder={props.fonts[config().font]().placeholder}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t(config().title)}
style={{ "font-family": props.fonts[config().font]().family }}
/>
</div>
</SettingsRowV2>
)
type ShellSelectOption = {
id: string
value: string
label: string
}
const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => {
const language = useLanguage()
return (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
<SettingsListV2>
<SoundSetting kind="agent" channel={props.controller.agent} />
<SoundSetting kind="permissions" channel={props.controller.permissions} />
<SoundSetting kind="errors" channel={props.controller.errors} />
</SettingsListV2>
</div>
)
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
// delay the playback by 100ms during quick selection changes and pause existing sounds.
const stopDemoSound = () => {
demoSoundState.run += 1
if (demoSoundState.cleanup) {
demoSoundState.cleanup()
}
clearTimeout(demoSoundState.timeout)
demoSoundState.cleanup = undefined
}
const SoundSetting: Component<{
kind: "agent" | "permissions" | "errors"
channel: SoundSettingsController["agent"]
}> = (props) => {
const language = useLanguage()
const config = () => soundSettings[props.kind]
return (
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
<SelectV2
appearance="inline"
data-action={config().action}
options={soundOptions}
current={props.channel.current()}
value={(option) => option.id}
label={(option) => language.t(option.label)}
onHighlight={props.channel.highlight}
onSelect={props.channel.select}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
)
}
const playDemoSound = (id: string | undefined) => {
stopDemoSound()
if (!id) return
const LanguageSetting = () => {
const language = useLanguage()
const options = createMemo(() =>
language.locales.map((locale) => ({
value: locale,
label: language.label(locale),
})),
)
return (
<SettingsRowV2
title={language.t("settings.general.row.language.title")}
description={language.t("settings.general.row.language.description")}
>
<SelectV2
appearance="inline"
data-action="settings-language"
options={options()}
placement="bottom-end"
gutter={6}
current={options().find((option) => option.value === language.locale())}
value={(option) => option.value}
label={(option) => option.label}
onSelect={(option) => option && language.setLocale(option.value)}
/>
</SettingsRowV2>
)
const run = ++demoSoundState.run
demoSoundState.timeout = setTimeout(() => {
void playSoundById(id).then((cleanup) => {
if (demoSoundState.run !== run) {
cleanup?.()
return
}
demoSoundState.cleanup = cleanup
})
}, 100)
}
export const SettingsGeneralV2: Component<{
sessionID?: string
}> = (props) => {
const theme = useTheme()
const language = useLanguage()
const permission = usePermission()
const platform = usePlatform()
const dialog = useDialog()
const settings = useSettings()
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction()
const permissionScope = createPermissionScopeController(() => props.sessionID)
const shell = createShellSettingsController()
const appearance = createAppearanceSettingsController()
const sounds = createSoundSettingsController()
const dir = createMemo(() => {
if (!props.sessionID) return undefined
return serverSync().session.lineage.peek(props.sessionID)?.session.directory
})
const accepting = createMemo(() => {
const value = dir()
if (!value || !props.sessionID) return false
return permission.isAutoAccepting(props.sessionID, value)
})
const toggleAccept = (checked: boolean) => {
const value = dir()
if (!value || !props.sessionID) return
if (checked) {
permission.enableAutoAccept(props.sessionID, value)
return
}
permission.disableAutoAccept(props.sessionID, value)
}
const desktop = createMemo(() => platform.platform === "desktop")
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const [shells] = createResource(
async () => {
const sdk = serverSdk()
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.shells()).data ?? []
}
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[]
},
{ initialValue: [] as ShellOption[] },
)
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
() => desktop() && "getPinchZoomEnabled" in platform,
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
{ initialValue: false },
)
onMount(() => {
void theme.loadThemes()
})
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
const shellOptions = createMemo<ShellSelectOption[]>(() => {
const list = shells.latest
const current = serverSync().data.config.shell
const nameCounts = new Map<string, number>()
for (const s of list) {
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
}
const options = [
autoOption,
...list.map((s) => {
const ambiguousName = (nameCounts.get(s.name) || 0) > 1
const text = ambiguousName ? s.path : s.name
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
return {
id: s.path,
// Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH.
value: ambiguousName ? s.path : s.name,
label,
}
}),
]
if (current && !options.some((o) => o.value === current)) {
options.push({ id: current, value: current, label: current })
}
return options
})
const onPinchZoomChange = (checked: boolean) => {
setPinchZoom(checked)
const update = platform.setPinchZoomEnabled?.(checked)
@@ -299,6 +184,52 @@ export const SettingsGeneralV2: Component<{
void update.catch(() => setPinchZoom(!checked))
}
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") },
{ value: "light", label: language.t("theme.scheme.light") },
{ value: "dark", label: language.t("theme.scheme.dark") },
])
const languageOptions = createMemo(() =>
language.locales.map((locale) => ({
value: locale,
label: language.label(locale),
})),
)
const noneSound = { id: "none", label: "sound.option.none" } as const
const soundOptions = [noneSound, ...SOUND_OPTIONS]
const mono = () => monoInput(settings.appearance.font())
const sans = () => sansInput(settings.appearance.uiFont())
const terminal = () => terminalInput(settings.appearance.terminalFont())
const soundSelectProps = (
enabled: () => boolean,
current: () => string,
setEnabled: (value: boolean) => void,
set: (id: string) => void,
) => ({
options: soundOptions,
current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound,
value: (o: (typeof soundOptions)[number]) => o.id,
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
playDemoSound(option.id === "none" ? undefined : option.id)
},
onSelect: (option: (typeof soundOptions)[number] | null) => {
if (!option) return
if (option.id === "none") {
setEnabled(false)
stopDemoSound()
return
}
setEnabled(true)
set(option.id)
playDemoSound(option.id)
},
})
const InterfaceSection = () => (
<LayoutTransitionToggle
title={language.t("settings.general.row.newInterface.title")}
@@ -320,18 +251,59 @@ export const SettingsGeneralV2: Component<{
title={language.t("settings.general.row.newInterfaceNotice.title")}
description={language.t("settings.general.row.newInterfaceNotice.description")}
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
onDismiss={() => settings.general.dismissNewInterfaceNotice()}
onDismiss={settings.general.dismissNewInterfaceNotice}
/>
)
const GeneralSection = () => (
<div class="settings-v2-section">
<SettingsListV2>
<LanguageSetting />
<SettingsRowV2
title={language.t("settings.general.row.language.title")}
description={language.t("settings.general.row.language.description")}
>
<SelectV2
appearance="inline"
data-action="settings-language"
options={languageOptions()}
placement="bottom-end"
gutter={6}
current={languageOptions().find((o) => o.value === language.locale())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && language.setLocale(option.value)}
/>
</SettingsRowV2>
<PermissionScopeSetting controller={permissionScope} />
<SettingsRowV2
title={language.t("command.permissions.autoaccept.enable")}
description={language.t("toast.permissions.autoaccept.on.description")}
>
<div data-action="settings-auto-accept-permissions">
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
</div>
</SettingsRowV2>
<ShellSetting controller={shell} />
<SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<SelectV2
appearance="inline"
data-action="settings-shell"
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
placement="bottom-end"
gutter={6}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync().updateConfig({ shell: option.value })
}}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -442,6 +414,124 @@ export const SettingsGeneralV2: Component<{
</div>
)
const AppearanceSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.appearance")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")}
>
<SelectV2
appearance="inline"
data-action="settings-color-scheme"
options={colorSchemeOptions()}
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
placement="bottom-end"
gutter={6}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<Link class="settings-v2-link" href="https://opencode.ai/docs/themes/">
{language.t("common.learnMore")}
</Link>
</>
}
>
<SelectV2
appearance="inline"
data-action="settings-theme"
options={themeOptions()}
current={themeOptions().find((o) => o.id === theme.themeId())}
placement="bottom-end"
gutter={6}
value={(o) => o.id}
label={(o) => o.name}
onSelect={(option) => {
if (!option) return
theme.setTheme(option.id)
}}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.uiFont.title")}
description={language.t("settings.general.row.uiFont.description")}
>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action="settings-ui-font"
type="text"
appearance="base"
value={sans()}
onInput={(event) => settings.appearance.setUIFont(event.currentTarget.value)}
placeholder={sansDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.general.row.uiFont.title")}
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.font.title")}
description={language.t("settings.general.row.font.description")}
>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action="settings-code-font"
type="text"
appearance="base"
value={mono()}
onInput={(event) => settings.appearance.setFont(event.currentTarget.value)}
placeholder={monoDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.general.row.font.title")}
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.terminalFont.title")}
description={language.t("settings.general.row.terminalFont.description")}
>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action="settings-terminal-font"
type="text"
appearance="base"
value={terminal()}
onInput={(event) => settings.appearance.setTerminalFont(event.currentTarget.value)}
placeholder={terminalDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.general.row.terminalFont.title")}
style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }}
/>
</div>
</SettingsRowV2>
</SettingsListV2>
</div>
)
const NotificationsSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
@@ -486,6 +576,68 @@ export const SettingsGeneralV2: Component<{
</div>
)
const SoundsSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.sounds.agent.title")}
description={language.t("settings.general.sounds.agent.description")}
>
<SelectV2
appearance="inline"
data-action="settings-sounds-agent"
{...soundSelectProps(
() => settings.sounds.agentEnabled(),
() => settings.sounds.agent(),
(value) => settings.sounds.setAgentEnabled(value),
(id) => settings.sounds.setAgent(id),
)}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.sounds.permissions.title")}
description={language.t("settings.general.sounds.permissions.description")}
>
<SelectV2
appearance="inline"
data-action="settings-sounds-permissions"
{...soundSelectProps(
() => settings.sounds.permissionsEnabled(),
() => settings.sounds.permissions(),
(value) => settings.sounds.setPermissionsEnabled(value),
(id) => settings.sounds.setPermissions(id),
)}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.sounds.errors.title")}
description={language.t("settings.general.sounds.errors.description")}
>
<SelectV2
appearance="inline"
data-action="settings-sounds-errors"
{...soundSelectProps(
() => settings.sounds.errorsEnabled(),
() => settings.sounds.errors(),
(value) => settings.sounds.setErrorsEnabled(value),
(id) => settings.sounds.setErrors(id),
)}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
</SettingsListV2>
</div>
)
const UpdatesSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
@@ -507,7 +659,7 @@ export const SettingsGeneralV2: Component<{
title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")}
>
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={() => updater.run()}>
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={updater.run}>
{language.t(updater.action().label)}
</ButtonV2>
</SettingsRowV2>
@@ -552,11 +704,11 @@ export const SettingsGeneralV2: Component<{
<GeneralSection />
<AppearanceSection controller={appearance} />
<AppearanceSection />
<NotificationsSection />
<SoundsSection controller={sounds} />
<SoundsSection />
<Show when={desktop()}>
<UpdatesSection />
@@ -5,12 +5,9 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { type Component, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models"
import { useServerSDK } from "@/context/server-sdk"
import { popularProviders } from "@/hooks/use-providers"
import { Persist, persisted } from "@/utils/persist"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
import "./settings-v2.css"
@@ -22,11 +19,6 @@ const PROVIDER_ICON_SIZE = 16
export const SettingsModelsV2: Component = () => {
const language = useLanguage()
const models = useModels()
const serverSdk = useServerSDK()
const [store, setStore] = persisted(
Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"),
createStore({ collapsed: {} as Record<string, boolean> }),
)
const list = useFilteredList<ModelItem>({
items: (_filter) => models.list(),
@@ -102,82 +94,41 @@ export const SettingsModelsV2: Component = () => {
}
>
<For each={list.grouped.latest}>
{(group) => {
const searching = () => list.filter().length > 0
const expanded = () => searching() || !store.collapsed[group.category]
return (
<div
class="settings-v2-section"
data-component="settings-models-provider"
data-expanded={expanded() ? "" : undefined}
>
<h3 class="settings-v2-models-group-header">
<button
type="button"
class="settings-v2-models-group-trigger"
aria-expanded={expanded()}
disabled={searching()}
onClick={() => setStore("collapsed", group.category, expanded())}
>
<span class="settings-v2-models-group-chevron">
<Show
when={expanded()}
fallback={
<svg width="5" height="6" viewBox="0 0 5 6" fill="none" aria-hidden="true">
<path
d="M0.75194 5.31663C0.41861 5.51103 0 5.27063 0 4.88473V0.500754C0 0.114854 0.41861 -0.125577 0.75194 0.0688635L4.5096 2.26084C4.8404 2.45378 4.8404 2.93168 4.5096 3.12462L0.75194 5.31663Z"
fill="currentColor"
/>
</svg>
}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M5.37624 6.75194C5.18184 6.41861 5.42224 6 5.80814 6H10.1921C10.578 6 10.8184 6.41861 10.624 6.75194L8.43203 10.5096C8.23909 10.8404 7.76119 10.8404 7.56825 10.5096L5.37624 6.75194Z"
fill="currentColor"
/>
</svg>
</Show>
</span>
<span class="settings-v2-models-group-label">
<ProviderIcon
id={group.category}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-v2-models-provider-icon shrink-0"
/>
<span class="settings-v2-section-title">{group.items[0].provider.name}</span>
</span>
</button>
</h3>
<Show when={expanded()}>
<SettingsListV2>
<For each={group.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
return (
<SettingsRowV2 title={item.name} description="">
<div>
<Switch
checked={models.visible(key)}
onChange={(checked) => {
models.setVisibility(key, checked)
}}
hideLabel
>
{item.name}
</Switch>
</div>
</SettingsRowV2>
)
}}
</For>
</SettingsListV2>
</Show>
{(group) => (
<div class="settings-v2-section" data-component="settings-models-provider">
<div class="settings-v2-models-group-header">
<ProviderIcon
id={group.category}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-v2-models-provider-icon shrink-0"
/>
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
</div>
)
}}
<SettingsListV2>
<For each={group.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
return (
<SettingsRowV2 title={item.name} description="">
<div>
<Switch
checked={models.visible(key)}
onChange={(checked) => {
models.setVisibility(key, checked)
}}
hideLabel
>
{item.name}
</Switch>
</div>
</SettingsRowV2>
)
}}
</For>
</SettingsListV2>
</div>
)}
</For>
</Show>
</Show>
@@ -373,64 +373,14 @@
}
.settings-v2-models {
gap: 12px;
}
.settings-v2-models .settings-v2-section {
gap: 12px;
}
.settings-v2-models .settings-v2-section[data-expanded] {
padding-bottom: 12px;
gap: 24px;
}
.settings-v2-models-group-header {
display: flex;
align-items: center;
height: 28px;
}
.settings-v2-models-group-trigger {
display: flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 4px 8px 4px 4px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--v2-text-text-base);
}
.settings-v2-models-group-trigger:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
@media (hover: hover) {
.settings-v2-models-group-trigger:not(:disabled):hover {
background-color: var(--v2-background-bg-layer-02);
}
}
.settings-v2-models-group-trigger:disabled {
cursor: default;
}
.settings-v2-models-group-chevron {
display: flex;
width: 20px;
height: 20px;
flex-shrink: 0;
align-items: center;
justify-content: center;
color: var(--v2-icon-icon-muted);
}
.settings-v2-models-group-label {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 8px;
}
.settings-v2-models .settings-v2-section-title {
@@ -444,10 +394,6 @@
color: var(--v2-icon-icon-base);
}
.settings-v2-models [data-component="settings-v2-list"] {
padding-inline: 16px;
}
.settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] {
margin-top: 0;
}
+1 -5
View File
@@ -382,11 +382,7 @@ export const Terminal = (props: TerminalProps) => {
event.preventDefault()
event.stopImmediatePropagation()
if (URL.canParse(text) && new URL(text).protocol === "file:" && platform.openLocalFile) {
platform.openLocalFile(text)
return
}
platform.openExternal(text)
platform.openLink(text)
}
onMount(() => {
@@ -9,86 +9,53 @@
justify-content: center;
}
[data-titlebar-tab] {
--tab-base: var(--v2-background-bg-deep);
--tab-overlay: transparent;
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
[data-titlebar-tab]:is(:hover, :has(> [data-slot="tab-link"]:focus-visible)):not([data-state="pressed"]):not(
[data-dragging="true"]
):not([data-editing="true"]) {
--tab-base: var(--v2-background-bg-layer-02);
--tab-overlay: var(--v2-overlay-simple-overlay-hover);
}
[data-titlebar-tab]:is([data-state="pressed"], [data-dragging="true"], [data-editing="true"]) {
--tab-base: var(--v2-background-bg-layer-02);
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
}
[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] {
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
display: none;
}
[data-titlebar-tab-list] {
gap: 6px;
gap: 13.5px;
}
[data-titlebar-tab-slot] {
--tab-separator: var(--v2-background-bg-layer-03);
position: relative;
}
[data-color-scheme="dark"] [data-titlebar-tab-slot] {
--tab-separator: var(--v2-background-bg-layer-02);
}
[data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before {
content: "";
position: absolute;
top: 8px;
left: -3.75px;
left: -6.75px;
width: 1.5px;
height: 12px;
border-radius: 9999px;
background: var(--tab-separator);
background: var(--v2-background-bg-layer-02);
}
[data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before {
display: none;
}
[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover)::before,
[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover) + [data-titlebar-tab-slot]::before {
[data-titlebar-tab] [data-slot="tab-close"]::before {
content: "";
position: absolute;
top: 0;
height: 28px;
width: 16px;
pointer-events: none;
background: linear-gradient(to right, transparent, var(--tab-bg));
display: none;
}
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] {
--tab-title-fade-offset: 4px;
-webkit-mask-image: linear-gradient(
to right,
black 0,
black calc(100% - var(--tab-title-fade-offset) - 16px),
transparent calc(100% - var(--tab-title-fade-offset)),
transparent 100%
);
mask-image: linear-gradient(
to right,
black 0,
black calc(100% - var(--tab-title-fade-offset) - 16px),
transparent calc(100% - var(--tab-title-fade-offset)),
transparent 100%
);
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before {
display: block;
right: 0;
}
[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"])
[data-slot="tab-link"] {
--tab-title-fade-offset: 24px;
[data-titlebar-tab][data-title-overflow="true"]:hover:not([data-editing="true"]) [data-slot="tab-close"]::before,
[data-titlebar-tab][data-title-overflow="true"][data-active="true"]:not([data-editing="true"])
[data-slot="tab-close"]::before {
right: 100%;
}
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
@@ -180,11 +180,11 @@ export function TabNavItem(props: {
data-slot="titlebar-tab-item"
data-title-overflow={titleOverflowing()}
data-editing={editing()}
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] px-1.5 [container-type:inline-size]"
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
classList={{ invisible: props.hidden }}
data-active={props.active}
data-dragging={props.dragging}
data-state={props.active || props.pressed ? "pressed" : undefined}
data-pressed={props.pressed}
onMouseDown={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return
event.preventDefault()
@@ -224,7 +224,6 @@ export function TabNavItem(props: {
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
<Show
when={props.session()}
keyed
fallback={
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
}
@@ -232,8 +231,8 @@ export function TabNavItem(props: {
{(session) => (
<SessionTabAvatar
project={project()}
directory={session.directory}
sessionId={session.id}
directory={session().directory}
sessionId={session().id}
server={props.server}
/>
)}
@@ -277,7 +276,7 @@ export function TabNavItem(props: {
/>
</a>
<div data-slot="tab-close">
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<IconButtonV2
size="small"
variant="ghost-muted"
@@ -335,8 +334,8 @@ export function DraftTabItem(props: {
data-slot="titlebar-tab-item"
data-active={props.active}
data-dragging={props.dragging}
data-state={props.active || props.pressed ? "pressed" : undefined}
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 [container-type:inline-size] whitespace-nowrap"
data-pressed={props.pressed}
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
classList={{ invisible: props.hidden }}
onMouseDown={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return
@@ -382,7 +381,7 @@ export function DraftTabItem(props: {
{props.title}
</span>
</a>
<div data-slot="tab-close">
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<IconButtonV2
size="small"
variant="ghost-muted"
+21 -19
View File
@@ -395,25 +395,27 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<TooltipV2
placement="bottom"
value={
<>
{language.t("command.session.new")}
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
</>
}
>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</TooltipV2>
<Show when={!creating()}>
<TooltipV2
placement="bottom"
value={
<>
{language.t("command.session.new")}
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
</>
}
>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</TooltipV2>
</Show>
<div class="flex-1" />
<TitlebarV2Right state={v2RightState()} />
</div>
@@ -43,7 +43,7 @@ export function WindowsAppMenu(props: {
runAction(entry.action)
return
}
if (entry.href) props.platform.openExternal(entry.href)
if (entry.href) props.platform.openLink(entry.href)
}
return (
@@ -8,7 +8,6 @@ import {
bootstrapDirectory,
loadAgentsQuery,
loadCommands,
loadGlobalConfigQuery,
loadPathQuery,
loadProjectsQuery,
loadProvidersQuery,
@@ -77,7 +76,6 @@ function directoryState() {
describe("bootstrapDirectory", () => {
test("uses legacy MCP endpoints while refreshing a v1 directory", async () => {
const legacyConfigReads: string[] = []
const mcpReads: string[] = []
const [store, setStore] = directoryState()
@@ -93,12 +91,7 @@ describe("bootstrapDirectory", () => {
},
sdk: {
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
config: {
get: async () => {
legacyConfigReads.push("directory")
return { data: {} }
},
},
config: { get: async () => ({ data: {} }) },
session: { status: async () => ({ data: {} }) },
vcs: { get: async () => ({ data: undefined }) },
command: {
@@ -141,88 +134,8 @@ describe("bootstrapDirectory", () => {
await new Promise((resolve) => setTimeout(resolve, 80))
expect(store.status).toBe("complete")
expect(legacyConfigReads).toEqual(["directory"])
expect(mcpReads.sort()).toEqual(["command", "resource", "status"])
})
test("skips legacy config while refreshing a v2 directory", async () => {
const [store, setStore] = directoryState()
await bootstrapDirectory({
directory: "/project",
scope: ServerScope.local,
mcp: false,
global: {
config: {} satisfies Config,
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
project: [{ id: "project", worktree: "/project" } as Project],
provider,
},
sdk: {
config: {
get: async () => {
throw new Error("legacy directory config should not be called")
},
},
} as unknown as OpencodeClient,
api,
store,
setStore,
vcsCache: { setStore() {} } as unknown as VcsCache,
loadSessions() {},
translate: (key) => key,
queryClient: new QueryClient(),
protocol: Promise.resolve("v2"),
})
expect(store.status).toBe("partial")
await new Promise((resolve) => setTimeout(resolve, 80))
expect(store.status).toBe("complete")
})
})
describe("config queries", () => {
test("skips legacy global config for v2 servers", async () => {
const sdk = {
global: {
config: {
get: async () => {
throw new Error("legacy global config should not be called")
},
},
},
} as unknown as OpencodeClient
const result = await new QueryClient().fetchQuery(
loadGlobalConfigQuery(ServerScope.local, sdk, Promise.resolve("v2")),
)
expect(result).toEqual({})
})
test("loads legacy global config for v1 servers", async () => {
const calls: string[] = []
const config = { shell: "zsh" } satisfies Config
const sdk = {
global: {
config: {
get: async () => {
calls.push("global")
return { data: config }
},
},
},
} as unknown as OpencodeClient
const result = await new QueryClient().fetchQuery(
loadGlobalConfigQuery(ServerScope.local, sdk, Promise.resolve("v1")),
)
expect(result).toEqual(config)
expect(calls).toEqual(["global"])
})
})
describe("query keys", () => {
@@ -105,13 +105,10 @@ function showErrors(input: {
})
}
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient, protocol?: Promise<ServerProtocol>) =>
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
queryOptions({
queryKey: [scope, "config"],
queryFn: async () => {
if ((await protocol) !== "v1") return {}
return retry(() => sdk.global.config.get().then((x) => x.data!))
},
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
})
type ProjectApi = {
@@ -152,7 +149,7 @@ export async function bootstrapGlobal(input: {
queryClient: QueryClient
}) {
const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK, input.protocol)),
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
() =>
input.queryClient.fetchQuery(
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
@@ -379,10 +376,7 @@ export async function bootstrapDirectory(input: {
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
.then((data) => input.setStore("agent", data)),
() =>
retry(async () => {
if ((await input.protocol) !== "v1") return
return input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))
}),
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
() =>
retry(() =>
(async () => {
+3 -8
View File
@@ -1,6 +1,6 @@
import { createStore, reconcile } from "solid-js/store"
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { useParams, useSearchParams } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { ServerSDK } from "./server-sdk"
import type { ServerSync } from "./server-sync"
@@ -118,7 +118,6 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const global = useGlobal()
const server = useServer()
const tabs = useTabs()
const navigate = useNavigate()
const platform = usePlatform()
const settings = useSettings()
const language = useLanguage()
@@ -154,7 +153,6 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
platform,
settings,
language,
navigate,
}),
}),
owner ?? undefined,
@@ -219,7 +217,6 @@ function createServerNotificationState(input: {
platform: ReturnType<typeof usePlatform>
settings: ReturnType<typeof useSettings>
language: ReturnType<typeof useLanguage>
navigate: (href: string) => void
}) {
const serverSDK = () => input.sdk
const serverSync = () => input.sync
@@ -356,9 +353,7 @@ function createServerNotificationState(input: {
const href = `/${base64Encode(directory)}/session/${sessionID}`
if (settings.notifications.agent()) {
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
input.navigate(href),
)
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
}
})
}
@@ -391,7 +386,7 @@ function createServerNotificationState(input: {
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, () => input.navigate(href))
void platform.notify(language.t("notification.session.error.title"), description, href)
}
})
}
+13 -11
View File
@@ -5,7 +5,6 @@ import type { DesktopMenuAction } from "../desktop-menu"
import { ServerConnection } from "./server"
import type { WslServersPlatform } from "../wsl/types"
import type { UpdaterPlatform } from "../updater"
import type { DraftStore } from "@/utils/draft-store"
type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
@@ -32,23 +31,26 @@ type PlatformBase = {
/** App version */
version?: string
/** Open a web or mail URL in the default system application */
openExternal(url: string): void
/** Open a URL in the default browser */
openLink(url: string): void
/** Open a local path in a local app (desktop only) */
openPath?(path: string, app?: string): Promise<void>
/** Open a local file URL in its default app (desktop only) */
openLocalFile?(url: string): void
/** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
revealPath?(path: string): Promise<boolean>
/** Restart the app */
restart(): Promise<void>
/** Send a system notification */
notify(title: string, description?: string, onClick?: () => void): Promise<void>
/** Navigate back in history */
back(): void
/** Navigate forward in history */
forward(): void
/** Send a system notification (optional deep link) */
notify(title: string, description?: string, href?: string): Promise<void>
/** Open a native attachment picker and read selected files sequentially (desktop only) */
openAttachmentPickerDialog?(
@@ -65,9 +67,6 @@ type PlatformBase = {
/** Storage mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage
/** Prompt drafts, history, and their blobs. */
draftStore?: DraftStore
/** Stable platform window identity for window-scoped persistence */
windowID?: string
@@ -92,6 +91,9 @@ type PlatformBase = {
/** Set the preferred display backend (desktop only) */
setDisplayBackend?(backend: DisplayBackend): Promise<void>
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
parseMarkdown?(markdown: string): Promise<string>
/** Webview zoom level (desktop only) */
webviewZoom?: Accessor<number>
+4 -5
View File
@@ -5,7 +5,6 @@ import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist"
import type { ServerScope } from "@/utils/server-scope"
import type { BlobReference } from "@/utils/draft-store"
interface PartBase {
content: string
@@ -38,7 +37,7 @@ export interface ImageAttachmentPart {
filename: string
sourcePath?: string
mime: string
blob: BlobReference
dataUrl: string
}
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
@@ -169,9 +168,9 @@ function createPromptActions(setStore: SetStoreFunction<PromptStore>) {
}
function promptTarget(serverScope: ServerScope, scope: PromptScope) {
if ("draftID" in scope) return Persist.prompt(Persist.draft(scope.draftID, "prompt"))
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
return Persist.prompt(Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy]))
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
}
function promptStore(initial?: InitialPrompt): PromptStore {
@@ -246,7 +245,7 @@ export function createPromptSession(serverScope: ServerScope, scope: PromptScope
}
export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
return createPersistedPrompt(Persist.prompt(Persist.draft(draftID, "prompt")), initial)
return createPersistedPrompt(Persist.draft(draftID, "prompt"), initial)
}
export type PromptSession = ReturnType<typeof createPromptSession>
+1 -1
View File
@@ -184,7 +184,7 @@ function makeQueryOptionsApi(
protocol: Promise<"v1" | "v2">,
) {
return {
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK(), protocol),
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
projects: () => loadProjectsQuery(scope, serverAPI.project),
providers: (directory: PathKey | null) =>
loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol),
-17
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"
import {
hasExistingWebState,
initialAgentVisibility,
isAppUpgrade,
layoutTransitionState,
maximumSunsetTimeout,
@@ -12,22 +11,6 @@ import {
shouldEnableNewLayout,
} from "./settings"
describe("agent visibility", () => {
test("shows the picker for existing profiles and hides it for first-time installs", () => {
expect(initialAgentVisibility(undefined, true)).toBe(true)
expect(initialAgentVisibility(undefined, false)).toBe(false)
})
test("shows the picker when updating from a recent release", () => {
expect(initialAgentVisibility(undefined, false, "1.18.8")).toBe(true)
})
test("preserves the preference after initialization", () => {
expect(initialAgentVisibility(true, true, "1.18.8")).toBeUndefined()
expect(initialAgentVisibility(true, false)).toBeUndefined()
})
})
describe("layout transition", () => {
test("blank profiles default to the new layout", () => {
expect(newLayoutDesignsDefault).toBe(true)
+3 -19
View File
@@ -1,5 +1,5 @@
import { createStore, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { persisted } from "@/utils/persist"
import { usePlatform } from "@/context/platform"
@@ -36,7 +36,6 @@ export interface Settings {
mobileTitlebarPosition: "top" | "bottom"
newLayoutDesigns?: boolean
layoutTransitionEligible?: boolean
agentVisibilityInitialized?: boolean
newInterfaceNoticeDismissed?: boolean
shouldDisplayTabsToast?: boolean
}
@@ -94,11 +93,6 @@ export function hasExistingWebState(settings: Promise<string> | string | null, p
return settings !== null || previousVersion !== undefined
}
export function initialAgentVisibility(initialized: boolean | undefined, existing: boolean, previousVersion?: string) {
if (initialized === true) return
return existing || previousVersion !== undefined
}
export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
if (!current) return false
const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
@@ -277,14 +271,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
)
})
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
const initializeAgentVisibility = (existing: boolean) => {
const initial = initialAgentVisibility(store.general?.agentVisibilityInitialized, existing, launchState.previous)
if (initial === undefined) return
batch(() => {
setStore("general", "showCustomAgents", initial)
setStore("general", "agentVisibilityInitialized", true)
})
}
if (sunset && !oldInterfaceRetired()) {
const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
@@ -313,9 +299,8 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
createEffect(() => {
if (!ready() || !launchState.classified || platform.platform !== "web") return
const existing = hasExistingWebState(settingsInit, launchState.previous)
if (!layoutTransitionClassified()) setStore("general", "layoutTransitionEligible", existing)
initializeAgentVisibility(existing)
if (layoutTransitionClassified()) return
setStore("general", "layoutTransitionEligible", hasExistingWebState(settingsInit, launchState.previous))
})
createEffect(() => {
@@ -441,7 +426,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
if (typeof current === "boolean") return
setStore("general", "layoutTransitionEligible", eligible)
},
initializeAgentVisibility,
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
dismissNewInterfaceNotice() {
-23
View File
@@ -1,23 +0,0 @@
import type { ServerConnection } from "./server"
import type { Tab } from "./tabs"
export function migrateTabs(value: unknown, fallback: ServerConnection.Key): Tab[] {
if (!Array.isArray(value)) return []
return value.flatMap<Tab>((tab) => {
if (!tab || typeof tab !== "object") return []
if ("server" in tab && typeof tab.server !== "string") return []
const server = ("server" in tab ? tab.server : fallback) as ServerConnection.Key
if (tab.type === "session" && typeof tab.sessionId === "string") {
return [{ type: tab.type, server, sessionId: tab.sessionId }]
}
if (
tab.type === "draft" &&
typeof tab.draftID === "string" &&
typeof tab.directory === "string" &&
(tab.worktree === undefined || typeof tab.worktree === "string")
) {
return [{ type: tab.type, server, draftID: tab.draftID, directory: tab.directory, worktree: tab.worktree }]
}
return []
})
}
-18
View File
@@ -3,7 +3,6 @@ import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import type { SessionTab, Tab } from "./tabs"
import { migrateTabs } from "./tab-migration"
import type { ServerConnection } from "./server"
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
@@ -12,23 +11,6 @@ function sessionTab(sessionId: string): SessionTab {
return { type: "session", server, sessionId }
}
describe("tab migration", () => {
test("drops null and malformed persisted tabs", () => {
expect(
migrateTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"], server),
).toEqual([sessionTab("a")])
})
test("adds the fallback server to valid legacy tabs", () => {
expect(migrateTabs([{ type: "session", sessionId: "a", dirBase64: "legacy" }], server)).toEqual([sessionTab("a")])
})
test("replaces invalid top-level persisted data", () => {
expect(migrateTabs(null, server)).toEqual([])
expect(migrateTabs({}, server)).toEqual([])
})
})
describe("tab memory", () => {
test("keeps state until its tab is removed", () => {
createRoot((dispose) => {
+8 -6
View File
@@ -12,7 +12,6 @@ import { sessionHref } from "@/utils/session-route"
import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
import { migrateTabs } from "./tab-migration"
export type SessionTab = {
type: "session"
@@ -60,7 +59,13 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const [store, setStore, _, ready] = persisted(
{
...Persist.window("tabs"),
migrate: (value: unknown) => migrateTabs(value, fallback),
migrate: (value: unknown) => {
if (!Array.isArray(value)) return value
return value.map((tab) => {
if (!tab || typeof tab !== "object" || "server" in tab) return tab
return { ...tab, server: fallback }
})
},
},
createStore<Tab[]>([]),
)
@@ -101,10 +106,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}
const removeDraftPersisted = (draftID: string) => {
for (const key of draftPersistedKeys()) {
const target = Persist.draft(draftID, key)
removePersisted(key === "prompt" ? Persist.prompt(target) : target, platform)
}
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
}
const removeInfo = (key: string) => {
+16 -11
View File
@@ -4,9 +4,9 @@ import * as Sentry from "@sentry/solid"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app"
import { type Platform, PlatformProvider } from "@/context/platform"
import { createBrowserDraftStore } from "@/utils/draft-store"
import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { handleNotificationClick } from "@/utils/notification-click"
import { authFromToken } from "@/utils/server"
import pkg from "../package.json"
import { ServerConnection } from "./context/server"
@@ -54,7 +54,7 @@ const setStorage = (key: string, value: string | null) => {
const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY)
const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url)
const notify: Platform["notify"] = async (title, description, onClick) => {
const notify: Platform["notify"] = async (title, description, href) => {
if (!("Notification" in window)) return
const permission =
@@ -73,17 +73,21 @@ const notify: Platform["notify"] = async (title, description, onClick) => {
})
notification.onclick = () => {
window.focus()
onClick?.()
handleNotificationClick(href)
notification.close()
}
}
const openExternal: Platform["openExternal"] = (value) => {
if (!URL.canParse(value)) return
const url = new URL(value)
if (url.protocol !== "http:" && url.protocol !== "https:" && url.protocol !== "mailto:") return
window.open(url.href, "_blank", "noopener,noreferrer")
const openLink: Platform["openLink"] = (url) => {
window.open(url, "_blank")
}
const back: Platform["back"] = () => {
window.history.back()
}
const forward: Platform["forward"] = () => {
window.history.forward()
}
const restart: Platform["restart"] = async () => {
@@ -117,9 +121,10 @@ const clearAuthToken = () => {
const platform: Platform = {
platform: "web",
draftStore: createBrowserDraftStore(),
version: pkg.version,
openExternal,
openLink,
back,
forward,
restart,
notify,
getDefaultServer: async () => {
-1
View File
@@ -277,7 +277,6 @@ export const dict = {
"prompt.action.send": "إرسال",
"prompt.action.stop": "توقف",
"prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم",
"prompt.toast.attachmentDuplicate.title": "تم تحميل هذا الملف بالفعل",
"prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.",
"prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً",
"prompt.toast.modelAgentRequired.description": "اختر وكيلاً ونموذجاً قبل إرسال الموجه.",
-1
View File
@@ -277,7 +277,6 @@ export const dict = {
"prompt.action.send": "Enviar",
"prompt.action.stop": "Parar",
"prompt.toast.pasteUnsupported.title": "Anexo não suportado",
"prompt.toast.attachmentDuplicate.title": "Este arquivo já foi enviado",
"prompt.toast.pasteUnsupported.description": "Apenas imagens, PDFs ou arquivos de texto podem ser anexados aqui.",
"prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo",
"prompt.toast.modelAgentRequired.description": "Escolha um agente e modelo antes de enviar um prompt.",
-1
View File
@@ -298,7 +298,6 @@ export const dict = {
"prompt.action.stop": "Zaustavi",
"prompt.toast.pasteUnsupported.title": "Nepodržan prilog",
"prompt.toast.attachmentDuplicate.title": "Ova datoteka je već učitana",
"prompt.toast.pasteUnsupported.description": "Ovdje se mogu priložiti samo slike, PDF-ovi ili tekstualne datoteke.",
"prompt.toast.modelAgentRequired.title": "Odaberi agenta i model",
"prompt.toast.modelAgentRequired.description": "Odaberi agenta i model prije slanja upita.",
-1
View File
@@ -296,7 +296,6 @@ export const dict = {
"prompt.action.stop": "Stop",
"prompt.toast.pasteUnsupported.title": "Ikke understøttet vedhæftning",
"prompt.toast.attachmentDuplicate.title": "Denne fil er allerede uploadet",
"prompt.toast.pasteUnsupported.description": "Kun billeder, PDF'er eller tekstfiler kan vedhæftes her.",
"prompt.toast.modelAgentRequired.title": "Vælg en agent og model",
"prompt.toast.modelAgentRequired.description": "Vælg en agent og model før du sender en forespørgsel.",
-1
View File
@@ -282,7 +282,6 @@ export const dict = {
"prompt.action.send": "Senden",
"prompt.action.stop": "Stopp",
"prompt.toast.pasteUnsupported.title": "Nicht unterstützter Anhang",
"prompt.toast.attachmentDuplicate.title": "Diese Datei wurde bereits hochgeladen",
"prompt.toast.pasteUnsupported.description": "Hier können nur Bilder, PDFs oder Textdateien angehängt werden.",
"prompt.toast.modelAgentRequired.title": "Wählen Sie einen Agenten und ein Modell",
"prompt.toast.modelAgentRequired.description":
-1
View File
@@ -301,7 +301,6 @@ export const dict = {
"prompt.toast.pasteUnsupported.title": "Unsupported attachment",
"prompt.toast.pasteUnsupported.description": "Only images, PDFs, or text files can be attached here.",
"prompt.toast.attachmentDuplicate.title": "This file has already been uploaded",
"prompt.toast.modelAgentRequired.title": "Select an agent and model",
"prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.",
"prompt.toast.worktreeCreateFailed.title": "Failed to create worktree",
-1
View File
@@ -297,7 +297,6 @@ export const dict = {
"prompt.action.stop": "Detener",
"prompt.toast.pasteUnsupported.title": "Adjunto no compatible",
"prompt.toast.attachmentDuplicate.title": "Este archivo ya se ha subido",
"prompt.toast.pasteUnsupported.description": "Solo se pueden adjuntar imágenes, PDFs o archivos de texto aquí.",
"prompt.toast.modelAgentRequired.title": "Selecciona un agente y modelo",
"prompt.toast.modelAgentRequired.description": "Elige un agente y modelo antes de enviar un prompt.",
-1
View File
@@ -277,7 +277,6 @@ export const dict = {
"prompt.action.send": "Envoyer",
"prompt.action.stop": "Arrêter",
"prompt.toast.pasteUnsupported.title": "Pièce jointe non prise en charge",
"prompt.toast.attachmentDuplicate.title": "Ce fichier a déjà été téléversé",
"prompt.toast.pasteUnsupported.description":
"Seules les images, les PDF ou les fichiers texte peuvent être joints ici.",
"prompt.toast.modelAgentRequired.title": "Sélectionnez un agent et un modèle",
-1
View File
@@ -276,7 +276,6 @@ export const dict = {
"prompt.action.send": "送信",
"prompt.action.stop": "停止",
"prompt.toast.pasteUnsupported.title": "サポートされていない添付ファイル",
"prompt.toast.attachmentDuplicate.title": "このファイルはすでにアップロードされています",
"prompt.toast.pasteUnsupported.description": "画像、PDF、またはテキストファイルのみ添付できます。",
"prompt.toast.modelAgentRequired.title": "エージェントとモデルを選択",
"prompt.toast.modelAgentRequired.description": "プロンプトを送信する前にエージェントとモデルを選択してください。",
-1
View File
@@ -264,7 +264,6 @@ export const dict = {
"prompt.action.send": "전송",
"prompt.action.stop": "중지",
"prompt.toast.pasteUnsupported.title": "지원되지 않는 첨부 파일",
"prompt.toast.attachmentDuplicate.title": "이 파일은 이미 업로드되었습니다",
"prompt.toast.pasteUnsupported.description": "이미지, PDF 또는 텍스트 파일만 첨부할 수 있습니다.",
"prompt.toast.modelAgentRequired.title": "에이전트 및 모델 선택",
"prompt.toast.modelAgentRequired.description": "프롬프트를 보내기 전에 에이전트와 모델을 선택하세요.",
-1
View File
@@ -288,7 +288,6 @@ export const dict = {
"prompt.action.stop": "Stopp",
"prompt.toast.pasteUnsupported.title": "Ikke støttet vedlegg",
"prompt.toast.attachmentDuplicate.title": "Denne filen er allerede lastet opp",
"prompt.toast.pasteUnsupported.description": "Kun bilder, PDF-er eller tekstfiler kan legges ved her.",
"prompt.toast.modelAgentRequired.title": "Velg en agent og modell",
"prompt.toast.modelAgentRequired.description": "Velg en agent og modell før du sender en forespørsel.",
-1
View File
@@ -278,7 +278,6 @@ export const dict = {
"prompt.action.send": "Wyślij",
"prompt.action.stop": "Zatrzymaj",
"prompt.toast.pasteUnsupported.title": "Nieobsługiwany załącznik",
"prompt.toast.attachmentDuplicate.title": "Ten plik został już przesłany",
"prompt.toast.pasteUnsupported.description": "Można tutaj załączać tylko obrazy, pliki PDF lub pliki tekstowe.",
"prompt.toast.modelAgentRequired.title": "Wybierz agenta i model",
"prompt.toast.modelAgentRequired.description": "Wybierz agenta i model przed wysłaniem zapytania.",
-1
View File
@@ -297,7 +297,6 @@ export const dict = {
"prompt.action.stop": "Остановить",
"prompt.toast.pasteUnsupported.title": "Неподдерживаемое вложение",
"prompt.toast.attachmentDuplicate.title": "Этот файл уже загружен",
"prompt.toast.pasteUnsupported.description": "Здесь можно прикрепить только изображения, PDF или текстовые файлы.",
"prompt.toast.modelAgentRequired.title": "Выберите агента и модель",
"prompt.toast.modelAgentRequired.description": "Выберите агента и модель перед отправкой запроса.",
-1
View File
@@ -297,7 +297,6 @@ export const dict = {
"prompt.action.stop": "หยุด",
"prompt.toast.pasteUnsupported.title": "ไฟล์แนบที่ไม่รองรับ",
"prompt.toast.attachmentDuplicate.title": "ไฟล์นี้ถูกอัปโหลดแล้ว",
"prompt.toast.pasteUnsupported.description": "แนบได้เฉพาะรูปภาพ, PDF หรือไฟล์ข้อความเท่านั้น",
"prompt.toast.modelAgentRequired.title": "เลือกเอเจนต์และโมเดล",
"prompt.toast.modelAgentRequired.description": "เลือกเอเจนต์และโมเดลก่อนส่งพร้อมท์",
-1
View File
@@ -302,7 +302,6 @@ export const dict = {
"prompt.action.stop": "Durdur",
"prompt.toast.pasteUnsupported.title": "Desteklenmeyen ek",
"prompt.toast.attachmentDuplicate.title": "Bu dosya zaten yüklendi",
"prompt.toast.pasteUnsupported.description": "Buraya yalnızca resimler, PDF'ler veya metin dosyaları eklenebilir.",
"prompt.toast.modelAgentRequired.title": "Bir ajan ve model seçin",
"prompt.toast.modelAgentRequired.description": "Komut göndermeden önce bir ajan ve model seçin.",
-1
View File
@@ -299,7 +299,6 @@ export const dict = {
"prompt.action.stop": "Зупинити",
"prompt.toast.pasteUnsupported.title": "Непідтримуване вкладення",
"prompt.toast.attachmentDuplicate.title": "Цей файл уже завантажено",
"prompt.toast.pasteUnsupported.description": "Сюди можна прикріплювати лише зображення, PDF або текстові файли.",
"prompt.toast.modelAgentRequired.title": "Виберіть агента та модель",
"prompt.toast.modelAgentRequired.description": "Виберіть агента та модель перед надсиланням запиту.",
-1
View File
@@ -316,7 +316,6 @@ export const dict = {
"prompt.action.send": "发送",
"prompt.action.stop": "停止",
"prompt.toast.pasteUnsupported.title": "不支持的附件",
"prompt.toast.attachmentDuplicate.title": "此文件已上传",
"prompt.toast.pasteUnsupported.description": "此处仅能附加图片、PDF 或文本文件。",
"prompt.toast.modelAgentRequired.title": "请选择智能体和模型",
"prompt.toast.modelAgentRequired.description": "发送提示前请先选择智能体和模型。",
-1
View File
@@ -297,7 +297,6 @@ export const dict = {
"prompt.action.stop": "停止",
"prompt.toast.pasteUnsupported.title": "不支援的附件",
"prompt.toast.attachmentDuplicate.title": "此檔案已上傳",
"prompt.toast.pasteUnsupported.description": "此處僅能附加圖片、PDF 或文字檔案。",
"prompt.toast.modelAgentRequired.title": "請選擇代理程式和模型",
"prompt.toast.modelAgentRequired.description": "傳送提示前請先選擇代理程式和模型。",
+1 -1
View File
@@ -27,4 +27,4 @@ export {
type WslServersState,
} from "./wsl/types"
export { ServerConnection } from "./context/server"
export { createDraftStore, type DraftStore } from "./utils/draft-store"
export { handleNotificationClick } from "./utils/notification-click"
@@ -63,7 +63,6 @@ export function DirectoryDataProvider(
<DataProvider
data={sync().data}
directory={directory}
sessionID={params.id}
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
onSessionHref={href}
>
+1 -1
View File
@@ -354,7 +354,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
<button
type="button"
class="flex items-center text-text-interactive-base gap-1"
onClick={() => platform.openExternal("https://opencode.ai/desktop-feedback")}
onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
>
<div>{language.t("error.page.report.discord")}</div>
<Icon name="discord" class="text-text-interactive-base" />
@@ -120,7 +120,7 @@ export function createHomeProjectsController(home: HomeController) {
},
utility: {
settings: openSettings,
help: () => platform.openExternal("https://opencode.ai/desktop-feedback"),
help: () => platform.openLink("https://opencode.ai/desktop-feedback"),
},
}
}
@@ -482,6 +482,7 @@ function HomeProjectRow(
class="pr-16 disabled:opacity-60"
classList={{
"bg-v2-background-bg-layer-01 text-v2-text-text-base": sortable.isDragSource(),
"[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]": sortable.isDragSource(),
}}
data-selected={props.selected ? "" : undefined}
aria-current={props.selected ? "page" : undefined}
@@ -589,9 +590,9 @@ function HomeProjectNavButton(props: JSX.ButtonHTMLAttributes<HTMLButtonElement>
class={`
flex h-7 min-w-0 w-full shrink-0 cursor-default items-center gap-2 rounded-[6px] bg-transparent px-1.5 text-left
text-v2-text-text-muted [font-weight:440] transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base
hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]
data-[selected]:bg-v2-background-bg-layer-03 data-[selected]:text-v2-text-text-base
data-[selected]:hover:bg-v2-background-bg-layer-03
data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-03
focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:outline-none
focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]
${local.class ?? ""}
@@ -1,5 +1,5 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { type Accessor, createMemo, For, Show, Suspense } from "solid-js"
import { type Accessor, createMemo, For, Show } from "solid-js"
import { Spinner } from "@opencode-ai/ui/spinner"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
@@ -39,6 +39,7 @@ function isBackgroundOpen(event: MouseEvent) {
export type HomeSessionsViewProps = {
language: ReturnType<typeof useLanguage>
groups: Accessor<HomeSessionGroup[]>
loading: Accessor<boolean>
showProjectName: Accessor<boolean>
server: Accessor<ServerConnection.Key>
canCreateSession: Accessor<boolean>
@@ -80,22 +81,20 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
>
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
<HomeSessionSearch {...props} />
<Suspense>
<Show when={props.groups().length > 0 && props.canCreateSession()}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<ButtonV2
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={props.onCreateSession}
>
{props.language.t("command.session.new")}
</ButtonV2>
</div>
</Show>
</Suspense>
<Show when={props.groups().length > 0 && props.canCreateSession()}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<ButtonV2
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={props.onCreateSession}
>
{props.language.t("command.session.new")}
</ButtonV2>
</div>
</Show>
</div>
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
<div
@@ -105,7 +104,8 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
/>
</div>
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
<Suspense
<Show
when={!props.loading()}
fallback={
<div class="pt-3">
<HomeSessionSkeleton label={props.language.t("common.loading")} />
@@ -141,7 +141,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
</For>
</div>
</Show>
</Suspense>
</Show>
</div>
</section>
)
@@ -12,6 +12,7 @@ export function HomeSessions(props: {
<HomeSessionsView
language={props.sessions.copy.language}
groups={props.sessions.data.groups}
loading={props.sessions.data.loading}
showProjectName={props.sessions.session.showProjectName}
server={props.sessions.session.server}
canCreateSession={props.sessions.session.canCreate}
+4
View File
@@ -1,13 +1,17 @@
import { createEffect, Suspense, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { DebugBar } from "@/components/debug-bar"
import { TabsInfoPopup } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click"
import { setV2Toast, ToastRegion } from "@/utils/toast"
export default function NewLayout(props: ParentProps) {
const platform = usePlatform()
const navigate = useNavigate()
setNavigate(navigate)
const [state, setState] = createStore({ debugTools: true })
createEffect(() => setV2Toast(true))
+10 -7
View File
@@ -33,7 +33,8 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useProviders } from "@/hooks/use-providers"
import { dismissToast, setV2Toast, showToast, ToastRegion } from "@/utils/toast"
import { toaster } from "@opencode-ai/ui/toast"
import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
import { useServerSDK } from "@/context/server-sdk"
import { normalizeProjectInfo } from "@/context/global-sync/utils"
import { clearWorkspaceTerminals } from "@/context/terminal"
@@ -44,6 +45,7 @@ import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry"
import { playSoundById } from "@/utils/sound"
import { createAim } from "@/utils/aim"
import { setNavigate } from "@/utils/notification-click"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { setSessionHandoff } from "@/pages/session/handoff"
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
@@ -116,6 +118,7 @@ export default function LegacyLayout(props: ParentProps) {
const notification = useNotification()
const permission = usePermission()
const navigate = useNavigate()
setNavigate(navigate)
const providers = useProviders(() => undefined)
const dialog = useDialog()
const command = useCommand()
@@ -381,7 +384,7 @@ export default function LegacyLayout(props: ParentProps) {
const dismissSessionAlert = (sessionKey: string) => {
const toastId = toastBySession.get(sessionKey)
if (toastId === undefined) return
dismissToast(toastId)
toaster.dismiss(toastId)
toastBySession.delete(sessionKey)
alertedAtBySession.delete(sessionKey)
}
@@ -446,13 +449,13 @@ export default function LegacyLayout(props: ParentProps) {
void playSoundById(settings.sounds.permissions())
}
if (settings.notifications.permissions()) {
void platform.notify(title, description, () => navigate(href))
void platform.notify(title, description, href)
}
}
if (e.details.type === "question.asked") {
if (settings.notifications.agent()) {
void platform.notify(title, description, () => navigate(href))
void platform.notify(title, description, href)
}
}
@@ -1456,7 +1459,7 @@ export default function LegacyLayout(props: ParentProps) {
title: language.t("workspace.resetting.title"),
description: language.t("workspace.resetting.description"),
})
const dismiss = () => dismissToast(progress)
const dismiss = () => toaster.dismiss(progress)
const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => [])
@@ -2249,7 +2252,7 @@ export default function LegacyLayout(props: ParentProps) {
settingsKeybind={() => command.keybind("settings.open")}
onOpenSettings={openSettings}
helpLabel={() => language.t("sidebar.help")}
onOpenHelp={() => platform.openExternal("https://opencode.ai/desktop-feedback")}
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
renderPanel={() =>
mobile ? <SidebarPanel project={currentProject} mobile /> : <SidebarPanel project={currentProject} merged />
}
@@ -2447,7 +2450,7 @@ function UpdateAvailableToast(props: {
onCleanup(() => {
if (toastId === undefined) return
dismissToast(toastId)
toaster.dismiss(toastId)
})
return null
@@ -51,7 +51,10 @@ export function NewSessionView(props: {
<Show
when={props.workspace.bar.visible()}
fallback={
<PromptGitStatus branch={props.workspace.bar.branch()} noGit={!props.workspace.project.git()} />
<PromptGitStatus
branch={props.workspace.bar.branch()}
noGit={!props.workspace.project.git()}
/>
}
>
<PromptWorkspaceSelector
@@ -78,9 +81,9 @@ export function NewSessionStatus(props: { mount: Accessor<HTMLElement | null>; v
const language = useLanguage()
return (
<Show when={props.mount()} keyed>
<Show when={props.mount()}>
{(mount) => (
<Portal mount={mount}>
<Portal mount={mount()}>
<Show when={props.visible()}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopoverV2 />
@@ -59,7 +59,6 @@ import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/ses
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff
const FILE_TREE_WIDTH_MIN = 240
function renderDiff(value: ReviewDiff): value is RenderDiff {
return typeof value.file === "string"
@@ -105,14 +104,13 @@ export function SessionSidePanel(props: {
}),
)
const open = createMemo(() => reviewOpen() || fileOpen())
const fileTreeWidth = createMemo(() => Math.max(FILE_TREE_WIDTH_MIN, layout.fileTree.width()))
const reviewTab = createMemo(() => isDesktop())
const panelWidth = createMemo(() => {
if (!open()) return "0px"
if (reviewOpen()) return "auto"
return `${fileTreeWidth()}px`
return `${layout.fileTree.width()}px`
})
const treeWidth = createMemo(() => (fileOpen() ? `${fileTreeWidth()}px` : "0px"))
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
const diffs = createMemo(() => props.diffs().filter(renderDiff))
const diffFiles = createMemo(() => diffs().map((d) => d.file))
@@ -847,8 +845,8 @@ export function SessionSidePanel(props: {
<ResizeHandle
direction="horizontal"
edge="start"
size={fileTreeWidth()}
min={FILE_TREE_WIDTH_MIN}
size={layout.fileTree.width()}
min={200}
max={480}
onResize={(width) => {
props.size.touch()
@@ -646,7 +646,7 @@ export function MessageTimeline(props: {
const viewShare = () => {
const url = shareUrl()
if (!url) return
platform.openExternal(url)
platform.openLink(url)
}
const errorMessage = (err: unknown) => {
@@ -167,41 +167,4 @@ describe("current session timeline rows", () => {
"thinking:msg_2",
])
})
test("removes a failed assistant error when the turn continues streaming", () => {
const source = [
{ id: "msg_user", type: "user", text: "recover", time: { created: 1 } },
{
id: "msg_failed",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
error: { type: "ProviderError", message: "temporary failure" },
time: { created: 2, completed: 3 },
},
{
id: "msg_recovery",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "streaming again" }],
time: { created: 4 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows(
source,
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"busy",
true,
normalized.messages.filter((message) => message.role === "user"),
)
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart"])
})
})
@@ -116,8 +116,7 @@ export namespace Timeline {
const compaction = userParts.some((p) => p.type === "compaction")
const interruptedMessageIndex = assistantMessages.findIndex((m) => m.error?.name === "MessageAbortedError")
const interrupted = interruptedMessageIndex !== -1
const latestError = assistantMessages.at(-1)?.error
const error = latestError?.name === "MessageAbortedError" ? undefined : latestError
const error = assistantMessages.find((m) => m.error && m.error.name !== "MessageAbortedError")?.error
const assistantPartRefs = assistantMessages.flatMap((message, messageIndex) =>
getMessageParts(message.id)
-171
View File
@@ -1,171 +0,0 @@
import type { AsyncStorage } from "@solid-primitives/storage"
export type BlobReference = { id: string; url: string }
type Driver = {
get(key: string): Promise<string | null>
set(key: string, value: string): Promise<void>
remove(key: string): Promise<void>
putBlob(blob: Blob): Promise<string>
getBlob(id: string): Promise<Blob | null>
}
export type DraftStore = AsyncStorage & { putBlob(blob: Blob): Promise<BlobReference> }
const urls = new Map<string, string>()
function blobUrl(id: string, blob: Blob) {
const existing = urls.get(id)
if (existing) return existing
const url = URL.createObjectURL(blob)
urls.set(id, url)
return url
}
async function blobID(blob: Blob) {
const id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer())))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("")
return id
}
export async function createBlobReference(blob: Blob): Promise<BlobReference> {
const id = await blobID(blob)
return { id, url: blobUrl(id, blob) }
}
export function createDraftStore(driver: Driver): DraftStore {
const versions = new Map<string, number>()
const putBlob = async (blob: Blob) => {
const id = await driver.putBlob(blob)
return { id, url: blobUrl(id, blob) }
}
const encode = async (value: unknown): Promise<unknown> => {
if (Array.isArray(value)) return Promise.all(value.map(encode))
if (!value || typeof value !== "object") return value
const item = value as Record<string, unknown>
if (item.type === "image" && typeof item.dataUrl === "string") {
const blob = await fetch(item.dataUrl).then((response) => response.blob())
const { dataUrl: _, ...rest } = item
return { ...rest, blob: { id: await driver.putBlob(blob) } }
}
if ("blob" in item && item.blob && typeof item.blob === "object") {
const blob = item.blob as Record<string, unknown>
if (typeof blob.id === "string" && blob.id.startsWith("data:")) {
const data = await fetch(blob.id).then((response) => response.blob())
return { ...item, blob: { id: await driver.putBlob(data) } }
}
return { ...item, blob: { id: blob.id } }
}
return Object.fromEntries(
await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await encode(entry)])),
)
}
const decode = async (value: unknown): Promise<unknown> => {
if (Array.isArray(value)) return Promise.all(value.map(decode))
if (!value || typeof value !== "object") return value
const item = value as Record<string, unknown>
if (item.blob && typeof item.blob === "object") {
const ref = item.blob as Record<string, unknown>
if (typeof ref.id === "string") {
const blob = await driver.getBlob(ref.id)
if (blob) return { ...item, blob: { id: ref.id, url: blobUrl(ref.id, blob) } }
}
}
return Object.fromEntries(
await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await decode(entry)])),
)
}
return {
getItem: async (key) => {
const value = await driver.get(key)
return value === null ? null : JSON.stringify(await decode(JSON.parse(value)))
},
setItem: async (key, value) => {
const version = (versions.get(key) ?? 0) + 1
versions.set(key, version)
const encoded = JSON.stringify(await encode(JSON.parse(value)))
if (versions.get(key) === version) await driver.set(key, encoded)
},
removeItem: async (key) => {
versions.set(key, (versions.get(key) ?? 0) + 1)
await driver.remove(key)
},
putBlob,
}
}
export function createBrowserDraftStore(): DraftStore {
const request = indexedDB.open("opencode-drafts", 1)
request.addEventListener("upgradeneeded", () => {
request.result.createObjectStore("documents")
request.result.createObjectStore("blobs")
})
const db = new Promise<IDBDatabase>((resolve, reject) => {
request.addEventListener("success", () => {
const database = request.result
const transaction = database.transaction(["documents", "blobs"], "readwrite")
const documents = transaction.objectStore("documents").getAll()
documents.addEventListener("success", () => {
const used = new Set<string>()
JSON.parse(`[${documents.result.join(",")}]`, (_key, item) => {
if (item?.blob && typeof item.blob.id === "string") used.add(item.blob.id)
return item
})
const blobs = transaction.objectStore("blobs").openKeyCursor()
blobs.addEventListener("success", () => {
const cursor = blobs.result
if (!cursor) return
if (!used.has(String(cursor.key))) cursor.delete()
cursor.continue()
})
})
transaction.addEventListener("complete", () => resolve(database))
transaction.addEventListener("abort", () => resolve(database))
})
request.addEventListener("error", () => reject(request.error))
})
const get = async (store: string, key: string) => {
const result = (await db).transaction(store).objectStore(store).get(key)
return new Promise<unknown>((resolve, reject) => {
result.addEventListener("success", () => resolve(result.result))
result.addEventListener("error", () => reject(result.error))
})
}
const write = async (store: string, key: string, value?: unknown) => {
const transaction = (await db).transaction(store, "readwrite")
if (value === undefined) transaction.objectStore(store).delete(key)
else transaction.objectStore(store).put(value, key)
return new Promise<void>((resolve, reject) => {
transaction.addEventListener("complete", () => resolve())
transaction.addEventListener("error", () => reject(transaction.error))
})
}
return createDraftStore({
get: async (key) => ((await get("documents", key)) as string | undefined) ?? null,
set: (key, value) => write("documents", key, value),
remove: (key) => write("documents", key),
putBlob: async (blob) => {
const id = await blobID(blob)
await write("blobs", id, blob)
return id
},
getBlob: async (id) => ((await get("blobs", id)) as Blob | undefined) ?? null,
})
}
export async function blobDataUrl(blob: BlobReference, mime: string) {
const data = await fetch(blob.url).then((response) => response.blob())
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener("error", () => reject(reader.error))
reader.addEventListener("load", () => {
const value = typeof reader.result === "string" ? reader.result : ""
resolve(`data:${mime};base64,${value.slice(value.indexOf(",") + 1)}`)
})
reader.readAsDataURL(data)
})
}
export function createLegacyBlobReference(dataUrl: string): BlobReference {
return { id: dataUrl, url: dataUrl }
}
@@ -0,0 +1,27 @@
import { afterEach, describe, expect, test } from "bun:test"
import { handleNotificationClick, setNavigate } from "./notification-click"
describe("notification click", () => {
afterEach(() => {
setNavigate(undefined as any)
})
test("navigates via registered navigate function", () => {
const calls: string[] = []
setNavigate((href) => calls.push(href))
handleNotificationClick("/abc/session/123")
expect(calls).toEqual(["/abc/session/123"])
})
test("does not navigate when href is missing", () => {
const calls: string[] = []
setNavigate((href) => calls.push(href))
handleNotificationClick(undefined)
expect(calls).toEqual([])
})
test("falls back to location.assign without registered navigate", () => {
handleNotificationClick("/abc/session/123")
// falls back to window.location.assign — no error thrown
})
})
@@ -0,0 +1,13 @@
let nav: ((href: string) => void) | undefined
export const setNavigate = (fn: (href: string) => void) => {
nav = fn
}
export const handleNotificationClick = (href?: string) => {
window.focus()
if (!href) return
if (nav) return nav(href)
console.warn("notification-click: navigate function not set, falling back to window.location.assign")
window.location.assign(href)
}
+5 -47
View File
@@ -15,7 +15,6 @@ type PersistedWithReady<T> = [
]
type PersistTarget = {
draft?: boolean
storage?: string
scope?: "window"
legacyStorageNames?: string[]
@@ -296,14 +295,6 @@ async function removeAsync(storage: AsyncStorage, key: string) {
} catch {}
}
function toAsyncStorage(storage: SyncStorage | AsyncStorage): AsyncStorage {
return {
getItem: async (key) => storage.getItem(key),
setItem: async (key, value) => storage.setItem(key, value),
removeItem: async (key) => storage.removeItem(key),
}
}
async function migrateLegacyAsync(input: {
current: AsyncStorage
legacyStore?: AsyncStorage
@@ -522,9 +513,6 @@ export const Persist = {
if (session) return Persist.serverSession(scope, dir, session, key, legacy)
return Persist.serverWorkspace(scope, dir, key, legacy)
},
prompt(target: PersistTarget): PersistTarget {
return { ...target, draft: true }
},
}
function resolveTarget(target: PersistTarget, platform: Platform): PersistTarget {
@@ -538,12 +526,9 @@ function resolveTarget(target: PersistTarget, platform: Platform): PersistTarget
}
export function removePersisted(
target: { draft?: boolean; storage?: string; legacyStorageNames?: string[]; key: string },
target: { storage?: string; legacyStorageNames?: string[]; key: string },
platform?: Platform,
) {
if (target.draft && platform?.draftStore) {
void platform.draftStore.removeItem(`${target.storage ?? "default"}:${target.key}`)
}
const isDesktop = platform?.platform === "desktop" && !!platform.storage
if (isDesktop) {
@@ -576,17 +561,8 @@ export function persisted<T>(
const legacy = config.legacy ?? []
const isDesktop = platform.platform === "desktop" && !!platform.storage
const draft = config.draft ? platform.draftStore : undefined
const currentStorage = (() => {
if (draft) {
const prefix = `${config.storage ?? "default"}:`
return {
getItem: (key: string) => draft.getItem(prefix + key),
setItem: (key: string, value: string) => draft.setItem(prefix + key, value),
removeItem: (key: string) => draft.removeItem(prefix + key),
} satisfies AsyncStorage
}
if (isDesktop) return platform.storage?.(config.storage)
if (!config.storage) return localStorageDirect()
return localStorageWithPrefix(config.storage)
@@ -601,7 +577,7 @@ export function persisted<T>(
const legacyStorageNames = config.legacyStorageNames ?? []
const storage = (() => {
if (!isDesktop && !draft) {
if (!isDesktop) {
const current = currentStorage as SyncStorage
const legacyStore = legacyStorage as SyncStorage
const legacyStores = legacyStorageNames.map(localStorageWithPrefix)
@@ -633,26 +609,15 @@ export function persisted<T>(
const current = currentStorage as AsyncStorage
const legacyStore = legacyStorage as AsyncStorage | undefined
const oldCurrent = draft
? isDesktop
? platform.storage?.(config.storage)
: config.storage
? localStorageWithPrefix(config.storage)
: localStorageDirect()
: undefined
const legacyStores = [
oldCurrent,
...legacyStorageNames.map((name) => (isDesktop ? platform.storage?.(name) : localStorageWithPrefix(name))),
]
const legacyStores = legacyStorageNames
.map((name) => platform.storage?.(name) as AsyncStorage | undefined)
.filter((x) => !!x)
.map(toAsyncStorage)
let draftLatest: string | undefined
const api: AsyncStorage = {
getItem: async (key) => {
const value = await readCurrentAsync({ storage: current, key, defaults, migrate: config.migrate })
if (value !== undefined) return value
const migrated = await migrateLegacyAsync({
return migrateLegacyAsync({
current,
legacyStore,
stores: legacyStores,
@@ -661,15 +626,8 @@ export function persisted<T>(
defaults,
migrate: config.migrate,
})
if (draftLatest === undefined) {
if (draft && migrated !== null) return (await current.getItem(key)) ?? migrated
return migrated
}
await current.setItem(key, draftLatest)
return draftLatest
},
setItem: async (key, value) => {
if (draft) draftLatest = value
await current.setItem(key, value)
},
removeItem: async (key) => {
+2 -12
View File
@@ -37,18 +37,8 @@ describe("extractPromptFromParts", () => {
expect(result).toHaveLength(3)
expect(result[0]).toMatchObject({ type: "text", content: "check these" })
expect(result.slice(1)).toMatchObject([
{
type: "image",
filename: "a.png",
mime: "image/png",
blob: expect.objectContaining({ id: expect.any(String) }),
},
{
type: "image",
filename: "b.pdf",
mime: "application/pdf",
blob: expect.objectContaining({ id: expect.any(String) }),
},
{ type: "image", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
{ type: "image", filename: "b.pdf", mime: "application/pdf", dataUrl: "data:application/pdf;base64,BBB" },
])
})
})
+1 -2
View File
@@ -1,6 +1,5 @@
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@opencode-ai/sdk/v2"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { createLegacyBlobReference } from "@/utils/draft-store"
type Inline =
| {
@@ -108,7 +107,7 @@ export function extractPromptFromParts(parts: Part[], opts?: { directory?: strin
id: filePart.id,
filename: filePart.filename ?? attachmentName,
mime: filePart.mime,
blob: createLegacyBlobReference(filePart.url),
dataUrl: filePart.url,
})
}
}
+2 -15
View File
@@ -1,12 +1,6 @@
import { Icon, type IconProps } from "@opencode-ai/ui/icon"
import {
Toast,
showToast as showLegacyToast,
toaster as legacyToaster,
type ToastOptions,
type ToastVariant,
} from "@opencode-ai/ui/toast"
import { ToastV2, showToastV2, toasterV2 } from "@opencode-ai/ui/v2/toast-v2"
import { Toast, showToast as showLegacyToast, type ToastOptions, type ToastVariant } from "@opencode-ai/ui/toast"
import { ToastV2, showToastV2 } from "@opencode-ai/ui/v2/toast-v2"
let v2 = false
@@ -33,13 +27,6 @@ export function showToast(options: ToastOptions | string) {
})
}
// v1 and v2 ids come from separate registries, so dismissal has to use the same
// implementation that issued the id.
export function dismissToast(toastId: number) {
if (!v2) return legacyToaster.dismiss(toastId)
return toasterV2.dismiss(toastId)
}
function resolveIcon(icon: IconProps["name"] | undefined, variant: ToastVariant | undefined) {
const name = icon ?? (variant === "success" ? "check" : undefined)
if (!name) return
@@ -1,10 +1,7 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createPromptAttachmentsCore } from "@/components/prompt-input/attachments"
import { createPromptState } from "@/context/prompt"
import { createPromptInputV2Attachments } from "../../session-ui/src/v2/components/prompt-input/attachments"
import type { PromptInputV2Prompt } from "../../session-ui/src/v2/components/prompt-input/types"
describe("prompt attachment session ownership", () => {
test("adds an asynchronously read image to the session where the read started", async () => {
@@ -88,88 +85,6 @@ describe("prompt attachment session ownership", () => {
})
})
test("rejects a duplicate native clipboard attachment in the V2 prompt store", async () => {
await createRoot(async (dispose) => {
const [state, setState] = createStore({ prompt: [] as PromptInputV2Prompt })
const duplicate = Promise.withResolvers<void>()
const files = [
new File(["hello"], "clipboard-1.txt", { type: "text/plain" }),
new File(["hello"], "clipboard-2.txt", { type: "text/plain" }),
]
const attachments = createPromptInputV2Attachments({
capture: () => ({
current: () => state.prompt,
cursor: () => 0,
set: (prompt) => setState("prompt", prompt),
}),
editor: () => document.createElement("div"),
focusEditor: () => undefined,
addPart: () => false,
setDraggingType: () => undefined,
directory: () => "/",
isDialogActive: () => false,
warn: () => undefined,
duplicate: duplicate.resolve,
onError: () => undefined,
readClipboardImage: async () => files.shift() ?? null,
})
const event = {
clipboardData: { items: [], getData: () => "" },
preventDefault: () => undefined,
stopPropagation: () => undefined,
} as unknown as ClipboardEvent
await attachments.handlePaste(event)
await attachments.handlePaste(event)
await duplicate.promise
expect(state.prompt).toHaveLength(1)
dispose()
})
})
test("rejects desktop duplicates and keeps changed files in the V2 prompt store", async () => {
await createRoot(async (dispose) => {
const [state, setState] = createStore({ prompt: [] as PromptInputV2Prompt })
const duplicates: string[] = []
const attachments = createPromptInputV2Attachments({
capture: () => ({
current: () => state.prompt,
cursor: () => 0,
set: (prompt) => setState("prompt", prompt),
}),
editor: () => document.createElement("div"),
focusEditor: () => undefined,
addPart: () => false,
setDraggingType: () => undefined,
directory: () => "/",
isDialogActive: () => false,
warn: () => undefined,
duplicate: () => duplicates.push("duplicate"),
onError: () => undefined,
getPathForFile: (file) => (file.name === "browser.txt" ? "" : `/tmp/${file.name}`),
})
const first = new File(["first"], "a.txt", { type: "text/plain" })
const second = new File(["second"], "b.txt", { type: "text/plain" })
await attachments.addAttachments([first, second])
await attachments.addAttachments([first, second])
expect(state.prompt).toHaveLength(2)
expect(duplicates).toEqual(["duplicate", "duplicate"])
await attachments.addAttachments([new File(["edited"], "a.txt", { type: "text/plain" })])
expect(state.prompt).toHaveLength(3)
await attachments.addAttachments([
new File(["same"], "browser.txt", { type: "text/plain" }),
new File(["same"], "browser.txt", { type: "text/plain" }),
])
expect(state.prompt).toHaveLength(4)
expect(duplicates).toHaveLength(3)
dispose()
})
})
function images(prompt: ReturnType<typeof createPromptState>) {
return prompt.current().filter((part) => part.type === "image")
}
@@ -2,7 +2,6 @@ import { beforeAll, describe, expect, mock, test } from "bun:test"
import type { AsyncStorage } from "@solid-primitives/storage"
import { createEffect, createRoot } from "solid-js"
import { ServerScope } from "@/utils/server-scope"
import { createDraftStore } from "@/utils/draft-store"
let Prompt: typeof import("@/context/prompt")
let read: ((value: string | null) => void) | undefined
@@ -31,7 +30,7 @@ beforeAll(async () => {
}),
}))
mock.module("@/context/platform", () => ({
usePlatform: () => ({ platform: "desktop", storage: () => storage, draftStore: storage }),
usePlatform: () => ({ platform: "desktop", storage: () => storage }),
}))
Prompt = await import("@/context/prompt")
@@ -70,50 +69,3 @@ describe("prompt persistence", () => {
})
})
})
test("moves legacy image data URLs into blobs and hydrates object URLs", async () => {
const documents = new Map<string, string>()
const blobs = new Map<string, Blob>()
const store = createDraftStore({
get: async (key) => documents.get(key) ?? null,
set: async (key, value) => void documents.set(key, value),
remove: async (key) => void documents.delete(key),
putBlob: async (blob) => {
const id = String(blob.size)
blobs.set(id, blob)
return id
},
getBlob: async (id) => blobs.get(id) ?? null,
})
await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "image", dataUrl: "data:image/png;base64,YQ==" }] }))
expect(documents.get("prompt")).not.toContain("dataUrl")
const value = JSON.parse((await store.getItem("prompt"))!)
expect(value.prompt[0].blob.id).toBe("1")
expect(value.prompt[0].blob.url).toStartWith("blob:")
})
test("does not let delayed blob migration overwrite a newer draft", async () => {
const documents = new Map<string, string>()
const migration = Promise.withResolvers<void>()
const store = createDraftStore({
get: async () => null,
set: async (key, value) => void documents.set(key, value),
remove: async () => undefined,
putBlob: async () => {
await migration.promise
return "blob"
},
getBlob: async () => null,
})
const older = store.setItem(
"prompt",
JSON.stringify({ prompt: [{ type: "image", dataUrl: "data:image/png;base64,YQ==" }] }),
)
await Bun.sleep(0)
await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "text", content: "latest" }] }))
migration.resolve()
await older
expect(documents.get("prompt")).toContain("latest")
})
@@ -1,64 +0,0 @@
import { expect, test } from "bun:test"
import { MetaProvider, Title } from "@solidjs/meta"
import { MemoryRouter, Route, createMemoryHistory, useParams } from "@solidjs/router"
import { createMemo } from "solid-js"
import { createComponent, render } from "solid-js/web"
test("route cleanup cannot invalidate an owner list being disposed", async () => {
const host = document.createElement("div")
document.body.append(host)
const history = createMemoryHistory()
const RepoPage = () => {
const params = useParams<{ id?: string }>()
const title = createMemo(() => params.id ?? "")
const button = document.createElement("button")
button.textContent = "Back"
button.addEventListener("click", () => history.set({ value: "/", scroll: false, replace: false }))
return [
createComponent(Title, {
get children() {
return title()
},
}),
button,
]
}
const HomePage = () => {
const button = document.createElement("button")
button.textContent = "Go"
button.addEventListener("click", () => history.set({ value: "/project", scroll: false, replace: false }))
return button
}
const App = () =>
createComponent(MetaProvider, {
get children() {
return createComponent(MemoryRouter, {
history,
get children() {
return [
createComponent(Route, { path: "/", component: HomePage }),
createComponent(Route, { path: "/:id", component: RepoPage }),
]
},
})
},
})
const dispose = render(() => createComponent(App, {}), host)
const go = host.querySelector("button")
expect(go?.textContent).toBe("Go")
go?.click()
await new Promise((resolve) => setTimeout(resolve, 0))
const back = host.querySelector("button")
expect(back?.textContent).toBe("Back")
back?.click()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(host.querySelector("button")?.textContent).toBe("Go")
dispose()
host.remove()
})
+1 -39
View File
@@ -1,46 +1,8 @@
import { beforeEach, describe, expect, test } from "bun:test"
import { describe, expect, test } from "bun:test"
import { createSignal, type JSX } from "solid-js"
import { showToastV2, toasterV2 } from "@opencode-ai/ui/v2/toast-v2"
describe("showToastV2", () => {
// The toast registry is module state, so each test starts from an empty stack.
beforeEach(() => {
toasterV2.dismiss()
})
test("coalesces exact active content", () => {
const first = showToastV2({ title: "Repeated error", description: "Try again" })
const second = showToastV2({ title: "Repeated error", description: "Try again" })
const different = showToastV2({ title: "Repeated error", description: "A different error" })
expect(second).toBe(first)
expect(different).not.toBe(first)
toasterV2.dismiss(first)
toasterV2.dismiss(different)
})
test("allows dismissed content to appear again", () => {
const first = showToastV2("Dismiss and retry")
toasterV2.dismiss(first)
const second = showToastV2("Dismiss and retry")
expect(second).not.toBe(first)
toasterV2.dismiss(second)
})
test("recreates matching content when it is not the topmost toast", () => {
const first = showToastV2("First toast")
const topmost = showToastV2("Topmost toast")
const repeated = showToastV2("First toast")
expect(repeated).not.toBe(first)
toasterV2.dismiss(topmost)
toasterV2.dismiss(repeated)
})
test("creates no reactive computations at call time", () => {
const [tick, setTick] = createSignal(0)
let reads = 0
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.18.12",
"version": "1.18.8",
"type": "module",
"license": "MIT",
"bin": {
+1 -1
View File
@@ -1,4 +1,4 @@
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.opencode.ai"
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
export const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/codemode",
"version": "1.18.12",
"version": "1.18.8",
"description": "Effect-native confined code execution over schema-described tools",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.18.12",
"version": "1.18.8",
"type": "module",
"license": "MIT",
"scripts": {
+10 -21
View File
@@ -252,9 +252,9 @@ export const dict = {
"zen.privacy.exceptionsLink": "الاستثناءات التالية",
"go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع",
"go.banner.text": "يحصل GPT 5.6 Luna على حدود استخدام مضاعفة لفترة محدودة",
"go.banner.text": "يحصل Kimi K3 على حدود استخدام مضاعفة لفترة محدودة",
"go.meta.description":
"يبدأ Go بسعر $5 للشهر الأول، ثم $10/شهر، مع حدود استخدام سخية ووصول موثوق إلى نماذج البرمجة الرائدة.",
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash وHy3.",
"go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع",
"go.hero.body":
"يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.",
@@ -301,7 +301,8 @@ export const dict = {
"go.problem.item1": "أسعار اشتراك منخفضة التكلفة",
"go.problem.item2": "حدود سخية ووصول موثوق",
"go.problem.item3": "مصمم لأكبر عدد ممكن من المبرمجين",
"go.problem.item4": "مجموعة منسقة من النماذج المختبرة للبرمجة الوكيلة",
"go.problem.item4":
"يتضمن Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash وHy3",
"go.how.title": "كيف يعمل Go",
"go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.",
"go.how.step1.title": "أنشئ حسابًا",
@@ -325,7 +326,7 @@ export const dict = {
"go.faq.a2": "يتضمن Go النماذج المدرجة أدناه، مع حدود سخية وإتاحة موثوقة.",
"go.faq.q3": "هل Go هو نفسه Zen؟",
"go.faq.a3":
"لا. يعتمد Zen على الدفع حسب الاستخدام، بينما يبدأ Go بسعر $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى مجموعة منسقة من النماذج.",
"لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash وHy3.",
"go.faq.q4": "كم تكلفة Go؟",
"go.faq.a4.p1.beforePricing": "تكلفة Go",
"go.faq.a4.p1.pricingLink": "$5 للشهر الأول",
@@ -334,20 +335,8 @@ export const dict = {
"go.faq.a4.p2.accountLink": "حسابك",
"go.faq.a4.p3": "ألغِ في أي وقت.",
"go.faq.q5": "ماذا عن البيانات والخصوصية؟",
"go.faq.a5.model": "النموذج",
"go.faq.a5.training": "تدريب النموذج",
"go.faq.a5.retention": "الاحتفاظ بالبيانات",
"go.faq.a5.retention30": "30 يومًا",
"go.faq.a5.retention0": "0 أيام",
"go.faq.a5.used": "مستخدَمة",
"go.faq.a5.notUsed": "غير مستخدَمة",
"go.faq.a5.noAgreement": "لا توجد اتفاقية",
"go.faq.a5.grokRetention":
"تعطّل ZDR ميزات API مهمة تعتمد على البيانات المخزنة، بما في ذلك Responses API ذات الحالة، وFiles and Collections، وBatch API.",
"go.faq.a5.gptRetention":
"تُنشأ سجلات مراقبة إساءة الاستخدام لكل استخدام لميزات API، ويُحتفظ بها لمدة تصل إلى 30 يومًا.",
"go.faq.a5.learnMore": "اعرف المزيد",
"go.faq.a5.deepseekRetention": "تُجدَّد اتفاقية ZDR شهريًا. الاتفاقية الحالية سارية حتى 31 أغسطس 2026.",
"go.faq.a5.body":
"تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر. يتبع مزودونا سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج.",
"go.faq.a5.beforeExceptions":
"تتم استضافة نماذج Go في الولايات المتحدة. يتبع المزودون سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج، مع",
"go.faq.a5.exceptionsLink": "الاستثناءات التالية",
@@ -360,7 +349,7 @@ export const dict = {
"go.faq.q9": "ما الفرق بين النماذج المجانية وGo؟",
"go.faq.a9":
"تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة قدرها 200 طلب/يوم. يقدّم Go مجموعة منسقة من النماذج مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، وأسبوعية، وشهرية)، تعادل تقريبًا $12 لكل 5 ساعات، و$30 في الأسبوع، و$60 في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).",
"تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash وHy3 مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).",
"zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.",
"zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم",
@@ -385,7 +374,7 @@ export const dict = {
"لقد وصلت إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{membersUrl}}",
"zen.api.error.modelDisabled": "النموذج معطل",
"zen.api.error.regionNotAllowed":
"لا يتوفر أحدث إصدار من هذا النموذج إلا مستضافًا في الصين، ويتطلب تفعيلًا صريحًا: {{consoleGoUrl}}",
"هذا النموذج مستضاف في الصين. إذا كنت ترغب في استخدام هذا النموذج، فعّله في إعداداتك: {{consoleGoUrl}}",
"zen.api.error.trialEnded":
"انتهى العرض المجاني لـ {{model}}. يمكنك مواصلة استخدام النموذج بالاشتراك في OpenCode Go - {{link}}",
@@ -675,7 +664,7 @@ export const dict = {
"workspace.lite.promo.price": "$5 للشهر الأول",
"workspace.lite.promo.modelsTitle": "ما يتضمنه",
"workspace.lite.promo.footer":
"صُممت الخطة بشكل أساسي للمستخدمين الدوليين، وتوفر وصولًا عالميًا مستقرًا. قد تتغير الأسعار وحدود الاستخدام بينما نتعلم من الاستخدام المبكر والملاحظات.",
"تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر. قد تتغير الأسعار وحدود الاستخدام بناءً على تعلمنا من الاستخدام المبكر والملاحظات.",
"workspace.lite.promo.subscribe": "الاشتراك في Go",
"workspace.lite.promo.subscribing": "جارٍ إعادة التوجيه...",
"workspace.lite.promo.otherMethods": "طرق دفع أخرى",
+10 -22
View File
@@ -256,9 +256,9 @@ export const dict = {
"zen.privacy.exceptionsLink": "seguintes exceções",
"go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos",
"go.banner.text": "GPT 5.6 Luna tem limites de uso 2x maiores por tempo limitado",
"go.banner.text": "Kimi K3 tem limites de uso 2x maiores por tempo limitado",
"go.meta.description":
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de uso e acesso confiável aos principais modelos de codificação.",
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3.",
"go.hero.title": "Modelos de codificação de baixo custo para todos",
"go.hero.body":
"O Go traz a codificação com agentes para programadores em todo o mundo. Oferecendo limites generosos e acesso confiável aos modelos de código aberto mais capazes, para que você possa construir com agentes poderosos sem se preocupar com custos ou disponibilidade.",
@@ -306,7 +306,8 @@ export const dict = {
"go.problem.item1": "Preço de assinatura de baixo custo",
"go.problem.item2": "Limites generosos e acesso confiável",
"go.problem.item3": "Feito para o maior número possível de programadores",
"go.problem.item4": "Uma seleção de modelos testados para codificação com agentes",
"go.problem.item4":
"Inclui Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3",
"go.how.title": "Como o Go funciona",
"go.how.body":
"O Go começa em $5 no primeiro mês, depois $10/mês. Você pode usá-lo com o OpenCode ou qualquer agente.",
@@ -332,7 +333,7 @@ export const dict = {
"go.faq.a2": "O Go inclui os modelos listados abaixo, com limites generosos e acesso confiável.",
"go.faq.q3": "O Go é o mesmo que o Zen?",
"go.faq.a3":
"Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável a uma seleção de modelos.",
"Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3.",
"go.faq.q4": "Quanto custa o Go?",
"go.faq.a4.p1.beforePricing": "O Go custa",
"go.faq.a4.p1.pricingLink": "$5 no primeiro mês",
@@ -341,21 +342,8 @@ export const dict = {
"go.faq.a4.p2.accountLink": "conta",
"go.faq.a4.p3": "Cancele a qualquer momento.",
"go.faq.q5": "E sobre dados e privacidade?",
"go.faq.a5.model": "Modelo",
"go.faq.a5.training": "Treinamento de modelos",
"go.faq.a5.retention": "Retenção de dados",
"go.faq.a5.retention30": "30 dias",
"go.faq.a5.retention0": "0 dias",
"go.faq.a5.used": "Usado",
"go.faq.a5.notUsed": "Não usado",
"go.faq.a5.noAgreement": "Sem acordo",
"go.faq.a5.grokRetention":
"O ZDR desativa recursos importantes da API que dependem de dados armazenados, incluindo a Responses API com estado, Files and Collections e a Batch API.",
"go.faq.a5.gptRetention":
"Logs de monitoramento de abuso são gerados para todo uso de recursos da API e retidos por até 30 dias.",
"go.faq.a5.learnMore": "Saiba mais",
"go.faq.a5.deepseekRetention":
"O acordo de ZDR é renovado mensalmente. O acordo atual é válido até 31 de agosto de 2026.",
"go.faq.a5.body":
"O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável. Nossos provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelos.",
"go.faq.a5.beforeExceptions":
"Os modelos Go são hospedados nos EUA. Os provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelos, com as",
"go.faq.a5.exceptionsLink": "seguintes exceções",
@@ -369,7 +357,7 @@ export const dict = {
"go.faq.q9": "Qual a diferença entre os modelos gratuitos e o Go?",
"go.faq.a9":
"Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go oferece uma seleção de modelos com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).",
"Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3 com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).",
"zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.",
"zen.api.error.modelNotSupported": "Modelo {{model}} não suportado",
@@ -394,7 +382,7 @@ export const dict = {
"Você atingiu seu limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{membersUrl}}",
"zen.api.error.modelDisabled": "O modelo está desabilitado",
"zen.api.error.regionNotAllowed":
"A versão mais recente deste modelo está disponível apenas com hospedagem na China e requer adesão explícita: {{consoleGoUrl}}",
"Este modelo está hospedado na China. Se você quiser usar este modelo, ative-o nas suas configurações: {{consoleGoUrl}}",
"zen.api.error.trialEnded":
"A promoção gratuita do {{model}} terminou. Você pode continuar usando o modelo assinando o OpenCode Go - {{link}}",
@@ -686,7 +674,7 @@ export const dict = {
"workspace.lite.promo.price": "$5 no primeiro mês",
"workspace.lite.promo.modelsTitle": "O que está incluído",
"workspace.lite.promo.footer":
"O plano foi desenvolvido principalmente para usuários internacionais e oferece acesso global estável. Os preços e limites de uso podem mudar à medida que aprendemos com o uso inicial e o feedback recebido.",
"O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável. Preços e limites de uso podem mudar conforme aprendemos com o uso inicial e feedback.",
"workspace.lite.promo.subscribe": "Assinar Go",
"workspace.lite.promo.subscribing": "Redirecionando...",
"workspace.lite.promo.otherMethods": "Outros métodos de pagamento",
+10 -23
View File
@@ -254,9 +254,9 @@ export const dict = {
"zen.privacy.exceptionsLink": "følgende undtagelser",
"go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle",
"go.banner.text": "GPT 5.6 Luna får fordoblet brugsgrænse i en begrænset periode",
"go.banner.text": "Kimi K3 får fordoblet brugsgrænse i en begrænset periode",
"go.meta.description":
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse brugsgrænser og pålidelig adgang til førende kodningsmodeller.",
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3.",
"go.hero.title": "Kodningsmodeller til lav pris for alle",
"go.hero.body":
"Go bringer agentisk kodning til programmører over hele verden. Med generøse grænser og pålidelig adgang til de mest kapable open source-modeller, så du kan bygge med kraftfulde agenter uden at bekymre dig om omkostninger eller tilgængelighed.",
@@ -303,7 +303,8 @@ export const dict = {
"go.problem.item1": "Lavpris abonnementspriser",
"go.problem.item2": "Generøse grænser og pålidelig adgang",
"go.problem.item3": "Bygget til så mange programmører som muligt",
"go.problem.item4": "Et kurateret modeludvalg testet til agentisk kodning",
"go.problem.item4":
"Inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3",
"go.how.title": "Hvordan Go virker",
"go.how.body":
"Go starter ved $5 for den første måned, derefter $10/måned. Du kan bruge det med OpenCode eller enhver agent.",
@@ -329,7 +330,7 @@ export const dict = {
"go.faq.a2": "Go inkluderer modellerne nedenfor med generøse grænser og pålidelig adgang.",
"go.faq.q3": "Er Go det samme som Zen?",
"go.faq.a3":
"Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til et kurateret modeludvalg.",
"Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3.",
"go.faq.q4": "Hvad koster Go?",
"go.faq.a4.p1.beforePricing": "Go koster",
"go.faq.a4.p1.pricingLink": "$5 første måned",
@@ -338,22 +339,8 @@ export const dict = {
"go.faq.a4.p2.accountLink": "konto",
"go.faq.a4.p3": "Annuller til enhver tid.",
"go.faq.q5": "Hvad med data og privatliv?",
"go.faq.a5.model": "Model",
"go.faq.a5.training": "Modeltræning",
"go.faq.a5.retention": "Dataopbevaring",
"go.faq.a5.retention30": "30 dage",
"go.faq.a5.retention0": "0 dage",
"go.faq.a5.used": "Brugt",
"go.faq.a5.notUsed": "Ikke brugt",
"go.faq.a5.noAgreement": "Ingen aftale",
"go.faq.a5.grokRetention":
"ZDR deaktiverer vigtige API-funktioner, der afhænger af lagrede data, herunder den tilstandsbevarende Responses API, Files and Collections og Batch API.",
"go.faq.a5.gptRetention":
"Logfiler til overvågning af misbrug genereres ved al brug af API-funktioner og opbevares i op til 30 dage.",
"go.faq.a5.learnMore": "Læs mere",
"go.faq.a5.deepseekRetention":
"ZDR-aftalen fornyes månedligt. Den nuværende aftale er gyldig til og med 31. august 2026.",
"go.faq.a5.body":
"Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang. Vores udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning.",
"go.faq.a5.beforeExceptions":
"Go-modeller hostes i USA. Udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning, med de",
"go.faq.a5.exceptionsLink": "følgende undtagelser",
@@ -366,7 +353,7 @@ export const dict = {
"go.faq.q9": "Hvad er forskellen på gratis modeller og Go?",
"go.faq.a9":
"Gratis modeller inkluderer Big Pickle plus kampagnemodeller, der er tilgængelige på det pågældende tidspunkt, med en kvote på 200 forespørgsler/dag. Go tilbyder et kurateret modeludvalg med højere forespørgselskvoter håndhævet over rullende perioder (5 timer, ugentligt og månedligt), omtrent svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (det faktiske antal forespørgsler varierer efter model og brug).",
"Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3 med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).",
"zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.",
"zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke",
@@ -391,7 +378,7 @@ export const dict = {
"Du har nået din månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{membersUrl}}",
"zen.api.error.modelDisabled": "Modellen er deaktiveret",
"zen.api.error.regionNotAllowed":
"Den nyeste version af denne model er kun tilgængelig som hostet i Kina og kræver, at du aktivt tilvælger den: {{consoleGoUrl}}",
"Denne model hostes i Kina. Hvis du vil bruge denne model, skal du aktivere den i dine indstillinger: {{consoleGoUrl}}",
"zen.api.error.trialEnded":
"Den gratis kampagne for {{model}} er afsluttet. Du kan fortsætte med at bruge modellen ved at abonnere på OpenCode Go - {{link}}",
@@ -683,7 +670,7 @@ export const dict = {
"workspace.lite.promo.price": "$5 for den første måned",
"workspace.lite.promo.modelsTitle": "Hvad er inkluderet",
"workspace.lite.promo.footer":
"Planen er primært udviklet til internationale brugere og giver stabil adgang i hele verden. Priser og forbrugsgrænser kan ændre sig, efterhånden som vi lærer af de første brugserfaringer og tilbagemeldinger.",
"Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang. Priser og forbrugsgrænser kan ændre sig, efterhånden som vi lærer af tidlig brug og feedback.",
"workspace.lite.promo.subscribe": "Abonner på Go",
"workspace.lite.promo.subscribing": "Omdirigerer...",
"workspace.lite.promo.otherMethods": "Andre betalingsmetoder",
+10 -22
View File
@@ -256,9 +256,9 @@ export const dict = {
"zen.privacy.exceptionsLink": "folgenden Ausnahmen",
"go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle",
"go.banner.text": "GPT 5.6 Luna erhält für begrenzte Zeit 2x Nutzungslimits",
"go.banner.text": "Kimi K3 erhält für begrenzte Zeit 2x Nutzungslimits",
"go.meta.description":
"Go beginnt bei $5 für deinen ersten Monat, danach $10/Monat, mit großzügigen Nutzungslimits und zuverlässigem Zugang zu führenden Coding-Modellen.",
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash und Hy3.",
"go.hero.title": "Kostengünstige Coding-Modelle für alle",
"go.hero.body":
"Go bringt Agentic Coding zu Programmierern auf der ganzen Welt. Mit großzügigen Limits und zuverlässigem Zugang zu den leistungsfähigsten Open-Source-Modellen, damit du mit leistungsstarken Agenten entwickeln kannst, ohne dir Gedanken über Kosten oder Verfügbarkeit zu machen.",
@@ -305,7 +305,8 @@ export const dict = {
"go.problem.item1": "Kostengünstiges Abonnement",
"go.problem.item2": "Großzügige Limits und zuverlässiger Zugang",
"go.problem.item3": "Für so viele Programmierer wie möglich gebaut",
"go.problem.item4": "Eine kuratierte, für Agentic Coding getestete Modellauswahl",
"go.problem.item4":
"Beinhaltet Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash und Hy3",
"go.how.title": "Wie Go funktioniert",
"go.how.body":
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat. Du kannst es mit OpenCode oder jedem Agenten nutzen.",
@@ -331,7 +332,7 @@ export const dict = {
"go.faq.a2": "Go umfasst die unten aufgeführten Modelle mit großzügigen Limits und zuverlässigem Zugriff.",
"go.faq.q3": "Ist Go dasselbe wie Zen?",
"go.faq.a3":
"Nein. Zen ist Pay-as-you-go, während Go bei $5 für deinen ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu einer kuratierten Modellauswahl.",
"Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash und Hy3.",
"go.faq.q4": "Wie viel kostet Go?",
"go.faq.a4.p1.beforePricing": "Go kostet",
"go.faq.a4.p1.pricingLink": "$5 im ersten Monat",
@@ -340,21 +341,8 @@ export const dict = {
"go.faq.a4.p2.accountLink": "Konto verwalten",
"go.faq.a4.p3": "Jederzeit kündbar.",
"go.faq.q5": "Was ist mit Daten und Privatsphäre?",
"go.faq.a5.model": "Modell",
"go.faq.a5.training": "Modelltraining",
"go.faq.a5.retention": "Datenaufbewahrung",
"go.faq.a5.retention30": "30 Tage",
"go.faq.a5.retention0": "0 Tage",
"go.faq.a5.used": "Verwendet",
"go.faq.a5.notUsed": "Nicht verwendet",
"go.faq.a5.noAgreement": "Keine Vereinbarung",
"go.faq.a5.grokRetention":
"ZDR deaktiviert wichtige API-Funktionen, die von gespeicherten Daten abhängen, einschließlich der zustandsbehafteten Responses API, Files and Collections und der Batch API.",
"go.faq.a5.gptRetention":
"Für die Nutzung aller API-Funktionen werden Protokolle zur Missbrauchsüberwachung erstellt und bis zu 30 Tage lang aufbewahrt.",
"go.faq.a5.learnMore": "Mehr erfahren",
"go.faq.a5.deepseekRetention":
"Die ZDR-Vereinbarung wird monatlich erneuert. Die aktuelle Vereinbarung gilt bis einschließlich 31. August 2026.",
"go.faq.a5.body":
"Der Plan ist primär für internationale Nutzer konzipiert, mit Modellen gehostet in den USA, der EU und Singapur für stabilen globalen Zugang. Unsere Anbieter verfolgen eine Zero-Retention-Politik und nutzen deine Daten nicht für das Training von Modellen.",
"go.faq.a5.beforeExceptions":
"Go-Modelle werden in den USA gehostet. Anbieter verfolgen eine Zero-Retention-Politik und nutzen deine Daten nicht für das Training von Modellen, mit den",
"go.faq.a5.exceptionsLink": "folgenden Ausnahmen",
@@ -368,7 +356,7 @@ export const dict = {
"go.faq.q9": "Was ist der Unterschied zwischen kostenlosen Modellen und Go?",
"go.faq.a9":
"Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go bietet eine kuratierte Modellauswahl mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).",
"Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash und Hy3 mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).",
"zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.",
"zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt",
@@ -393,7 +381,7 @@ export const dict = {
"Du hast dein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{membersUrl}}",
"zen.api.error.modelDisabled": "Modell ist deaktiviert",
"zen.api.error.regionNotAllowed":
"Die neueste Version dieses Modells ist nur verfügbar, wenn sie in China gehostet wird, und muss ausdrücklich aktiviert werden: {{consoleGoUrl}}",
"Dieses Modell wird in China gehostet. Wenn du dieses Modell verwenden möchtest, aktiviere es in deinen Einstellungen: {{consoleGoUrl}}",
"zen.api.error.trialEnded":
"Die kostenlose Aktion für {{model}} ist beendet. Du kannst das Modell weiterhin nutzen, indem du OpenCode Go abonnierst - {{link}}",
@@ -685,7 +673,7 @@ export const dict = {
"workspace.lite.promo.price": "$5 im ersten Monat",
"workspace.lite.promo.modelsTitle": "Was enthalten ist",
"workspace.lite.promo.footer":
"Der Plan richtet sich in erster Linie an internationale Nutzer und bietet stabilen weltweiten Zugriff. Preise und Nutzungslimits können sich ändern, wenn wir Erkenntnisse aus der ersten Nutzung und dem Feedback gewinnen.",
"Der Plan wurde hauptsächlich für internationale Nutzer entwickelt, wobei die Modelle in den USA, der EU und Singapur gehostet werden, um einen stabilen weltweiten Zugriff zu gewährleisten. Preise und Nutzungslimits können sich ändern, während wir aus der frühen Nutzung und dem Feedback lernen.",
"workspace.lite.promo.subscribe": "Go abonnieren",
"workspace.lite.promo.subscribing": "Leite weiter...",
"workspace.lite.promo.otherMethods": "Andere Zahlungsmethoden",
+10 -23
View File
@@ -253,9 +253,9 @@ export const dict = {
"zen.privacy.exceptionsLink": "following exceptions",
"go.title": "OpenCode Go | Low cost coding models for everyone",
"go.banner.text": "GPT 5.6 Luna gets 2× usage limits for a limited time",
"go.banner.text": "Kimi K3 gets 2× usage limits for a limited time",
"go.meta.description":
"Go starts at $5 for your first month, then $10/month, with generous usage limits and reliable access to leading coding models.",
"Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash, and Hy3.",
"go.hero.title": "Low cost coding models for everyone",
"go.hero.body":
"Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable open-source models, so you can build with powerful agents without worrying about cost or availability.",
@@ -270,7 +270,6 @@ export const dict = {
"go.graph.freePill": "Big Pickle and free models",
"go.graph.go": "Go",
"go.graph.label": "Requests per 5 hour",
"go.graph.tick": "{{n}}x",
"go.graph.usageLimits": "Usage limits",
"go.graph.aria": "Requests per 5h: {{free}} vs {{go}}",
@@ -302,7 +301,8 @@ export const dict = {
"go.problem.item1": "Low cost subscription pricing",
"go.problem.item2": "Generous limits and reliable access",
"go.problem.item3": "Built for as many programmers as possible",
"go.problem.item4": "A curated model lineup tested for agentic coding",
"go.problem.item4":
"Includes Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash, and Hy3",
"go.how.title": "How Go works",
"go.how.body": "Go starts at $5 for your first month, then $10/month. You can use it with OpenCode or any agent.",
"go.how.step1.title": "Create an account",
@@ -327,7 +327,7 @@ export const dict = {
"go.faq.a2": "Go includes the models listed below, with generous limits and reliable access.",
"go.faq.q3": "Is Go the same as Zen?",
"go.faq.a3":
"No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to a curated model lineup.",
"No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash, and Hy3.",
"go.faq.q4": "How much does Go cost?",
"go.faq.a4.p1.beforePricing": "Go costs",
"go.faq.a4.p1.pricingLink": "$5 first month",
@@ -336,21 +336,8 @@ export const dict = {
"go.faq.a4.p2.accountLink": "account",
"go.faq.a4.p3": "Cancel any time.",
"go.faq.q5": "What about data and privacy?",
"go.faq.a5.model": "Model",
"go.faq.a5.training": "Model training",
"go.faq.a5.retention": "Data retention",
"go.faq.a5.retention30": "30 days",
"go.faq.a5.retention0": "0 days",
"go.faq.a5.used": "Used",
"go.faq.a5.notUsed": "Not used",
"go.faq.a5.noAgreement": "No agreement",
"go.faq.a5.grokRetention":
"ZDR disables important API features that depend on stored data, including the stateful Responses API, Files and Collections, and the Batch API.",
"go.faq.a5.gptRetention":
"Abuse monitoring logs are generated for all API feature usage and retained for up to 30 days.",
"go.faq.a5.deepseekRetention":
"ZDR agreement is renewed monthly. The current agreement is valid through August 31, 2026.",
"go.faq.a5.learnMore": "Learn more",
"go.faq.a5.body":
"The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access. Our providers follow a zero-retention policy and do not use your data for model training.",
"go.faq.a5.beforeExceptions":
"Go models are hosted in the US. Providers follow a zero-retention policy and do not use your data for model training, with the",
@@ -364,7 +351,7 @@ export const dict = {
"go.faq.q9": "What is the difference between free models and Go?",
"go.faq.a9":
"Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go offers a curated model lineup with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).",
"Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash, and Hy3 with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).",
"zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.",
"zen.api.error.modelNotSupported": "Model {{model}} is not supported",
@@ -389,7 +376,7 @@ export const dict = {
"You have reached your monthly spending limit of ${{amount}}. Manage your limits here: {{membersUrl}}",
"zen.api.error.modelDisabled": "Model is disabled",
"zen.api.error.regionNotAllowed":
"The latest version of this model is only available hosted in China and requires explicit opt in: {{consoleGoUrl}}",
"This model is hosted in China. If you would like to use this model, enable it in your settings: {{consoleGoUrl}}",
"zen.api.error.trialEnded":
"Free promotion has ended for {{model}}. You can continue using the model by subscribing to OpenCode Go - {{link}}",
@@ -681,7 +668,7 @@ export const dict = {
"workspace.lite.promo.price": "$5 for your first month",
"workspace.lite.promo.modelsTitle": "What's Included",
"workspace.lite.promo.footer":
"The plan is designed primarily for international users and provides stable global access. Pricing and usage limits may change as we learn from early usage and feedback.",
"The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access. Pricing and usage limits may change as we learn from early usage and feedback.",
"workspace.lite.promo.subscribe": "Subscribe to Go",
"workspace.lite.promo.subscribing": "Redirecting...",
"workspace.lite.promo.otherMethods": "Other payment methods",

Some files were not shown because too many files have changed in this diff Show More