Compare commits

..

2 Commits

Author SHA1 Message Date
Jay 6cf2c80a98 fix(app): shorten undo tooltip 2026-07-18 00:21:23 +00:00
Jay 55d6e8903d fix(app): disable undo without git 2026-07-18 00:09:03 +00:00
440 changed files with 7426 additions and 26103 deletions
+6 -1
View File
@@ -219,6 +219,7 @@ jobs:
build-electron: build-electron:
needs: needs:
- build-cli
- version - version
if: github.repository == 'anomalyco/opencode' if: github.repository == 'anomalyco/opencode'
continue-on-error: false continue-on-error: false
@@ -315,7 +316,10 @@ jobs:
env: env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }} RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
- name: Build - name: Build
run: bun run build run: bun run build
@@ -339,7 +343,8 @@ jobs:
env: env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
GH_TOKEN: ${{ steps.committer.outputs.token }} 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: ${{ runner.temp }}/apple-api-key.p8
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} 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. 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 ## Issue Requirements
All issues **must** use one of our issue templates: All issues **must** use one of our issue templates:
+42 -59
View File
@@ -29,7 +29,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"@corvu/drawer": "catalog:", "@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0", "@dnd-kit/abstract": "0.5.0",
@@ -37,7 +37,6 @@
"@dnd-kit/helpers": "0.5.0", "@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0", "@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -66,7 +65,7 @@
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", "ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
"luxon": "catalog:", "luxon": "catalog:",
"marked": "catalog:", "marked": "catalog:",
"marked-shiki": "catalog:", "marked-shiki": "catalog:",
@@ -96,7 +95,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@opencode-ai/cli", "name": "@opencode-ai/cli",
"version": "1.18.12", "version": "1.18.3",
"bin": { "bin": {
"lildax": "./bin/lildax.cjs", "lildax": "./bin/lildax.cjs",
}, },
@@ -144,7 +143,7 @@
}, },
"packages/codemode": { "packages/codemode": {
"name": "@opencode-ai/codemode", "name": "@opencode-ai/codemode",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"acorn": "8.15.0", "acorn": "8.15.0",
"effect": "catalog:", "effect": "catalog:",
@@ -158,7 +157,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -194,7 +193,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -221,7 +220,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.82", "@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@@ -243,7 +242,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -267,7 +266,7 @@
}, },
"packages/console/support": { "packages/console/support": {
"name": "@opencode-ai/console-support", "name": "@opencode-ai/console-support",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*", "@opencode-ai/console-core": "workspace:*",
@@ -287,7 +286,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"version": "1.18.12", "version": "1.18.3",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -303,7 +302,7 @@
"@ai-sdk/google": "3.0.73", "@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.51", "@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.84", "@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26", "@ai-sdk/perplexity": "3.0.26",
@@ -339,7 +338,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.12.1", "gitlab-ai-provider": "6.11.1",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@@ -381,16 +380,16 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"@zip.js/zip.js": "2.7.62", "@zip.js/zip.js": "2.7.62",
"drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"electron-context-menu": "4.1.2", "electron-context-menu": "4.1.2",
"electron-log": "^5", "electron-log": "^5",
"electron-store": "11.0.2", "electron-store": "11.0.2",
"electron-updater": "6.8.9", "electron-updater": "6.8.9",
"electron-window-state": "^5.0.3", "electron-window-state": "^5.0.3",
"marked": "^15",
}, },
"devDependencies": { "devDependencies": {
"@actions/artifact": "4.0.0", "@actions/artifact": "4.0.0",
@@ -435,7 +434,7 @@
}, },
"packages/effect-drizzle-sqlite": { "packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite", "name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -449,7 +448,7 @@
}, },
"packages/effect-sqlite-node": { "packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node", "name": "@opencode-ai/effect-sqlite-node",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"effect": "catalog:", "effect": "catalog:",
}, },
@@ -461,7 +460,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"@hono/standard-validator": "catalog:", "@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -493,7 +492,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -509,7 +508,7 @@
}, },
"packages/http-recorder": { "packages/http-recorder": {
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83",
@@ -540,7 +539,7 @@
}, },
"packages/llm": { "packages/llm": {
"name": "@opencode-ai/llm", "name": "@opencode-ai/llm",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14", "@smithy/eventstream-codec": "4.2.14",
@@ -559,7 +558,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.18.12", "version": "1.18.3",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -578,7 +577,7 @@
"@ai-sdk/google": "3.0.73", "@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.51", "@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.84", "@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26", "@ai-sdk/perplexity": "3.0.26",
@@ -632,7 +631,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.12.1", "gitlab-ai-provider": "6.11.1",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@@ -690,7 +689,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -766,7 +765,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -781,7 +780,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@opencode-ai/server", "name": "@opencode-ai/server",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*", "@opencode-ai/protocol": "workspace:*",
@@ -796,10 +795,9 @@
}, },
"packages/session-ui": { "packages/session-ui": {
"name": "@opencode-ai/session-ui", "name": "@opencode-ai/session-ui",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -811,6 +809,7 @@
"@solid-primitives/media": "2.3.3", "@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"diff": "catalog:", "diff": "catalog:",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
@@ -840,7 +839,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -853,7 +852,7 @@
}, },
"packages/stats/app": { "packages/stats/app": {
"name": "@opencode-ai/stats-app", "name": "@opencode-ai/stats-app",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
@@ -887,7 +886,7 @@
}, },
"packages/stats/core": { "packages/stats/core": {
"name": "@opencode-ai/stats-core", "name": "@opencode-ai/stats-core",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"@aws-sdk/client-athena": "3.933.0", "@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0", "@planetscale/database": "1.19.0",
@@ -906,7 +905,7 @@
}, },
"packages/stats/server": { "packages/stats/server": {
"name": "@opencode-ai/stats-server", "name": "@opencode-ai/stats-server",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"@aws-sdk/client-firehose": "3.933.0", "@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
@@ -948,7 +947,7 @@
}, },
"packages/tui": { "packages/tui": {
"name": "@opencode-ai/tui", "name": "@opencode-ai/tui",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
@@ -975,7 +974,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
@@ -1001,7 +1000,6 @@
"remend": "catalog:", "remend": "catalog:",
"shiki": "catalog:", "shiki": "catalog:",
"solid-list": "catalog:", "solid-list": "catalog:",
"solid-sonner": "catalog:",
"strip-ansi": "7.1.2", "strip-ansi": "7.1.2",
}, },
"devDependencies": { "devDependencies": {
@@ -1027,7 +1025,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.18.12", "version": "1.18.3",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -1076,13 +1074,11 @@
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.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", "@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", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.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",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "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", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.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": { "overrides": {
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
@@ -1146,7 +1142,6 @@
"shiki": "4.2.0", "shiki": "4.2.0",
"solid-js": "1.9.10", "solid-js": "1.9.10",
"solid-list": "0.3.0", "solid-list": "0.3.0",
"solid-sonner": "0.3.1",
"sst": "4.13.1", "sst": "4.13.1",
"tailwindcss": "4.1.11", "tailwindcss": "4.1.11",
"typescript": "5.8.2", "typescript": "5.8.2",
@@ -1204,7 +1199,7 @@
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="], "@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="], "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="], "@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
@@ -3850,13 +3845,13 @@
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#83c0a07", {}, "anomalyco-ghostty-web-83c0a07", "sha512-Lf2v1agHkVUpMpHBWWuCZrhOEmcwwin5/Hboc9rZwQ7/CKkIh5rU1r1CvfLlhkMoFv+ed8z52RZ8hkzGZZj3MQ=="], "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#513463a", {}, "anomalyco-ghostty-web-513463a", "sha512-GZR8LSmgGzViWnBJrqRI8MpAZRCJxhcr1Hi9Tyeh7YRooHZQjK9J97FQRD3tbBaM2wjq05gzGY2UEsG+JtZeBw=="],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "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.11.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-SJ6f5qa7P8md6lPrserryER3zerLkrezlnqqYQ2AbvDPpHLbwtbyk0FYJ5kNRcmbI80i/VMcsMBP0YIRdc3ucQ=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -5128,8 +5123,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-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-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=="], "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=="],
@@ -5706,9 +5699,7 @@
"@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
@@ -6034,8 +6025,6 @@
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="],
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
@@ -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/@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/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=="], "@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=="],
@@ -6052,8 +6043,6 @@
"@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="],
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], "@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], "@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
@@ -6184,8 +6173,6 @@
"ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="], "ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="],
"ai-gateway-provider/@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], "ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="], "ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
@@ -7002,8 +6989,6 @@
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
@@ -7430,8 +7415,6 @@
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
+2 -6
View File
@@ -288,12 +288,8 @@ new sst.cloudflare.x.SolidStart("Console", {
server: { server: {
placement: { region: "aws:us-east-2" }, placement: { region: "aws:us-east-2" },
transform: { transform: {
worker: (args) => { worker: {
args.compatibilityFlags = $resolve(args.compatibilityFlags).apply((flags) => [ tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
...(flags ?? []),
"global_fetch_strictly_public",
])
args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }]
}, },
}, },
}, },
-19
View File
@@ -8,25 +8,6 @@ export const zoneID = "430ba34c138cfb5360826c4909f99be8"
export const awsStage = $app.stage === "production" ? "production" : "dev" export const awsStage = $app.stage === "production" ? "production" : "dev"
export const deployAws = $app.stage === awsStage export const deployAws = $app.stage === awsStage
if ($app.stage === "production") {
new cloudflare.DnsRecord("TrustCenter", {
zoneId: zoneID,
name: "trust.opencode.ai",
type: "CNAME",
content: "3a69a5bb27875189.vercel-dns-016.com",
proxied: false,
ttl: 60,
})
new cloudflare.DnsRecord("TrustCenterVerification", {
zoneId: zoneID,
name: "opencode.ai",
type: "TXT",
content: "compai-domain-verification=org_6993a99c6200a2d642bb115d",
ttl: 60,
})
}
new cloudflare.RegionalHostname("RegionalHostname", { new cloudflare.RegionalHostname("RegionalHostname", {
hostname: domain, hostname: domain,
regionKey: "us", regionKey: "us",
+33 -66
View File
@@ -8,8 +8,6 @@
makeWrapper, makeWrapper,
writableTmpDirAsHomeHook, writableTmpDirAsHomeHook,
autoPatchelfHook, autoPatchelfHook,
copyDesktopItems,
makeDesktopItem,
opencode, opencode,
}: }:
let let
@@ -29,12 +27,9 @@ stdenv.mkDerivation (finalAttrs: {
nodejs nodejs
makeWrapper makeWrapper
writableTmpDirAsHomeHook writableTmpDirAsHomeHook
] ] ++ lib.optionals stdenv.hostPlatform.isLinux [
++ lib.optionals stdenv.hostPlatform.isLinux [
autoPatchelfHook autoPatchelfHook
copyDesktopItems ] ++ lib.optionals stdenv.hostPlatform.isDarwin [
]
++ lib.optionals stdenv.hostPlatform.isDarwin [
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing. # Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
darwin.autoSignDarwinBinariesHook darwin.autoSignDarwinBinariesHook
]; ];
@@ -43,37 +38,20 @@ stdenv.mkDerivation (finalAttrs: {
(lib.getLib stdenv.cc.cc) (lib.getLib stdenv.cc.cc)
]; ];
desktopItems = lib.optional stdenv.hostPlatform.isLinux (makeDesktopItem {
name = "ai.opencode.desktop";
desktopName = "OpenCode";
exec = "opencode-desktop %U";
icon = "ai.opencode.desktop";
# Electron 41 derives X11 WM_CLASS from app.name.
startupWMClass = "OpenCode";
categories = [ "Development" ];
});
env = opencode.env // { env = opencode.env // {
ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
}; };
postPatch = # https://github.com/electron/electron/issues/31121
# NOTE: Relax Bun version check to be a warning instead of an error # mac builds use a .app bundle which doesnt have this issue
'' postPatch = lib.optionalString stdenv.isLinux ''
substituteInPlace packages/script/src/index.ts \ BASE_PATH=packages/desktop
--replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \ FILES=(src/main/windows.ts)
'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}' for file in "''${FILES[@]}"; do
'' substituteInPlace $BASE_PATH/$file \
# https://github.com/electron/electron/issues/31121 --replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
# mac builds use a .app bundle which doesnt have this issue done
+ lib.optionalString stdenv.isLinux '' '';
BASE_PATH=packages/desktop
FILES=(src/main/windows.ts)
for file in "''${FILES[@]}"; do
substituteInPlace $BASE_PATH/$file \
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
done
'';
preBuild = '' preBuild = ''
cp -r "${electron.dist}" $HOME/.electron-dist cp -r "${electron.dist}" $HOME/.electron-dist
@@ -98,38 +76,27 @@ stdenv.mkDerivation (finalAttrs: {
runHook postBuild runHook postBuild
''; '';
installPhase = '' installPhase =
runHook preInstall ''
'' runHook preInstall
+ lib.optionalString stdenv.hostPlatform.isDarwin '' ''
mkdir -p $out/Applications + lib.optionalString stdenv.hostPlatform.isDarwin ''
mv dist/mac*/*.app $out/Applications mkdir -p $out/Applications
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop mv dist/mac*/*.app $out/Applications
'' makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
+ lib.optionalString stdenv.hostPlatform.isLinux '' ''
mkdir -p $out/opt/opencode-desktop + lib.optionalString stdenv.hostPlatform.isLinux ''
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop mkdir -p $out/opt/opencode-desktop
install -Dm644 resources/icons/32x32.png \ cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
"$out/share/icons/hicolor/32x32/apps/ai.opencode.desktop.png" makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
install -Dm644 resources/icons/64x64.png \ --inherit-argv0 \
"$out/share/icons/hicolor/64x64/apps/ai.opencode.desktop.png" --set ELECTRON_FORCE_IS_PACKAGED 1 \
install -Dm644 resources/icons/128x128.png \ --add-flags $out/opt/opencode-desktop/resources/app.asar \
"$out/share/icons/hicolor/128x128/apps/ai.opencode.desktop.png" --add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
install -Dm644 resources/icons/128x128@2x.png \ ''
"$out/share/icons/hicolor/256x256/apps/ai.opencode.desktop.png" + ''
install -Dm644 resources/icons/icon.png \ runHook postInstall
"$out/share/icons/hicolor/512x512/apps/ai.opencode.desktop.png" '';
install -Dm644 resources/ai.opencode.desktop.metainfo.xml \
"$out/share/metainfo/ai.opencode.desktop.metainfo.xml"
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
--inherit-argv0 \
--set ELECTRON_FORCE_IS_PACKAGED 1 \
--add-flags $out/opt/opencode-desktop/resources/app.asar \
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
''
+ ''
runHook postInstall
'';
autoPatchelfIgnoreMissingDeps = [ autoPatchelfIgnoreMissingDeps = [
"libc.musl-x86_64.so.1" "libc.musl-x86_64.so.1"
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-a7NyYa9vRUEqDfZNDPXXmFO58RDEgioyuGSl5CPBvxo=", "x86_64-linux": "sha256-zAftZieWR0hWQQVkhEa826iueHaRnKxtyRodhNb+Apc=",
"aarch64-linux": "sha256-l4OJtSEllHvRhktjcaJYwkXBSaJvsIoyoLusbZfYMcM=", "aarch64-linux": "sha256-cTG+gB7CuiFgaYHSFbzXaXUI1C2kiugV3B9rUDRUwlg=",
"aarch64-darwin": "sha256-IIl0BQGs1/HLFh0auiQjiwfSQ2nfHcK2G2BAphYW59c=", "aarch64-darwin": "sha256-ap4Xv5ZliourkI4mcD0ktbzSI88jb4oVBv5mMK3WdUs=",
"x86_64-darwin": "sha256-vVeuPyd4ZIRYrHouphTuEb4rkRZLKKTAHe840jNh9rU=" "x86_64-darwin": "sha256-NGRJMMnaMNrf3FkcYwr5PcfVm5thhlNLGIeMmAK0MeE="
} }
} }
-3
View File
@@ -90,7 +90,6 @@
"@sentry/solid": "10.36.0", "@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0", "@sentry/vite-plugin": "4.6.0",
"solid-js": "1.9.10", "solid-js": "1.9.10",
"solid-sonner": "0.3.1",
"vite-plugin-solid": "2.11.10", "vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.12" "@lydell/node-pty": "1.2.0-beta.12"
} }
@@ -144,14 +143,12 @@
"@types/node": "catalog:" "@types/node": "catalog:"
}, },
"patchedDependencies": { "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", "@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", "@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", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
-220
View File
@@ -1,220 +0,0 @@
# V1 API Migration Checklist
The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name.
## Events
- [x] Replace `GET /global/event` with `GET /api/event`.
- `src/context/server-sdk.tsx`
- [x] Reduce current granular session and message events into the existing app projections.
- `src/context/server-session-v2-reducer.ts`
- `src/context/server-session.ts`
- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`.
- `src/context/global-sync/event-reducer.ts`
- `src/context/server-session.ts`
- `src/context/notification.tsx`
- `src/pages/session/usage-exceeded-dialogs.tsx`
- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`.
- `src/context/global-sync/event-reducer.ts`
- `src/context/server-session.ts`
- [x] Adapt current permission and question events to the existing request model.
- `src/context/global-sync/event-reducer.ts`
- `src/context/permission.tsx`
- [x] Consume current file watcher events.
- `src/context/file.tsx`
- [x] Consume current VCS events.
- `src/context/global-sync/event-reducer.ts`
- `src/pages/session.tsx`
- [x] Consume current `pty.exited` events.
- `src/context/terminal.tsx`
- [ ] Migrate LSP and reference events.
- `src/context/global-sync/event-reducer.ts`
## Sessions
- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events.
- `src/context/server-sync.tsx`
- [x] Migrate session listing from `GET /session`.
- `src/context/server-sync.tsx`
- `src/context/directory-sync.ts`
- `src/pages/layout.tsx`
- [x] Migrate the remaining direct session read from `GET /session/:sessionID`.
- `src/components/titlebar.tsx`
- [x] Migrate session updates from `PATCH /session/:sessionID`.
- `src/context/directory-sync.ts`
- `src/context/layout.tsx`
- `src/pages/home.tsx`
- `src/pages/layout.tsx`
- `src/pages/session/timeline/message-timeline.tsx`
- `src/components/titlebar-tab-nav.tsx`
- Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`.
- [x] Migrate session deletion from `DELETE /session/:sessionID`.
- `src/pages/session/timeline/message-timeline.tsx`
- [x] Remove session diff loading from `GET /session/:sessionID/diff`.
- Historical Session diffs remain unavailable until the current API defines their snapshot semantics.
- [x] Migrate abort from `POST /session/:sessionID/abort`.
- `src/components/prompt-input/submit.ts`
- `src/pages/session/use-session-commands.tsx`
- `src/pages/session.tsx`
- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`.
- `src/pages/session/use-session-commands.tsx`
- `src/pages/session.tsx`
- [x] Replace `POST /session/:sessionID/summarize` with the current compact API.
- `src/pages/session/use-session-commands.tsx`
- [x] Migrate slash commands from `POST /session/:sessionID/command`.
- `src/components/prompt-input/submit.ts`
- [x] Migrate shell execution from `POST /session/:sessionID/shell`.
- `src/components/prompt-input/submit.ts`
- [x] Migrate session fork from `POST /session/:sessionID/fork`.
- `src/components/dialog-fork.tsx`
- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`.
- `src/pages/session/use-session-commands.tsx`
- `src/pages/session/timeline/message-timeline.tsx`
- Blocked: the current API has no sharing contract or implementation.
## Session Compatibility Fallbacks
These calls are retained as fallback adapters. The current production path supplies the current session and message APIs.
- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary.
- `src/context/server-session.ts`
- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary.
- `src/context/server-session.ts`
- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary.
- `src/context/server-session.ts`
## Filesystem
- [ ] Migrate file listing from `GET /file`.
- `src/context/file.tsx`
- [ ] Migrate file reads from `GET /file/content`.
- `src/context/file.tsx`
- `src/pages/session/review-tab.tsx`
- `src/pages/session/v2/review-panel-v2.tsx`
- [x] Migrate path discovery from `GET /path` to `GET /api/path`.
- `src/context/global-sync/bootstrap.ts`
- `src/components/dialog-select-directory.tsx`
- `src/components/dialog-select-directory-v2.tsx`
## Projects And Worktrees
- [x] Migrate project listing from `GET /project` to `GET /api/project`.
- `src/context/global-sync/bootstrap.ts`
- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`.
- `src/context/global-sync/bootstrap.ts`
- [ ] Migrate Git initialization from `POST /project/git/init`.
- `src/pages/session.tsx`
- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`.
- `src/context/layout.tsx`
- `src/components/edit-project.ts`
- `src/pages/layout.tsx`
- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`.
- `src/pages/layout.tsx`
- `src/components/prompt-input/submit.ts`
- Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain.
- [ ] Migrate instance disposal from `POST /instance/dispose`.
- `src/pages/layout.tsx`
## VCS
- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`.
- `src/context/global-sync/bootstrap.ts`
- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`.
- `src/pages/session.tsx`
- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`.
- `src/pages/layout.tsx`
## Configuration And Authentication
- [ ] Migrate global configuration reads from `GET /global/config`.
- `src/context/global-sync/bootstrap.ts`
- [ ] Migrate directory configuration reads from `GET /config`.
- `src/context/global-sync/bootstrap.ts`
- [ ] Migrate global configuration updates from `PATCH /global/config`.
- `src/context/server-sync.tsx`
- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`.
- `src/components/dialog-connect-provider.tsx`
- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`.
- `src/components/dialog-connect-provider.tsx`
- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`.
- Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`.
- `src/components/dialog-connect-provider.tsx`
- `src/components/dialog-custom-provider.tsx`
- `src/components/settings-providers.tsx`
- `src/components/settings-v2/providers.tsx`
- [ ] Migrate global disposal from `POST /global/dispose`.
- `src/components/dialog-connect-provider.tsx`
- `src/components/settings-providers.tsx`
- `src/components/settings-v2/providers.tsx`
## Permissions And Questions
- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`.
- `src/context/global-sync/bootstrap.ts`
- `src/context/permission.tsx`
- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`.
- `src/context/permission.tsx`
- `src/pages/session/composer/session-composer-state.ts`
- [x] Migrate question listing from `GET /question` to `GET /api/question/request`.
- `src/context/global-sync/bootstrap.ts`
- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`.
- `src/pages/session/composer/session-question-dock.tsx`
## Commands, MCP, LSP, And References
- [x] Migrate command listing from `GET /command` to `GET /api/command`.
- `src/context/global-sync/bootstrap.ts`
- `src/context/server-sync.tsx`
- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`.
- `src/context/server-sync.tsx`
- [ ] Replace legacy MCP authentication with the Integration OAuth workflow.
- `src/context/server-sync.tsx`
- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`.
- `src/context/server-sync.tsx`
- [ ] Migrate LSP status from `GET /lsp`.
- `src/context/server-sync.tsx`
- [x] Move `GET /api/reference` off the legacy generated SDK transport.
- `src/context/global-sync/bootstrap.ts`
## Search
- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`.
- `src/components/command-palette.ts`
- `src/components/dialog-command-palette-v2.tsx`
## PTY And Terminal
- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`.
- `src/context/terminal.tsx`
- `src/components/terminal.tsx`
- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`.
- `src/components/settings-general.tsx`
- `src/components/settings-v2/general.tsx`
- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`.
- `src/components/terminal.tsx`
- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`.
- `src/components/terminal.tsx`
## Legacy Types And Adapters
These are not V1 network requests, but they keep the UI coupled to V1 data contracts.
- [ ] Replace the current-session-to-legacy-session adapter.
- `src/utils/session.ts`
- [ ] Replace the current-message-to-legacy-message-and-part adapter.
- `src/utils/session-message.ts`
- [ ] Replace current agent, provider, and model adapters to legacy SDK structures.
- `src/context/global-sync/utils.ts`
- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering.
- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone.
- `package.json`
## Test Infrastructure
- [ ] Replace V1 endpoint mocks with current API mocks.
- `e2e/utils/mock-server.ts`
- [x] Replace `/global/event` and `/event` interception with current event transport handling.
- `e2e/utils/sse-transport.ts`
- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests.
- `e2e/performance/timeline-stability/fixture.ts`
- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests.
@@ -97,7 +97,6 @@ export async function setupTimeline(
locale?: string locale?: string
deviceScaleFactor?: number deviceScaleFactor?: number
seedHistory?: boolean seedHistory?: boolean
protocol?: "v1" | "v2"
} = {}, } = {},
) { ) {
const sessions = input.sessions ?? [session()] const sessions = input.sessions ?? [session()]
@@ -115,7 +114,6 @@ export async function setupTimeline(
retry: input.eventRetry ?? 20, retry: input.eventRetry ?? 20,
}) })
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: input.protocol,
directory, directory,
project: project(), project: project(),
provider: provider(), provider: provider(),
@@ -41,12 +41,7 @@ const assistants = Array.from({ length: 14 }, (_, index) => {
const messages = [user, ...assistants] const messages = [user, ...assistants]
const target = fixture.sessions.find((session) => session.id === fixture.targetID)! const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
const lastID = userID const lastID = userID
const lastAssistant = assistants.at(-1)! const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
const lastPart = lastAssistant.parts.at(-1)!
const lastPartID =
lastPart.type === "tool"
? lastPart.id
: `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => { benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
benchmark.setTimeout(180_000) benchmark.setTimeout(180_000)
@@ -112,25 +107,9 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined } return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
}, },
}) })
await page.route(`**/session/${fixture.targetID}`, (route) => { await page.route(`**/session/${fixture.targetID}`, (route) =>
const current = new URL(route.request().url()).pathname.startsWith("/api/") route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
return route.fulfill({ )
status: 200,
contentType: "application/json",
body: JSON.stringify(
current
? {
data: {
...target,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
location: { directory: target.directory },
},
}
: target,
),
})
})
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] }) await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
await page.goto(stressSessionHref(fixture.sourceID)) await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle) await expectSessionTitle(page, fixture.expected.sourceTitle)
@@ -165,8 +144,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
parent: requests.filter((request) => request.type === "parent").length, parent: requests.filter((request) => request.type === "parent").length,
} }
if (mode === "candidate") { if (mode === "candidate") {
expect(requestCounts.parent).toBe(0) expect(requestCounts.parent).toBe(1)
expect(historyGates).toBe(0) expect(historyGates).toBe(1)
} }
return { metrics, requestCounts, historyGateCount: historyGates } return { metrics, requestCounts, historyGateCount: historyGates }
} }
@@ -1,6 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096" const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097" const serverB = "http://127.0.0.1:4097"
@@ -34,7 +33,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn
await tabA.locator('[data-slot="tab-close"] button').click() await tabA.locator('[data-slot="tab-close"] button').click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true) await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true)
await expect(page.getByText(sessionB.title).first()).toBeVisible() await expect(page.getByText(sessionB.title).first()).toBeVisible()
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`)) const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true) expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
@@ -85,21 +84,17 @@ async function mockServers(page: Page, requests: string[]) {
const current = url.origin === serverA ? sessionA : sessionB const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory") const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
return sse(route) if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/global/health") return json(route, {}, 404) if (url.pathname === "/session") return json(route, [current])
if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
if (url.pathname === `/session/${current.id}`) return json(route, current) if (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, []) if (url.pathname === `/session/${current.id}/message`) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, []) return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
return json(route, {})
if (url.pathname === "/provider") if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
@@ -121,20 +116,7 @@ async function mockServers(page: Page, requests: string[]) {
directory: current.directory, directory: current.directory,
home: current.directory, home: current.directory,
}) })
if (url.pathname === "/api/path")
return json(route, {
state: current.directory,
config: current.directory,
worktree: current.directory,
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: current.directory },
data: { branch: "main", defaultBranch: "main" },
})
return json(route, {}) return json(route, {})
}) })
} }
@@ -1,132 +0,0 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/OpenFileExpand"
const projectID = "proj_open_file_expand"
const sessionID = "ses_open_file_expand"
const title = "Open file expand"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({ viewport: { width: 1440, height: 900 } })
test("expands a folder whose path has a trailing Windows separator", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "open-file-expand",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: sessionID,
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [],
fileList: (path) => {
if (path === "frontend\\" || path === "frontend") {
return [
{
name: "app.ts",
path: "frontend\\app.ts",
absolute: `${directory}/frontend/app.ts`,
type: "file" as const,
ignored: false,
},
]
}
if (path) return []
return [
{
name: "frontend",
path: "frontend\\",
absolute: `${directory}/frontend`,
type: "directory" as const,
ignored: false,
},
{
name: "README.md",
path: "README.md",
absolute: `${directory}/README.md`,
type: "file" as const,
ignored: false,
},
]
},
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessionID }) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({ general: { newLayoutDesigns: true, shouldDisplayTabsToast: false } }),
)
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const panel = page.locator("#review-panel")
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
await expect(sidebar).toBeVisible()
const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]')
await expect(frontendRow).toBeVisible()
await expect(frontendRow).toHaveAttribute("aria-expanded", "false")
await frontendRow.click()
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]')
await expect(appRow).toBeVisible()
await appRow.click()
await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible()
})
@@ -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)
})
@@ -1,50 +0,0 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/PromptInputV2Editing"
const projectID = "proj_prompt_input_v2_editing"
const sessionID = "ses_prompt_input_v2_editing"
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "prompt-input-v2-editing",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [
{
id: sessionID,
slug: "prompt-input-v2-editing",
projectID,
directory,
title: "Prompt input V2 editing",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
const composer = page.locator('[data-component="prompt-input-v2"]')
const input = composer.locator('[data-component="prompt-input"]')
await expectAppVisible(composer)
await input.fill("keep me")
await composer.getByRole("button", { name: "Add images and files" }).click()
await page.getByRole("menuitem", { name: "Commands" }).click()
await page.locator('[data-suggestion-id="model.choose"]').click()
await expect(input).toHaveText("keep me")
})
@@ -56,7 +56,7 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
const composer = page.locator('[data-component="prompt-input-v2"]') const composer = page.locator('[data-component="prompt-input-v2"]')
const input = composer.locator('[data-component="prompt-input"]') const input = composer.locator('[data-component="prompt-input"]')
const control = composer.getByRole("button", { name: "Choose model variant" }) const control = composer.locator('button[title="Choose model variant"]')
await expectAppVisible(composer) await expectAppVisible(composer)
await idleComposer(page) await idleComposer(page)
@@ -1,7 +1,6 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { installSseTransport } from "../utils/sse-transport" import { installSseTransport } from "../utils/sse-transport"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096" const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097" const serverB = "http://127.0.0.1:4097"
@@ -18,7 +17,7 @@ test("session settings use the remote server context", async ({ page }) => {
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`) await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(page.getByText(sessionB.title).first()).toBeVisible() await expect(page.getByText(sessionB.title).first()).toBeVisible()
await page.keyboard.press("Control+,") await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
const dialog = page.locator(".settings-v2-dialog") const dialog = page.locator(".settings-v2-dialog")
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]') const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
@@ -59,7 +58,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`) await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByText(sessionA.title).first()).toBeVisible() await expect(page.getByText(sessionA.title).first()).toBeVisible()
await page.keyboard.press("Control+,") await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]') const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click() await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked() await expect(autoAccept.getByRole("switch")).toBeChecked()
@@ -181,36 +180,10 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, true) return json(route, true)
} }
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true }) if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent") if (url.pathname === "/session/status") return json(route, {})
return json(route, { data: [] }) if (url.pathname === "/session") return json(route, sessions)
if (url.pathname === "/api/model/default") return json(route, { data: null })
if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname))
return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
return json(route, { location: { directory }, data: { resources: [], templates: [] } })
if (url.pathname === "/api/project") {
return json(route, [
{
id: remote ? sessionB.projectID : "project-server-a",
worktree: directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
},
])
}
if (url.pathname === "/api/project/current")
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
return json(route, { data: [], cursor: {} })
const current = sessions.find((session) => url.pathname === `/session/${session.id}`) const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
if (current) return json(route, current) if (current) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
@@ -243,12 +216,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
directory, directory,
home: directory, home: directory,
}) })
if (url.pathname === "/api/path")
return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory })
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
return json(route, {}) return json(route, {})
}) })
} }
@@ -1,6 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096" const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097" const serverB = "http://127.0.0.1:4097"
@@ -58,15 +57,11 @@ async function mockServers(page: Page) {
const current = url.origin === serverA ? sessionA : sessionB const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory") const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
return sse(route, url.pathname === "/api/event") if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/global/health") return json(route, {}, 404) if (url.pathname === "/session/status")
if (url.pathname === "/api/health") return json(route, { pid: 1 }) return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {})
if (url.pathname === "/api/session/active") if (url.pathname === "/session") return json(route, [current])
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
if (url.pathname === `/session/${current.id}`) return json(route, current) if (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, []) if (url.pathname === `/session/${current.id}/message`) return json(route, [])
@@ -95,20 +90,7 @@ async function mockServers(page: Page) {
directory: current.directory, directory: current.directory,
home: current.directory, home: current.directory,
}) })
if (url.pathname === "/api/path")
return json(route, {
state: current.directory,
config: current.directory,
worktree: current.directory,
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: current.directory },
data: { branch: "main", defaultBranch: "main" },
})
return json(route, {}) return json(route, {})
}) })
} }
@@ -122,10 +104,6 @@ function json(route: Route, body: unknown, status = 200) {
}) })
} }
function sse(route: Route, current: boolean) { function sse(route: Route) {
return route.fulfill({ return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
status: 200,
contentType: "text/event-stream",
body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n",
})
} }
@@ -84,7 +84,6 @@ test("stages a submitted line comment in the prompt context", async ({ page }) =
async function openReview(page: Page) { async function openReview(page: Page) {
await page.setViewportSize({ width: 700, height: 900 }) await page.setViewportSize({ width: 700, height: 900 })
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: { project: {
id: "proj_review_line_comment_regression", id: "proj_review_line_comment_regression",
@@ -144,9 +143,9 @@ async function openReview(page: Page) {
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title) await expectSessionTitle(page, title)
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff") const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff")
await page.getByRole("tab", { name: "Changes" }).click() await page.getByRole("tab", { name: "Changes" }).click()
expect((await (await diffResponse).json()).data).toHaveLength(1) expect(await (await diffResponse).json()).toHaveLength(1)
const review = page.locator('[data-component="session-review"]') const review = page.locator('[data-component="session-review"]')
await expectAppVisible(review) await expectAppVisible(review)
@@ -133,7 +133,7 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1) await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeDisabled() await expect(sidebarToggle).toBeDisabled()
await panel.locator("#session-side-panel-review-tab").click() await panel.getByRole("tab", { name: /Review/ }).click()
await expect(sidebarToggle).toBeEnabled() await expect(sidebarToggle).toBeEnabled()
await panel.getByRole("tab", { name: "Open file" }).click() await panel.getByRole("tab", { name: "Open file" }).click()
await page.keyboard.press("Control+w") await page.keyboard.press("Control+w")
@@ -46,7 +46,7 @@ test("restores review mode and selected file per session", async ({ page }) => {
async function selectMode(page: Page, current: string, next: string) { async function selectMode(page: Page, current: string, next: string) {
await page.getByRole("button", { name: current }).click() await page.getByRole("button", { name: current }).click()
await page.getByRole("option", { name: next }).dispatchEvent("click") await page.getByRole("option", { name: next }).click()
} }
async function selectFile(page: Page, file: string) { async function selectFile(page: Page, file: string) {
@@ -65,7 +65,6 @@ async function switchSession(page: Page, title: string) {
async function setup(page: Page) { async function setup(page: Page) {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v1",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -20,12 +20,10 @@ const branchDiffs = [
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => { test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
const events: Array<{ directory: string; payload: Record<string, unknown> }> = [] const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } }
let detailVersion = 1 let detailVersion = 1
let detailFailures = 1 let detailFailures = 1
await page.setViewportSize({ width: 1400, height: 900 }) await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v1",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -57,7 +55,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
time: { created: 1700000000000, updated: 1700000000000 }, time: { created: 1700000000000, updated: 1700000000000 },
}, },
], ],
sessionStatus: () => sessionStatus, sessionStatus: { [sessionID]: { type: "idle" } },
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1), events: () => events.splice(0, 1),
eventRetry: 16, eventRetry: 16,
@@ -66,10 +64,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }),
branch: "review-pane-performance",
default_branch: "dev",
}),
}), }),
) )
await page.route("**/vcs/diff**", (route) => { await page.route("**/vcs/diff**", (route) => {
@@ -91,51 +86,15 @@ test("keeps the review tree and terminal sized when both panels are open", async
), ),
}) })
}) })
await page.route("**/pty*", (route) => await page.route("**/pty", (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }),
location: { directory, project: { id: projectID, directory } },
data: {
id: "pty_review_terminal",
title: "Terminal 1",
command: "cmd.exe",
args: [],
cwd: directory,
status: "running",
pid: 1,
},
}),
}), }),
) )
await page.route("**/pty/pty_review_terminal*", (route) => await page.route("**/pty/pty_review_terminal", (route) =>
route.fulfill({ route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: {
id: "pty_review_terminal",
title: "Terminal 1",
command: "cmd.exe",
args: [],
cwd: directory,
status: "running",
pid: 1,
},
}),
}),
)
await page.route("**/pty/pty_review_terminal/connect-token*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: { ticket: "e2e-ticket", expires_in: 60 },
}),
}),
) )
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined) await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
await page.addInitScript(() => { await page.addInitScript(() => {
@@ -184,7 +143,6 @@ test("keeps the review tree and terminal sized when both panels are open", async
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]') const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
await expect(preview).toContainText("after-1") await expect(preview).toContainText("after-1")
detailVersion = 2 detailVersion = 2
sessionStatus[sessionID] = { type: "busy" }
events.push(statusEvent("busy")) events.push(statusEvent("busy"))
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
const refreshedDiff = page.waitForRequest((request) => { const refreshedDiff = page.waitForRequest((request) => {
@@ -194,7 +152,6 @@ test("keeps the review tree and terminal sized when both panels are open", async
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
) )
}) })
sessionStatus[sessionID] = { type: "idle" }
events.push(statusEvent("idle")) events.push(statusEvent("idle"))
await refreshedDiff await refreshedDiff
await expect(preview).toContainText("after-2") await expect(preview).toContainText("after-2")
@@ -16,8 +16,8 @@ test("shows loaded sessions before the directory path request resolves", async (
const pathBlocked = new Promise<void>((resolve) => { const pathBlocked = new Promise<void>((resolve) => {
releasePath = resolve releasePath = resolve
}) })
await page.route("**/api/path?*", async (route) => { await page.route("**/path?*", async (route) => {
if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback() if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback()
await pathBlocked await pathBlocked
return route.fallback() return route.fallback()
}) })
@@ -42,8 +42,7 @@ test("shows a pending question dock", async ({ page }) => {
const rejectRequests: string[] = [] const rejectRequests: string[] = []
page.on("request", (request) => { page.on("request", (request) => {
if (request.method() !== "POST") return if (request.method() !== "POST") return
if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`) if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
rejectRequests.push(request.url())
}) })
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click() await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
@@ -65,9 +64,7 @@ test("shows a pending question dock", async ({ page }) => {
await question.getByRole("radio", { name: /Minimal/ }).click() await question.getByRole("radio", { name: /Minimal/ }).click()
const reply = page.waitForRequest( const reply = page.waitForRequest(
(request) => (request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
request.method() === "POST" &&
new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
) )
await question.getByRole("button", { name: "Submit" }).click() await question.getByRole("button", { name: "Submit" }).click()
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] }) expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
@@ -100,8 +97,8 @@ test("shows a pending permission dock", async ({ page }) => {
const reply = page.waitForRequest((request) => request.method() === "POST") const reply = page.waitForRequest((request) => request.method() === "POST")
await permission.getByRole("button", { name: "Allow once" }).click() await permission.getByRole("button", { name: "Allow once" }).click()
const request = await reply const request = await reply
expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`) expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`)
expect(request.postDataJSON()).toEqual({ reply: "once" }) expect(request.postDataJSON()).toEqual({ response: "once" })
}) })
test("restores the draft caret before typing after a request dock closes", async ({ page }) => { test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
@@ -173,7 +170,6 @@ async function mockServer(
}, },
) { ) {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -32,23 +32,6 @@ for (const expanded of [false, true]) {
}) })
} }
test("shows and expands a running shell command without shimmering it", async ({ page }) => {
const id = "prt_shell_running_command"
const command = "sleep 10 && echo done"
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })],
settings: { shellToolPartsExpanded: false },
})
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command)
await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0)
await tool.locator('[data-slot="collapsible-trigger"]').click()
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
})
test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => { test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => {
const reasoningID = "prt_reasoning_hidden" const reasoningID = "prt_reasoning_hidden"
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false }) const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
@@ -89,8 +89,8 @@ test("reconnects after a stream error", async ({ page }) => {
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error") expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
}) })
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => { test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" }) const timeline = await setupTimeline(page, { eventRetry: 10 })
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), { const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
id: "timeline-event-7", id: "timeline-event-7",
}) })
@@ -100,7 +100,7 @@ test("does not request replay when reconnecting the volatile V2 event stream", a
const connection = await timeline.transport.waitForConnection({ after: first.connectionID }) const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
expect(first.eventID).toBe("timeline-event-7") expect(first.eventID).toBe("timeline-event-7")
expect(connection.headers["last-event-id"]).toBeUndefined() expect(connection.headers["last-event-id"]).toBe("timeline-event-7")
}) })
test("passes through non-event fetches", async ({ page }) => { test("passes through non-event fetches", async ({ page }) => {
@@ -27,7 +27,6 @@ test("animates todo lifecycle without replaying it across session tabs", async (
test.setTimeout(90_000) test.setTimeout(90_000)
const events: EventPayload[] = [] const events: EventPayload[] = []
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] } const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
const sessionStatus: Record<string, { type: "busy" | "idle" }> = {}
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
directory, directory,
@@ -57,11 +56,9 @@ test("animates todo lifecycle without replaying it across session tabs", async (
default: { providerID: "opencode", modelID: "claude-opus-4-6" }, default: { providerID: "opencode", modelID: "claude-opus-4-6" },
}, },
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)], sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
sessionStatus: { [sourceID]: { type: "busy" } },
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1), events: () => events.splice(0, 1),
eventRetry: 16, eventRetry: 16,
sessionStatus: () => sessionStatus,
todos: (sessionID) => todos[sessionID] ?? [], todos: (sessionID) => todos[sessionID] ?? [],
}) })
await configurePage(page) await configurePage(page)
@@ -71,7 +68,6 @@ test("animates todo lifecycle without replaying it across session tabs", async (
const dock = page.locator('[data-component="session-todo-dock"]') const dock = page.locator('[data-component="session-todo-dock"]')
await expect(dock).toHaveCount(0) await expect(dock).toHaveCount(0)
sessionStatus[sourceID] = { type: "busy" }
events.push(statusEvent(sourceID, "busy")) events.push(statusEvent(sourceID, "busy"))
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
@@ -1,6 +1,6 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { currentSession, mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/SubagentNavigation" const directory = "C:/OpenCode/SubagentNavigation"
@@ -72,19 +72,16 @@ async function setup(page: Page, events?: () => EventPayload[]) {
events, events,
eventRetry: events ? 16 : undefined, eventRetry: events ? 16 : undefined,
}) })
// The child session resolves by ID but is absent from the session list, // The child session resolves via /session/:id but is absent from the /session list,
// matching a subagent session that has not been loaded into the list cache yet. // matching a subagent session that has not been loaded into the list cache yet.
await page.route( await page.route(
(url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"), (url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
(route) => (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
headers: { "access-control-allow-origin": "*" }, headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]),
data: [currentSession(session(parentID, parentTitle, 1700000000000))],
cursor: {},
}),
}), }),
) )
await configurePage(page) await configurePage(page)
@@ -1,12 +1,9 @@
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
const server = "http://127.0.0.1:4096" const server = "http://127.0.0.1:4096"
const sessionA = session("ses_tab_a", "Tab A session") const sessionA = session("ses_tab_a", "Tab A session")
const sessionB = session("ses_tab_b", "Tab B session") const sessionB = session("ses_tab_b", "Tab B session")
const sessionC = session("ses_tab_c", "Tab C session")
const unresolvedSessionID = "ses_tab_unresolved"
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => { test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
await mockServer(page) await mockServer(page)
@@ -42,34 +39,6 @@ test("pressing mouse down on a tab navigates before mouse up", async ({ page })
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
}) })
test("keyboard navigation follows the visible tab order", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, unresolved, sessionC }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionA },
{ type: "session", server, sessionId: unresolved },
{ type: "session", server, sessionId: sessionC },
]),
)
},
{ server, sessionA: sessionA.id, unresolved: unresolvedSessionID, sessionC: sessionC.id },
)
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}`
await page.goto(hrefA)
await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(2)
await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefC}"])`)).toBeVisible()
await page.keyboard.press("Control+Alt+ArrowRight")
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
})
function session(id: string, title: string) { function session(id: string, title: string) {
return { return {
id, id,
@@ -83,21 +52,13 @@ function session(id: string, title: string) {
} }
async function mockServer(page: Page) { async function mockServer(page: Page) {
const sessions = [sessionA, sessionB, sessionC] const sessions = [sessionA, sessionB]
await page.route("**/*", async (route) => { await page.route("**/*", async (route) => {
const url = new URL(route.request().url()) const url = new URL(route.request().url())
if (url.origin !== server) return route.fallback() if (url.origin !== server) return route.fallback()
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname)) if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
return new Promise(() => {})
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true }) if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) if (url.pathname === "/session") return json(route, sessions)
if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
return json(route, { data: [], cursor: {} })
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`) const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
if (byId) return json(route, byId) if (byId) return json(route, byId)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
@@ -105,7 +66,8 @@ async function mockServer(page: Page) {
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, []) return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
return json(route, {})
if (url.pathname === "/provider") if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
@@ -127,20 +89,7 @@ async function mockServer(page: Page) {
directory: sessionA.directory, directory: sessionA.directory,
home: sessionA.directory, home: sessionA.directory,
}) })
if (url.pathname === "/api/path")
return json(route, {
state: sessionA.directory,
config: sessionA.directory,
worktree: sessionA.directory,
directory: sessionA.directory,
home: sessionA.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: sessionA.directory },
data: { branch: "main", defaultBranch: "main" },
})
return json(route, {}) return json(route, {})
}) })
} }
@@ -13,7 +13,6 @@ test.use({ viewport: { width: 1440, height: 900 } })
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -47,30 +46,25 @@ test.beforeEach(async ({ page }) => {
], ],
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
}) })
await page.route("**/api/pty*", (route) => { await page.route("**/pty", (route) =>
expect(new URL(route.request().url()).searchParams.get("location[directory]")).toBe(directory)
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
})
})
await page.route(`**/api/pty/${ptyID}*`, (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }), body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
}), }),
) )
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => await page.route(`**/pty/${ptyID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
headers: { "access-control-allow-origin": "*" }, headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), body: JSON.stringify({ ticket: "e2e-ticket" }),
}), }),
) )
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined) await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), () => undefined)
await page.addInitScript(() => { await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
}) })
@@ -101,12 +95,12 @@ test("keeps composer focus when a cached terminal finishes mounting", async ({ p
const ghostty = Promise.withResolvers<void>() const ghostty = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>() const release = Promise.withResolvers<void>()
const created = { count: 0 } const created = { count: 0 }
await page.route("**/api/pty*", (route) => { await page.route("**/pty", (route) => {
created.count += 1 created.count += 1
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }), body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
}) })
}) })
await page.route(/ghostty-web/, async (route) => { await page.route(/ghostty-web/, async (route) => {
@@ -161,31 +155,27 @@ test("keeps newer composer focus while an explicit terminal open finishes", asyn
test("focuses a terminal created from the new-terminal button", async ({ page }) => { test("focuses a terminal created from the new-terminal button", async ({ page }) => {
const created = { count: 0 } const created = { count: 0 }
await page.route("**/api/pty*", (route) => { await page.route("**/pty", (route) => {
created.count += 1 created.count += 1
const next = created.count === 1 ? ptyInfo(ptyID, "Terminal 1") : ptyInfo(newPtyID, "Terminal 2") const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" }
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ location: ptyLocation(), data: next }), body: JSON.stringify(next),
}) })
}) })
await page.route(`**/api/pty/${newPtyID}*`, (route) => await page.route(`**/pty/${newPtyID}`, (route) =>
route.fulfill({ route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
status: 200,
contentType: "application/json",
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(newPtyID, "Terminal 2") }),
}),
) )
await page.route(`**/api/pty/${newPtyID}/connect-token*`, (route) => await page.route(`**/pty/${newPtyID}/connect-token*`, (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
headers: { "access-control-allow-origin": "*" }, headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), body: JSON.stringify({ ticket: "e2e-ticket" }),
}), }),
) )
await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined) await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus") await expectSessionTitle(page, "Terminal composer focus")
@@ -217,11 +207,3 @@ function seedCachedTerminal(page: Page) {
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID }, { terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
) )
} }
function ptyLocation() {
return { directory, project: { id: projectID, directory } }
}
function ptyInfo(id: string, title: string) {
return { id, title, command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
}
@@ -10,7 +10,6 @@ const title = "Hidden terminal regression"
test("unmounts the terminal panel while it is hidden", async ({ page }) => { test("unmounts the terminal panel while it is hidden", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 }) await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -44,53 +43,17 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
], ],
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
}) })
await page.route("**/api/pty*", (route) => await page.route("**/pty", (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ body: JSON.stringify({ id: "pty_hidden_terminal", title: "Terminal 1" }),
location: { directory, project: { id: projectID, directory } },
data: {
id: "pty_hidden_terminal",
title: "Terminal 1",
command: "cmd.exe",
args: [],
cwd: directory,
status: "running",
pid: 1,
},
}),
}), }),
) )
await page.route("**/api/pty/pty_hidden_terminal*", (route) => await page.route("**/pty/pty_hidden_terminal", (route) =>
route.fulfill({ route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: {
id: "pty_hidden_terminal",
title: "Terminal 1",
command: "cmd.exe",
args: [],
cwd: directory,
status: "running",
pid: 1,
},
}),
}),
) )
await page.route("**/api/pty/pty_hidden_terminal/connect-token*", (route) => await page.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined)
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: { ticket: "e2e-ticket", expires_in: 60 },
}),
}),
)
await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title) await expectSessionTitle(page, title)
@@ -29,10 +29,6 @@ test("keeps the terminal session alive when switching session tabs in a workspac
const terminal = page.locator('[data-component="terminal"]') const terminal = page.locator('[data-component="terminal"]')
await expect(terminal).toBeVisible() await expect(terminal).toBeVisible()
await expect.poll(() => connections.length).toBe(1) await expect.poll(() => connections.length).toBe(1)
const connection = new URL(connections[0]!)
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
expect(connection.searchParams.get("location[directory]")).toBe(directory)
expect(connection.searchParams.get("ticket")).toBeNull()
await writeProbe(page) await writeProbe(page)
await switchTab(page, titleB) await switchTab(page, titleB)
@@ -66,7 +62,6 @@ async function readProbe(page: Page) {
async function setup(page: Page) { async function setup(page: Page) {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -90,33 +85,26 @@ async function setup(page: Page) {
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
}) })
await page.route("**/api/pty*", (route) => await page.route("**/pty", (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }), body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
}), }),
) )
await page.route(`**/api/pty/${ptyID}*`, (route) => await page.route(`**/pty/${ptyID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
route.fulfill({ route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
}),
)
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => {
expect(route.request().headers()["x-opencode-ticket"]).toBe("1")
const url = new URL(route.request().url())
expect(url.searchParams.get("location[directory]")).toBe(directory)
return route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
headers: { "access-control-allow-origin": "*" }, headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), body: JSON.stringify({ ticket: "e2e-ticket" }),
}) }),
}) )
const connections: string[] = [] const connections: string[] = []
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => { await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), (ws) => {
connections.push(ws.url()) connections.push(ws.url())
}) })
@@ -155,11 +143,3 @@ function session(id: string, title: string, created: number) {
function sessionHref(sessionID: string) { function sessionHref(sessionID: string) {
return `/server/${base64Encode(server)}/session/${sessionID}` return `/server/${base64Encode(server)}/session/${sessionID}`
} }
function ptyLocation() {
return { directory, project: { id: projectID, directory } }
}
function ptyInfo() {
return { id: ptyID, title: "Terminal 1", command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
}
@@ -1,97 +0,0 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/NewProject"
test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => {
let connectedGo = false
let pendingGo = false
const connections: Array<{ integrationID: string; body: unknown }> = []
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_model_selection_flow",
worktree: directory,
vcs: "git",
name: "NewProject",
time: { created: 1_700_000_000_000, updated: 1_700_000_000_000 },
sandboxes: [],
},
provider: () => ({
all: [
{
id: "opencode",
name: "OpenCode",
models: {
"free-model": {
id: "free-model",
name: "Free Model",
cost: { input: 0, output: 0 },
limit: { context: 200_000 },
},
},
},
{
id: "opencode-go",
name: "OpenCode Go",
models: {
"go-model-1": {
id: "go-model-1",
name: "Go Model 1",
cost: { input: 1, output: 1 },
limit: { context: 200_000 },
},
},
},
],
connected: connectedGo ? ["opencode", "opencode-go"] : ["opencode"],
default: { providerID: "opencode", modelID: "free-model" },
}),
integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] },
onConnectKey: (input) => {
connections.push(input)
if (input.integrationID === "opencode-go") pendingGo = true
},
onInstanceDispose: () => {
if (pendingGo) connectedGo = true
},
sessions: [],
pageMessages: () => ({ items: [] }),
fileList: (path) =>
path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }],
findFiles: () => ["NewProject"],
})
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
})
await page.goto("/")
const addProject = page.locator('[data-action="home-add-project-row"]')
await expectAppVisible(addProject)
await addProject.click()
await page.locator("[data-directory-path]").click()
await page.locator('[data-action="home-new-session"]').click()
await expectAppVisible(page.locator('[data-component="prompt-input-v2"]'))
const modelControl = page.locator('[data-action="prompt-model"]')
await modelControl.click()
await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode")
await page.locator('[data-provider-id="opencode-go"]').click()
await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key")
await page.locator('[data-action="provider-connect-submit"]').click()
await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0)
expect(connections).toEqual([{ integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } }])
await expect(modelControl).toHaveAttribute("data-control-type", "popover")
await modelControl.click()
const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
await expect(goModel).toBeVisible()
await goModel.click()
await expect(modelControl).toContainText("Go Model 1")
})
+14 -291
View File
@@ -4,11 +4,7 @@ const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/sta
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"]) const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
export interface MockServerConfig { export interface MockServerConfig {
protocol?: "v1" | "v2" provider: unknown
provider: unknown | (() => unknown)
integrationMethods?: Record<string, unknown[]>
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
onInstanceDispose?: () => void
directory: string directory: string
project: unknown project: unknown
sessions: ({ id: string } & Record<string, unknown>)[] sessions: ({ id: string } & Record<string, unknown>)[]
@@ -27,13 +23,14 @@ export interface MockServerConfig {
fileList?: (path: string) => unknown | Promise<unknown> fileList?: (path: string) => unknown | Promise<unknown>
fileContent?: (path: string) => unknown | Promise<unknown> fileContent?: (path: string) => unknown | Promise<unknown>
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>) sessionStatus?: unknown
} }
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const cursors = new Map<string, string>() const cursors = new Map<string, string>()
let nextCursor = 0 let nextCursor = 0
const staticRoutes: Record<string, unknown> = { const staticRoutes: Record<string, unknown> = {
"/provider": config.provider,
"/path": { "/path": {
state: config.directory, state: config.directory,
config: config.directory, config: config.directory,
@@ -57,47 +54,19 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (url.port !== targetPort && url.port !== appPort) return route.fallback() if (url.port !== targetPort && url.port !== appPort) return route.fallback()
const path = url.pathname const path = url.pathname
if (path === "/global/event" || path === "/event" || path === "/api/event") { if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
const events = config.events?.() if (path === "/global/health") return json(route, { healthy: true })
return sse( if (path === "/api/session")
route, return json(route, {
path === "/api/event" data: config.sessions.map((session) => v2Session(session, config.directory)),
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])] cursor: {},
: [ })
...(path === "/global/event" if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
: []),
...(events ?? []),
],
config.eventRetry,
)
}
if (path === "/global/health")
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
if (path === "/api/health" && config.protocol === "v2")
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
if (path === "/provider")
return json(route, typeof config.provider === "function" ? config.provider() : config.provider)
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
if (legacyAuth && route.request().method() === "PUT") {
config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() })
return json(route, true)
}
if (path === "/instance/dispose" && route.request().method() === "POST") {
config.onInstanceDispose?.()
return json(route, true)
}
if (path === "/permission") if (path === "/permission")
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
if (path === "/question") if (path === "/question")
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? [])) return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
if (path === "/session/status") if (path === "/session/status") return json(route, config.sessionStatus ?? {})
return json(
route,
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}),
)
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff) if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
if (path === "/file" && config.fileList) if (path === "/file" && config.fileList)
return json(route, await config.fileList(url.searchParams.get("path") ?? "")) return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
@@ -120,138 +89,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}, },
data: [], data: [],
}) })
if (path === "/api/agent")
return json(route, {
location: location(config),
data: [
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
},
],
})
if (path === "/api/command") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp/resource")
return json(route, { location: location(config), data: { resources: [], templates: [] } })
const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1]
if (integration && route.request().method() === "GET")
return json(route, {
location: location(config),
data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
})
const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
if (integrationConnect && route.request().method() === "POST") {
config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (path === "/api/project") return json(route, [config.project])
if (path === "/api/project/current")
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project)
if (path === "/api/path")
return json(route, {
state: config.directory,
config: config.directory,
worktree: config.directory,
directory: config.directory,
home: "C:/OpenCode",
})
if (path === "/api/permission/request")
return json(route, {
location: location(config),
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
currentPermission,
),
})
if (path === "/api/question/request")
return json(route, {
location: location(config),
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
})
if (path === "/api/vcs")
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
if (emptyObject.has(path)) return json(route, {}) if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, []) if (emptyList.has(path)) return json(route, [])
if (path === "/api/session") {
const directory = url.searchParams.get("directory")
const parentID = url.searchParams.get("parentID")
const limit = Number(url.searchParams.get("limit") ?? 50)
const offset = Number(url.searchParams.get("cursor") ?? 0)
const sessions = config.sessions
.filter((session) => !directory || session.directory === directory)
.filter((session) => parentID !== "null" || session.parentID === undefined)
.filter((session) => {
const search = url.searchParams.get("search")?.toLowerCase()
return (
!search ||
String(session.title ?? "")
.toLowerCase()
.includes(search)
)
})
const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions
const data = ordered.slice(offset, offset + limit)
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
return json(route, {
data: data.map((session) => currentSession(session, config.directory)),
cursor: { next },
})
}
if (path === "/api/session/active") {
const statuses = (config.sessionStatus ?? {}) as Record<string, { type?: string }>
return json(route, {
data: Object.fromEntries(
Object.entries(statuses).flatMap(([id, status]) =>
status.type === "idle" ? [] : [[id, { type: "running" }]],
),
),
})
}
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
return json(route, true)
}
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") {
return json(route, true)
}
if (
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
route.request().method() === "POST"
) {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (path in staticRoutes) return json(route, staticRoutes[path]) if (path in staticRoutes) return json(route, staticRoutes[path])
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
if (currentSessionMatch) {
const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
if (!session) return json(route, { error: "Session not found" }, undefined, 404)
return json(route, {
data: currentSession(session, config.directory),
})
}
const sessionMatch = path.match(/^\/session\/([^/]+)$/) const sessionMatch = path.match(/^\/session\/([^/]+)$/)
if (sessionMatch) { if (sessionMatch) {
const session = config.sessions.find((s) => s.id === sessionMatch[1]) const session = config.sessions.find((s) => s.id === sessionMatch[1])
@@ -274,24 +115,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? []) if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
if (currentMessagesMatch) {
const token = url.searchParams.get("cursor") ?? undefined
const before = token ? cursors.get(token) : undefined
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" })
await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" })
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
if (cursor) cursors.set(cursor, pageData.cursor!)
return json(route, {
data: pageData.items.map(currentMessage).reverse(),
cursor: { next: cursor },
})
}
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
if (messagesMatch) { if (messagesMatch) {
const token = url.searchParams.get("before") ?? undefined const token = url.searchParams.get("before") ?? undefined
@@ -314,37 +137,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}) })
} }
function location(config: MockServerConfig) { function v2Session(session: { id: string } & Record<string, unknown>, fallbackDirectory: string) {
return {
directory: config.directory,
project: { id: (config.project as { id?: string }).id, directory: config.directory },
}
}
function currentPermission(value: unknown) {
const permission = value as Record<string, unknown>
if (permission.action) return permission
const tool = permission.tool as { messageID?: string; callID?: string } | undefined
return {
id: permission.id,
sessionID: permission.sessionID,
action: permission.permission,
resources: permission.patterns ?? [],
save: permission.always,
metadata: permission.metadata,
source:
tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
}
}
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
const time = session.time && typeof session.time === "object" ? session.time : {} const time = session.time && typeof session.time === "object" ? session.time : {}
return { return {
id: session.id, id: session.id,
parentID: session.parentID, parentID: session.parentID,
projectID: session.projectID ?? "project", projectID: session.projectID ?? "project",
agent: session.agent ?? "build",
model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
cost: session.cost ?? 0, cost: session.cost ?? 0,
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { time: {
@@ -359,67 +157,7 @@ export function currentSession(session: { id: string } & Record<string, unknown>
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory, directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}), ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
}, },
subpath: session.path, ...(typeof session.path === "string" ? { subpath: session.path } : {}),
revert: session.revert,
}
}
function currentMessage(value: unknown) {
const item = value as {
info: Record<string, unknown> & { id: string; role: "user" | "assistant"; time: { created: number } }
parts: Array<Record<string, unknown> & { type: string }>
}
if (item.info.role === "user") {
return {
id: item.info.id,
type: "user",
time: item.info.time,
text: item.parts
.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
.join("\n"),
}
}
return {
id: item.info.id,
type: "assistant",
time: item.info.time,
agent: item.info.agent ?? "build",
model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" },
cost: item.info.cost,
tokens: item.info.tokens,
error: item.info.error,
content: item.parts.flatMap<unknown>((part) => {
if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }]
if (part.type !== "tool") return []
const state = part.state as Record<string, unknown>
return [
{
type: "tool",
id: part.id,
name: part.tool,
time: state.time ?? { created: item.info.time.created },
state:
state.status === "pending"
? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) }
: state.status === "completed"
? {
status: "completed",
input: state.input ?? {},
structured: state.metadata ?? {},
content: [{ type: "text", text: state.output ?? "" }],
}
: state.status === "error"
? {
status: "error",
input: state.input ?? {},
structured: state.metadata ?? {},
content: [],
error: { type: "ToolError", message: state.error ?? "Tool failed" },
}
: { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] },
},
]
}),
} }
} }
@@ -443,18 +181,3 @@ function sse(route: Route, events?: unknown[], retry?: number) {
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`, body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
}) })
} }
function currentEvent(input: unknown) {
if (!input || typeof input !== "object" || !("payload" in input)) return input
const envelope = input as { directory?: string; payload?: unknown }
if (!envelope.payload || typeof envelope.payload !== "object") return input
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
if (!payload.type) return input
return {
id: payload.id ?? `evt_mock_${Date.now()}`,
created: Date.now(),
type: payload.type,
data: payload.properties ?? {},
location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
}
}
+9 -37
View File
@@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
export type SseConnectionRecord = { export type SseConnectionRecord = {
id: number id: number
url: string url: string
path: "/global/event" | "/event" | "/api/event" path: "/global/event" | "/event"
headers: Record<string, string> headers: Record<string, string>
openedAt: number openedAt: number
endedAt?: number endedAt?: number
@@ -93,21 +93,6 @@ export async function installSseTransport<T>(
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`, eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
`data: ${JSON.stringify(payload)}\n\n`, `data: ${JSON.stringify(payload)}\n\n`,
].join("") ].join("")
const currentEvent = (input: unknown) => {
if (!input || typeof input !== "object" || !("payload" in input)) return input
const envelope = input as { directory?: string; payload?: unknown }
if (!envelope.payload || typeof envelope.payload !== "object") return input
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
if (!payload.type) return input
return {
id: payload.id ?? `evt_mock_${Date.now()}`,
created: Date.now(),
type: payload.type,
data: payload.properties ?? {},
location:
envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
}
}
const acknowledge = ( const acknowledge = (
connection: Connection, connection: Connection,
bytes: number, bytes: number,
@@ -155,13 +140,15 @@ export async function installSseTransport<T>(
output.forEach((chunk) => connection.controller.enqueue(chunk)) output.forEach((chunk) => connection.controller.enqueue(chunk))
return acknowledge(connection, input.bytes.length, output.length) return acknowledge(connection, input.bytes.length, output.length)
} }
const encoded = input.deliveries.map((delivery) => { const encoded = input.deliveries.map((delivery) => ({
const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload delivery,
return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) } bytes: encoder.encode(frame(delivery.payload, delivery.options)),
}) }))
encoded.forEach((item) => marker(item.delivery.options?.marker)) encoded.forEach((item) => marker(item.delivery.options?.marker))
if (input.burst) { if (input.burst) {
const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join("")) const bytes = encoder.encode(
encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""),
)
connection.controller.enqueue(bytes) connection.controller.enqueue(bytes)
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id)) return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
} }
@@ -174,10 +161,7 @@ export async function installSseTransport<T>(
const fetch = (input: RequestInfo | URL, init?: RequestInit) => { const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init) const request = new Request(input, init)
const url = new URL(request.url) const url = new URL(request.url)
if ( if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
url.origin !== server ||
(url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
)
return originalFetch(request) return originalFetch(request)
const id = ++nextConnectionID const id = ++nextConnectionID
@@ -193,18 +177,6 @@ export async function installSseTransport<T>(
record.controller = controller record.controller = controller
connections.push(record) connections.push(record)
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`)) if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
if (url.pathname === "/api/event")
controller.enqueue(
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
)
if (url.pathname === "/global/event")
controller.enqueue(
encoder.encode(
frame({
payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
}),
),
)
request.signal.addEventListener( request.signal.addEventListener(
"abort", "abort",
() => { () => {
+4 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.18.12", "version": "1.18.3",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -19,9 +19,9 @@
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"test": "bun run test:unit && bun run test:browser", "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: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": "playwright test",
"test:e2e:local": "playwright test", "test:e2e:local": "playwright test",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
@@ -53,7 +53,6 @@
"@dnd-kit/helpers": "0.5.0", "@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0", "@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -82,7 +81,7 @@
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", "ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
"luxon": "catalog:", "luxon": "catalog:",
"marked": "catalog:", "marked": "catalog:",
"marked-shiki": "catalog:", "marked-shiki": "catalog:",
@@ -37,14 +37,6 @@ function writeAndWait(term: Terminal, data: string): Promise<void> {
} }
describe("SerializeAddon", () => { describe("SerializeAddon", () => {
test("preserves color scheme reporting mode", async () => {
const { term, addon } = createTerminal()
await writeAndWait(term, "\x1b[?2031h")
expect(addon.serialize().startsWith("\x1b[?2031h")).toBe(true)
expect(addon.serialize({ excludeModes: true }).startsWith("\x1b[?2031h")).toBe(false)
})
describe("ANSI color preservation", () => { describe("ANSI color preservation", () => {
test("should preserve text attributes (bold, italic, underline)", async () => { test("should preserve text attributes (bold, italic, underline)", async () => {
const { term, addon } = createTerminal() const { term, addon } = createTerminal()
+1 -9
View File
@@ -89,13 +89,6 @@ const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined =
return { active, normal, alternate } return { active, normal, alternate }
} }
const getTerminalMode = (value: ITerminalCore, mode: number) => {
if (!isRecord(value)) return false
const terminal = value.wasmTerm
if (!isRecord(terminal) || typeof terminal.getMode !== "function") return false
return terminal.getMode(mode) === true
}
// ============================================================================ // ============================================================================
// Types // Types
// ============================================================================ // ============================================================================
@@ -551,8 +544,7 @@ export class SerializeAddon implements ITerminalAddon {
return "" return ""
} }
let content = !options?.excludeModes && getTerminalMode(this._terminal, 2031) ? "\u001b[?2031h" : "" let content = options?.range
content += options?.range
? this._serializeBufferByRange(normalBuffer, options.range, true) ? this._serializeBufferByRange(normalBuffer, options.range, true)
: this._serializeBufferByScrollback(normalBuffer, options?.scrollback) : this._serializeBufferByScrollback(normalBuffer, options?.scrollback)
+1 -26
View File
@@ -67,8 +67,7 @@ import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref }
import { createSessionLineage } from "@/pages/session/session-lineage" import { createSessionLineage } from "@/pages/session/session-lineage"
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
import { NewHome } from "@/pages/home" import { NewHome, LegacyHome } from "@/pages/home"
import { LegacyHome } from "@/pages/home/legacy-home"
const NewSession = lazy(() => import("@/pages/new-session")) const NewSession = lazy(() => import("@/pages/new-session"))
@@ -237,30 +236,6 @@ function UiI18nBridge(props: ParentProps) {
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider> return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
} }
function LayoutCompatibility(props: ParentProps) {
const global = useGlobal()
const navigate = useNavigate()
const server = useServer()
const settings = useSettings()
createEffect(() => {
if (settings.general.newLayoutDesigns()) return
const current = server.current
if (!current) return
const protocol = global.ensureServerCtx(current).sdk.protocolKind()
if (protocol !== "v2") return
const next = global.servers.list().find((s) => {
if (ServerConnection.key(s) === ServerConnection.key(current)) return false
return global.ensureServerCtx(s).sdk.protocolKind() !== "v2"
})
if (!next) return
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(next)))
})
return <>{props.children}</>
}
declare global { declare global {
interface Window { interface Window {
__OPENCODE__?: { __OPENCODE__?: {
@@ -1,6 +1,5 @@
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import type { Project } from "@opencode-ai/sdk/v2/client" import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createMemo, onCleanup } from "solid-js" import { createMemo, onCleanup } from "solid-js"
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command" import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
@@ -14,7 +13,6 @@ import { useTabs } from "@/context/tabs"
import { displayName, projectForSession } from "@/pages/layout/helpers" import { displayName, projectForSession } from "@/pages/layout/helpers"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { normalizeSessionInfo } from "@/utils/session"
export type CommandPaletteEntry = { export type CommandPaletteEntry = {
id: string id: string
@@ -146,7 +144,8 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
server: ServerConnection.key(serverSDK.server), server: ServerConnection.key(serverSDK.server),
opened: serverCtx.projects.list, opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project, stored: () => serverCtx.sync.data.project,
load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }), load: (search, signal) =>
serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"), untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"), category: () => language.t("command.category.session"),
}) })
@@ -220,7 +219,7 @@ export function createServerSessionEntries(props: {
server: ServerConnection.Key server: ServerConnection.Key
opened: () => LocalProject[] opened: () => LocalProject[]
stored: () => Project[] stored: () => Project[]
load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }> load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }>
untitled: () => string untitled: () => string
category: () => string category: () => string
}) { }) {
@@ -256,8 +255,7 @@ export function createServerSessionEntries(props: {
return props return props
.load(search, current.signal) .load(search, current.signal)
.then((result) => .then((result) =>
result.data (result.data ?? [])
.map(normalizeSessionInfo)
.filter((session) => !session.time.archived) .filter((session) => !session.time.archived)
.map((session) => { .map((session) => {
const project = const project =
@@ -266,7 +264,7 @@ export function createServerSessionEntries(props: {
id: `session:${props.server}:${session.id}`, id: `session:${props.server}:${session.id}`,
type: "session" as const, type: "session" as const,
title: session.title || props.untitled(), title: session.title || props.untitled(),
description: project ? displayName(project) : getFilename(session.directory), description: project ? displayName(project) : session.project?.name || getFilename(session.directory),
category: props.category(), category: props.category(),
directory: session.directory, directory: session.directory,
sessionID: session.id, sessionID: session.id,
+18 -36
View File
@@ -66,7 +66,7 @@ function Cell(props: {
<div <div
classList={{ classList={{
"flex min-w-0 items-center": true, "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, "justify-center text-center": !props.inline,
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !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, "col-span-2": !!props.wide && !props.inline,
@@ -74,29 +74,21 @@ function Cell(props: {
> >
<div <div
classList={{ classList={{
"flex min-w-0": true, "text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
"-translate-y-px items-baseline gap-1.5": !!props.inline,
"flex-col items-center": !props.inline,
}} }}
> >
<div {props.label}
classList={{ </div>
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true, <div
}} classList={{
> "uppercase leading-none font-bold tabular-nums": true,
{props.label} "text-[11px]": !!props.inline,
</div> "text-[13px] sm:text-[14px]": !props.inline,
<div "text-text-on-critical-base": !!props.bad,
classList={{ "opacity-70": !!props.dim,
"uppercase leading-none font-bold tabular-nums": true, }}
"text-[11px]": !!props.inline, >
"text-[13px] sm:text-[14px]": !props.inline, {props.value}
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
>
{props.value}
</div>
</div> </div>
</div> </div>
) )
@@ -124,25 +116,15 @@ function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => vo
aria-pressed={props.active} aria-pressed={props.active}
classList={{ 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, "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, "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, "bg-surface-raised-base text-text-strong": props.active,
}} }}
onClick={props.onClick} onClick={props.onClick}
> >
<span <span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span>
classList={{ <span classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}>
flex: true, {props.active ? "ON" : "OFF"}
"-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> </span>
</button> </button>
) )
@@ -79,7 +79,8 @@ export function DialogHomeCommandPaletteV2(props: {
server: ServerConnection.key(props.server), server: ServerConnection.key(props.server),
opened: serverCtx.projects.list, opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project, stored: () => serverCtx.sync.data.project,
load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }), load: (search, signal) =>
serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"), untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"), category: () => language.t("command.category.session"),
}) })
@@ -1,4 +1,4 @@
import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise" import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
@@ -28,18 +28,15 @@ import {
Switch, Switch,
} from "solid-js" } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { useParams } from "@solidjs/router" import { Link } from "@/components/link"
import { ExternalLink } from "@/components/external-link"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { CustomProviderForm } from "./dialog-custom-provider" import { CustomProviderForm } from "./dialog-custom-provider"
import { decode64 } from "@/utils/base64"
const CUSTOM_ID = "_custom" const CUSTOM_ID = "_custom"
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
export function useProviderConnectController(options: { onBack?: () => void } = {}) { export function useProviderConnectController(options: { onBack?: () => void } = {}) {
const [store, setStore] = createStore({ selected: undefined as string | undefined }) const [store, setStore] = createStore({ selected: undefined as string | undefined })
@@ -157,7 +154,7 @@ function ProviderPicker(props: {
const settings = useSettings() const settings = useSettings()
if (settings.general.newLayoutDesigns()) if (settings.general.newLayoutDesigns())
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} /> return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
const providers = useProviders(() => props.directory?.()) const providers = useProviders(props.directory)
const language = useLanguage() const language = useLanguage()
const popularGroup = () => language.t("dialog.provider.group.popular") const popularGroup = () => language.t("dialog.provider.group.popular")
const otherGroup = () => language.t("dialog.provider.group.other") const otherGroup = () => language.t("dialog.provider.group.other")
@@ -229,8 +226,10 @@ function ProviderPickerV2(props: {
onSelect: (provider: string) => void onSelect: (provider: string) => void
onPrepare?: () => void onPrepare?: () => void
}) { }) {
const providers = useProviders(() => props.directory?.()) const providers = useProviders(props.directory)
const language = useLanguage() const language = useLanguage()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
filter: "", filter: "",
active: undefined as string | undefined, active: undefined as string | undefined,
@@ -267,7 +266,19 @@ function ProviderPickerV2(props: {
const connect = (provider: string) => { const connect = (provider: string) => {
props.onPrepare?.() props.onPrepare?.()
props.onSelect(provider) if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) {
props.onSelect(provider)
return
}
if (store.connecting) return
setStore("connecting", provider)
void serverSDK()
.client.provider.auth()
.then((response) => {
serverSync().set("provider_auth", response.data ?? {})
props.onSelect(provider)
})
.catch(() => props.onSelect(provider))
} }
const move = (event: KeyboardEvent, direction: number) => { const move = (event: KeyboardEvent, direction: number) => {
@@ -384,16 +395,10 @@ function ProviderConnection(props: {
const dialog = useDialog() const dialog = useDialog()
const serverSync = useServerSync() const serverSync = useServerSync()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const params = useParams()
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
const newLayout = settings.general.newLayoutDesigns const newLayout = settings.general.newLayoutDesigns
const providers = useProviders(() => props.directory?.()) const providers = useProviders(props.directory)
const directory = () => props.directory?.() ?? decode64(params.dir)
const location = () => {
const value = directory()
return value ? { directory: value } : undefined
}
const alive = { value: true } const alive = { value: true }
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined } const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
@@ -408,34 +413,38 @@ function ProviderConnection(props: {
const provider = createMemo( const provider = createMemo(
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!, () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
) )
const fallback = createMemo<ConnectMethod[]>(() => [ const fallback = createMemo<ProviderAuthMethod[]>(() => [
{ {
type: "key" as const, type: "api" as const,
label: language.t("provider.connect.method.apiKey"), label: language.t("provider.connect.method.apiKey"),
}, },
]) ])
const [integration] = createResource( const [auth] = createResource(
() => ({ provider: props.provider, directory: directory() }), () => props.provider,
(input) => async () => {
serverSDK() const cached = serverSync().data.provider_auth[props.provider]
.api.integration.get({ if (cached) return cached
integrationID: input.provider, const res = await serverSDK().client.provider.auth()
location: input.directory ? { directory: input.directory } : undefined, if (!alive.value) return fallback()
}) serverSync().set("provider_auth", res.data ?? {})
.then((result) => result.data), return res.data?.[props.provider] ?? fallback()
},
) )
const loading = createMemo(() => integration.loading) const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
const methods = createMemo<ConnectMethod[]>(() => { const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
const values = integration.latest?.methods.filter( const cachedMethods = serverSync().data.provider_auth[props.provider]
(method): method is ConnectMethod => method.type === "key" || method.type === "oauth", const directMethod =
) cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined
return values?.length ? values : fallback()
})
const [store, setStore] = createStore({ const [store, setStore] = createStore({
methodIndex: undefined as undefined | number, methodIndex: directMethod as undefined | number,
authorization: undefined as undefined | IntegrationOauthConnectOutput["data"], authorization: undefined as undefined | ProviderAuthAuthorization,
promptInputs: undefined as undefined | Record<string, string>, promptInputs: undefined as undefined | Record<string, string>,
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", state: (directMethod === undefined ? "pending" : undefined) as
| undefined
| "pending"
| "complete"
| "error"
| "prompt",
error: undefined as string | undefined, error: undefined as string | undefined,
}) })
@@ -445,7 +454,7 @@ function ProviderConnection(props: {
| { type: "auth.prompt" } | { type: "auth.prompt" }
| { type: "auth.inputs"; inputs: Record<string, string> } | { type: "auth.inputs"; inputs: Record<string, string> }
| { type: "auth.pending" } | { type: "auth.pending" }
| { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] } | { type: "auth.complete"; authorization: ProviderAuthAuthorization }
| { type: "auth.error"; error: string } | { type: "auth.error"; error: string }
function dispatch(action: Action) { function dispatch(action: Action) {
@@ -499,7 +508,7 @@ function ProviderConnection(props: {
const methodLabel = (value?: { type?: string; label?: string }) => { const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return "" if (!value) return ""
if (value.type === "key") return language.t("provider.connect.method.apiKey") if (value.type === "api") return language.t("provider.connect.method.apiKey")
return value.label ?? "" return value.label ?? ""
} }
@@ -509,7 +518,7 @@ function ProviderConnection(props: {
const hint = suffix?.[1] const hint = suffix?.[1]
return { return {
label: suffix ? label.slice(0, -suffix[0].length) : label, label: suffix ? label.slice(0, -suffix[0].length) : label,
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined, hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "api" ? "Browser" : undefined,
} }
} }
@@ -540,22 +549,46 @@ function ProviderConnection(props: {
const method = methods()[index] const method = methods()[index]
dispatch({ type: "method.select", index }) dispatch({ type: "method.select", index })
if (method.type === "api" && method.prompts?.length) {
if (!inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.inputs", inputs })
return
}
if (method.type === "oauth") { if (method.type === "oauth") {
if (method.prompts?.length && !inputs) { if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" }) dispatch({ type: "auth.prompt" })
return return
} }
dispatch({ type: "auth.pending" }) dispatch({ type: "auth.pending" })
const start = Date.now()
await serverSDK() await serverSDK()
.api.integration.oauth.connect({ .client.provider.oauth.authorize(
integrationID: props.provider, {
methodID: method.id, providerID: props.provider,
inputs: inputs ?? {}, method: index,
location: location(), inputs,
}) },
{ throwOnError: true },
)
.then((x) => { .then((x) => {
if (!alive.value) return if (!alive.value) return
dispatch({ type: "auth.complete", authorization: x.data }) const elapsed = Date.now() - start
const delay = 1000 - elapsed
if (delay > 0) {
if (timer.current !== undefined) clearTimeout(timer.current)
timer.current = setTimeout(() => {
timer.current = undefined
if (!alive.value) return
dispatch({ type: "auth.complete", authorization: x.data! })
}, delay)
return
}
dispatch({ type: "auth.complete", authorization: x.data! })
}) })
.catch((e) => { .catch((e) => {
if (!alive.value) return if (!alive.value) return
@@ -570,9 +603,9 @@ function ProviderConnection(props: {
index: 0, index: 0,
}) })
const prompts = createMemo(() => { const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
const value = method() const value = method()
return value?.type === "oauth" ? (value.prompts ?? []) : [] return value?.prompts ?? []
}) })
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => { const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true if (!prompt.when) return true
@@ -603,6 +636,10 @@ function ProviderConnection(props: {
setFormStore("index", next) setFormStore("index", next)
return return
} }
if (method()?.type === "api") {
dispatch({ type: "auth.inputs", inputs: value })
return
}
await selectMethod(store.methodIndex, value) await selectMethod(store.methodIndex, value)
} }
@@ -704,9 +741,7 @@ function ProviderConnection(props: {
}) })
async function complete() { async function complete() {
await serverSync() await serverSDK().client.global.dispose()
.refreshProviders()
.catch(() => undefined)
dialog.close() dialog.close()
showToast({ showToast({
variant: "success", variant: "success",
@@ -770,7 +805,7 @@ function ProviderConnection(props: {
listRef = ref listRef = ref
}} }}
items={methods} items={methods}
key={(m) => m?.label ?? m?.type} key={(m) => m?.label}
onSelect={async (selected, index) => { onSelect={async (selected, index) => {
if (!selected) return if (!selected) return
void selectMethod(index) void selectMethod(index)
@@ -816,10 +851,13 @@ function ProviderConnection(props: {
} }
setFormStore("error", undefined) setFormStore("error", undefined)
await serverSDK().api.integration.connect.key({ await serverSDK().client.auth.set({
integrationID: props.provider, providerID: props.provider,
location: location(), auth: {
key: apiKey, type: "api",
key: apiKey,
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
},
}) })
await complete() await complete()
} }
@@ -836,12 +874,12 @@ function ProviderConnection(props: {
<div>{language.t("provider.connect.opencodeZen.line2")}</div> <div>{language.t("provider.connect.opencodeZen.line2")}</div>
<div> <div>
{language.t("provider.connect.opencodeZen.visit.prefix")} {language.t("provider.connect.opencodeZen.visit.prefix")}
<ExternalLink <Link
href="https://opencode.ai/zen" 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" 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")} {language.t("provider.connect.opencodeZen.visit.link")}
</ExternalLink> </Link>
{language.t("provider.connect.opencodeZen.visit.suffix")} {language.t("provider.connect.opencodeZen.visit.suffix")}
</div> </div>
</div> </div>
@@ -853,7 +891,6 @@ function ProviderConnection(props: {
ref={apiKey} ref={apiKey}
class="!w-full" class="!w-full"
name="apiKey" name="apiKey"
data-input="provider-api-key"
placeholder={language.t("provider.connect.apiKey.placeholder")} placeholder={language.t("provider.connect.apiKey.placeholder")}
value={formStore.value} value={formStore.value}
invalid={formStore.error !== undefined} invalid={formStore.error !== undefined}
@@ -870,7 +907,7 @@ function ProviderConnection(props: {
</div> </div>
)} )}
</Show> </Show>
<ButtonV2 type="submit" variant="contrast" data-action="provider-connect-submit"> <ButtonV2 type="submit" variant="contrast">
{language.t("common.continue")} {language.t("common.continue")}
</ButtonV2> </ButtonV2>
</form> </form>
@@ -886,9 +923,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.line2")}</div>
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
{language.t("provider.connect.opencodeZen.visit.prefix")} {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")} {language.t("provider.connect.opencodeZen.visit.link")}
</ExternalLink> </Link>
{language.t("provider.connect.opencodeZen.visit.suffix")} {language.t("provider.connect.opencodeZen.visit.suffix")}
</div> </div>
</div> </div>
@@ -947,13 +984,12 @@ function ProviderConnection(props: {
setFormStore("error", undefined) setFormStore("error", undefined)
const result = await serverSDK() const result = await serverSDK()
.api.integration.oauth.complete({ .client.provider.oauth.callback({
integrationID: props.provider, providerID: props.provider,
attemptID: store.authorization!.attemptID, method: store.methodIndex,
location: location(),
code, code,
}) })
.then(() => ({ ok: true as const })) .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
.catch((error) => ({ ok: false as const, error })) .catch((error) => ({ ok: false as const, error }))
if (result.ok) { if (result.ok) {
await complete() await complete()
@@ -967,9 +1003,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 class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div> <div>
{language.t("provider.connect.oauth.code.visit.prefix")} {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")} {language.t("provider.connect.oauth.code.visit.link")}
</ExternalLink> </Link>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })} {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div> </div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch"> <form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
@@ -1006,9 +1042,7 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.code.visit.prefix")} {language.t("provider.connect.oauth.code.visit.prefix")}
<ExternalLink href={store.authorization!.url}> <Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
{language.t("provider.connect.oauth.code.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })} {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div> </div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
@@ -1042,46 +1076,32 @@ function ProviderConnection(props: {
}) })
onMount(() => { onMount(() => {
const poll = async () => { void (async () => {
const authorization = store.authorization
if (!authorization || !alive.value) return
const result = await serverSDK() const result = await serverSDK()
.api.integration.oauth.status({ .client.provider.oauth.callback({
integrationID: props.provider, providerID: props.provider,
attemptID: authorization.attemptID, method: store.methodIndex,
location: location(),
}) })
.then((value) => ({ ok: true as const, status: value.data })) .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
.catch((error) => ({ ok: false as const, error })) .catch((error) => ({ ok: false as const, error }))
if (!alive.value) return if (!alive.value) return
if (!result.ok) { if (!result.ok) {
dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) }) const message = formatError(result.error, language.t("common.requestFailed"))
dispatch({ type: "auth.error", error: message })
return return
} }
if (result.status.status === "complete") {
await complete() await complete()
return })()
}
if (result.status.status === "failed") {
dispatch({ type: "auth.error", error: result.status.message })
return
}
if (result.status.status === "expired") {
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
return
}
timer.current = setTimeout(poll, 1_000)
}
void poll()
}) })
return ( return (
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")} {language.t("provider.connect.oauth.auto.visit.prefix")}
<ExternalLink href={store.authorization!.url}> <Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
{language.t("provider.connect.oauth.auto.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })} {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
</div> </div>
<TextField <TextField
@@ -1158,15 +1178,15 @@ function ProviderConnection(props: {
</div> </div>
</div> </div>
</Match> </Match>
<Match when={method()?.type === "key"}> <Match when={method()?.type === "api"}>
<ApiAuthView /> <ApiAuthView />
</Match> </Match>
<Match when={method()?.type === "oauth"}> <Match when={method()?.type === "oauth"}>
<Switch> <Switch>
<Match when={store.authorization?.mode === "code"}> <Match when={store.authorization?.method === "code"}>
<OAuthCodeView /> <OAuthCodeView />
</Match> </Match>
<Match when={store.authorization?.mode === "auto"}> <Match when={store.authorization?.method === "auto"}>
<OAuthAutoView /> <OAuthAutoView />
</Match> </Match>
</Switch> </Switch>
@@ -8,7 +8,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { batch, For } from "solid-js" import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store" 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 { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -131,7 +131,6 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const saveMutation = useMutation(() => ({ const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => { mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
const disabledProviders = serverSync().data.config.disabled_providers ?? [] const disabledProviders = serverSync().data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
@@ -185,9 +184,9 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6"> <form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
<p class="text-14-regular text-text-base"> <p class="text-14-regular text-text-base">
{language.t("provider.custom.description.prefix")} {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")} {language.t("provider.custom.description.link")}
</ExternalLink> </Link>
{language.t("provider.custom.description.suffix")} {language.t("provider.custom.description.suffix")}
</p> </p>
+7 -3
View File
@@ -69,11 +69,15 @@ export const DialogFork: Component = () => {
const dir = base64Encode(sdk().directory) const dir = base64Encode(sdk().directory)
sdk() sdk()
.api.session.fork({ sessionID, messageID: item.id }) .client.session.fork({ sessionID, messageID: item.id })
.then((forked) => { .then((forked) => {
if (!forked.data) {
showToast({ title: language.t("common.requestFailed") })
return
}
dialog.close() dialog.close()
prompt.set(restored, undefined, { dir, id: forked.id }) prompt.set(restored, undefined, { dir, id: forked.data.id })
navigate(`/${dir}/session/${forked.id}`) navigate(`/${dir}/session/${forked.data.id}`)
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
@@ -8,7 +8,6 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
import type { Path } from "@opencode-ai/sdk/v2/client"
import { import {
absoluteTreePath, absoluteTreePath,
activeTreeNavigation, activeTreeNavigation,
@@ -29,7 +28,6 @@ import {
} from "./directory-picker-domain" } from "./directory-picker-domain"
import "./dialog-select-directory-v2.css" import "./dialog-select-directory-v2.css"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { getFilename } from "@opencode-ai/core/util/path"
interface DialogSelectDirectoryV2Props { interface DialogSelectDirectoryV2Props {
title?: string title?: string
@@ -69,13 +67,11 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource( const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined), () => (missingBase() ? true : undefined),
async (): Promise<Path | undefined> => { () =>
if ((await sdk.protocol) !== "v1") return sdk.client.path
return sdk.client.path
.get() .get()
.then((result) => result.data) .then((result) => result.data)
.catch(() => undefined) .catch(() => undefined),
},
{ initialValue: undefined }, { initialValue: undefined },
) )
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
@@ -89,26 +85,18 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
) )
const search = createDirectorySearch({ sdk, home, base: () => root() || start() }) const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
const [suggestions] = createResource(input, async (value) => { const [suggestions] = createResource(input, async (value) => {
const cleaned = cleanPickerInput(value) const typed = cleanPickerInput(value).replace(/\/+$/, "")
const typed = cleaned.replace(/\/+$/, "")
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "") const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
if (!cleaned || (root() && typed === current)) return { query: value, items: [] } if (!typed || typed === current) return { query: value, items: [] }
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const })) const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) } if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
const base = pickerRoot(cleaned) || root() || start() const files = await sdk.client.find
if (!base) return { query: value, items: directories.slice(0, 5) } .files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
const files = await sdk.api.file .then((result) => result.data ?? [])
.find({
location: { directory: base },
query: pickerFileSearchQuery(base, value, home()),
type: "file",
limit: 20,
})
.then((result) => result.data)
.catch(() => []) .catch(() => [])
const results = [ const results = [
...directories, ...directories,
...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })), ...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })),
] ]
return { return {
query: value, query: value,
@@ -127,14 +115,9 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
existing ?? existing ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => { loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined) if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
return sdk.api.file return sdk.client.file
.list({ location: { directory: absolute } }) .list({ directory: absolute, path: "" })
.then((result) => .then((result) => result.data ?? [])
result.data.map((entry) => ({
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
type: entry.type,
})),
)
.catch(() => undefined) .catch(() => undefined)
}) })
listings.set(key, request) listings.set(key, request)
@@ -329,7 +312,6 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
{(suggestion, index) => ( {(suggestion, index) => (
<button <button
id={`directory-picker-v2-suggestion-${index()}`} id={`directory-picker-v2-suggestion-${index()}`}
data-directory-path={suggestion.absolute}
role="option" role="option"
aria-selected={index() === activeSuggestion()} aria-selected={index() === activeSuggestion()}
data-active={index() === activeSuggestion() ? "" : undefined} data-active={index() === activeSuggestion() ? "" : undefined}
@@ -9,7 +9,6 @@ import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain" import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
import type { Path } from "@opencode-ai/sdk/v2/client"
interface DialogSelectDirectoryProps { interface DialogSelectDirectoryProps {
title?: string title?: string
@@ -18,8 +17,6 @@ interface DialogSelectDirectoryProps {
server: ServerConnection.Any server: ServerConnection.Any
} }
const RECENT_PROJECT_LIMIT = 5
type Row = { type Row = {
absolute: string absolute: string
search: string search: string
@@ -59,14 +56,13 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const [filter, setFilter] = createSignal("") const [filter, setFilter] = createSignal("")
let list: ListRef | undefined 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( const [fallbackPath] = createResource(
() => (missingHome() ? true : undefined), () => (missingBase() ? true : undefined),
async (): Promise<Path | undefined> => { async () => {
if ((await sdk.protocol) !== "v1") return
return sdk.client.path return sdk.client.path
.get() .get()
.then((result) => result.data) .then((x) => x.data)
.catch(() => undefined) .catch(() => undefined)
}, },
{ initialValue: undefined }, { initialValue: undefined },
@@ -104,6 +100,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
return projects return projects
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index })) .map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
.sort((a, b) => b.at - a.at || a.index - b.index) .sort((a, b) => b.at - a.at || a.index - b.index)
.slice(0, 5)
.map(({ project }) => { .map(({ project }) => {
const row = toRow(project.worktree, home(), "recent") const row = toRow(project.worktree, home(), "recent")
const name = project.name || getFilename(project.worktree) const name = project.name || getFilename(project.worktree)
@@ -117,10 +114,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const items = async (value: string) => { const items = async (value: string) => {
const results = await directories(value) const results = await directories(value)
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders")) const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
// Cap the idle list only. Once a query narrows the results, every project stays searchable. return uniqueRows([...recentProjects(), ...directoryRows])
const recent = recentProjects()
const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT)
return uniqueRows([...visible, ...directoryRows])
} }
function resolve(absolute: string) { function resolve(absolute: string) {
@@ -168,7 +162,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const path = displayPickerPath(item.absolute, filter(), home()) const path = displayPickerPath(item.absolute, filter(), home())
if (path === "~") { if (path === "~") {
return ( return (
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md"> <div class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0"> <div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" /> <FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0"> <div class="flex items-center text-14-regular min-w-0">
@@ -180,7 +174,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
) )
} }
return ( return (
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md"> <div class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0"> <div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" /> <FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0"> <div class="flex items-center text-14-regular min-w-0">
@@ -43,7 +43,7 @@ export const DialogSelectMcp: Component = () => {
filterKeys={["name", "status"]} filterKeys={["name", "status"]}
sortBy={(a, b) => a.name.localeCompare(b.name)} sortBy={(a, b) => a.name.localeCompare(b.name)}
onSelect={(x) => { onSelect={(x) => {
if (!x || x.status === "pending" || toggle.isPending) return if (!x || toggle.isPending) return
toggle.mutate(x.name) toggle.mutate(x.name)
}} }}
> >
@@ -76,7 +76,7 @@ export const DialogSelectMcp: Component = () => {
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<Switch <Switch
checked={enabled()} checked={enabled()}
disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)} disabled={toggle.isPending && toggle.variables === i.name}
onChange={() => { onChange={() => {
if (toggle.isPending) return if (toggle.isPending) return
toggle.mutate(i.name) toggle.mutate(i.name)
@@ -76,7 +76,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
</DialogHeader> </DialogHeader>
<DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2"> <DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
<div ref={listEl} class="flex min-h-0 flex-col"> <div ref={listEl} class="flex min-h-0 flex-col">
<div data-section="free-models" class="flex w-full flex-col items-start pb-3"> <div class="flex w-full flex-col items-start pb-3">
<div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2"> <div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]"> <div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.freeModels.title")} {language.t("dialog.model.unpaid.freeModels.title")}
@@ -134,7 +134,6 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
{(provider) => ( {(provider) => (
<button <button
type="button" type="button"
data-provider-id={provider.id}
class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none" class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
classList={{ classList={{
"border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]": "border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
@@ -1,5 +1,15 @@
import { Popover as Kobalte } from "@kobalte/core/popover" import { Popover as Kobalte } from "@kobalte/core/popover"
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js" import {
Component,
ComponentProps,
createEffect,
createMemo,
For,
JSX,
onCleanup,
Show,
ValidComponent,
} from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -19,7 +29,6 @@ import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { handleDocumentSearchKeydown } from "@/utils/search-keydown" import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
import { createEventListener } from "@solid-primitives/event-listener" import { createEventListener } from "@solid-primitives/event-listener"
import { matchesModelSearch } from "./dialog-select-model-search" import { matchesModelSearch } from "./dialog-select-model-search"
@@ -113,13 +122,14 @@ const ModelList: Component<{
} }
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref"> type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider" type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
export function ModelSelectorPopover(props: { export function ModelSelectorPopover(props: {
provider?: string provider?: string
model?: ModelState model?: ModelState
trigger: ModelSelectorTrigger children?: JSX.Element
triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps
onClose?: (cause: "escape" | "select") => void onClose?: (cause: "escape" | "select") => void
}) { }) {
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
@@ -164,7 +174,9 @@ export function ModelSelectorPopover(props: {
placement="top-start" placement="top-start"
gutter={4} gutter={4}
> >
<Kobalte.Trigger as={props.trigger} /> <Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
{props.children}
</Kobalte.Trigger>
<Kobalte.Portal> <Kobalte.Portal>
<Kobalte.Content <Kobalte.Content
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden" class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
@@ -225,101 +237,66 @@ export function ModelSelectorPopover(props: {
export function ModelSelectorPopoverV2(props: { export function ModelSelectorPopoverV2(props: {
provider?: string provider?: string
model?: ModelState model?: ModelState
trigger: ModelSelectorTrigger children?: JSX.Element
triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps
onClose?: () => void onClose?: () => void
}) { }) {
const model = props.model ?? useLocal().model
const language = useLanguage()
const dialog = useDialog() const dialog = useDialog()
const controller = createModelSelectorController({ const [store, setStore] = createStore({ open: false, search: "", active: "" })
model: props.model, let searchRef: HTMLInputElement | undefined
provider: () => props.provider, let contentRef: HTMLDivElement | undefined
onSelect: () => props.onClose?.(), let restoreTrigger = true
})
return (
<ModelSelectorPopoverV2View
trigger={props.trigger}
models={controller.models}
groups={controller.groups}
current={controller.current}
select={controller.select}
onManage={() => {
void import("./dialog-manage-models").then((module) => {
void dialog.show(() => <module.DialogManageModelsV2 />)
})
}}
onClose={() => props.onClose?.()}
/>
)
}
function createModelSelectorController(input: {
provider: () => string | undefined
model?: ModelState
onSelect: () => void
}) {
const model = input.model ?? useLocal().model
const allModels = createMemo(() => const allModels = createMemo(() =>
model model
.list() .list()
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id })) .filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
.filter((item) => (input.provider() ? item.provider.id === input.provider() : true)), .filter((item) => (props.provider ? item.provider.id === props.provider : true)),
) )
const models = createMemo(() => {
const search = store.search.trim()
const filtered = search
? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
: allModels()
return { return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
models: (search: string) => { })
const query = search.trim() const groups = createMemo(() => {
const filtered = query const byProvider = new Map<string, ModelItem[]>()
? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name])) for (const item of models()) {
: allModels() byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
return [...filtered].sort((a, b) => a.name.localeCompare(b.name)) }
}, return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
groups: (models: ModelItem[]) => { })
const byProvider = new Map<string, ModelItem[]>()
for (const item of models) {
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
}
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
},
current: () => {
const value = model.current()
return value ? modelKey(value) : undefined
},
select: (item: ModelItem) => {
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
input.onSelect()
},
}
}
function ModelSelectorPopoverV2View(props: {
trigger: ModelSelectorTrigger
models: (search: string) => ModelItem[]
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
current: () => string | undefined
select: (item: ModelItem) => void
onManage: () => void
onClose: () => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({ open: false, search: "", active: "" })
let searchRef: HTMLInputElement | undefined
let contentRef: HTMLDivElement | undefined
const dismiss = createMenuDismissController(() => contentRef)
const models = createMemo(() => props.models(store.search))
const groups = createMemo(() => props.groups(models()))
const keys = () => [...models().map(modelKey), manageKey] const keys = () => [...models().map(modelKey), manageKey]
const current = () => {
const value = model.current()
return value ? `${value.provider.id}:${value.id}` : undefined
}
const initialActive = () => { const initialActive = () => {
const selected = props.current() const selected = current()
const options = keys() const options = keys()
if (selected && options.includes(selected)) return selected if (selected && options.includes(selected)) return selected
return options[0] ?? "" return options[0] ?? ""
} }
const activeItem = () => const activeItem = () =>
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
const afterClose = (callback: () => void) => {
const complete = () => {
if (contentRef?.isConnected) {
requestAnimationFrame(complete)
return
}
requestAnimationFrame(() => requestAnimationFrame(callback))
}
requestAnimationFrame(complete)
}
const setOpen = (open: boolean) => { const setOpen = (open: boolean) => {
if (open) { if (open) {
dismiss.allowTriggerRestore() restoreTrigger = true
setStore({ open: true, active: initialActive() }) setStore({ open: true, active: initialActive() })
setTimeout(() => setTimeout(() =>
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -331,15 +308,23 @@ function ModelSelectorPopoverV2View(props: {
} }
setStore({ open: false, search: "", active: "" }) setStore({ open: false, search: "", active: "" })
} }
const select = (item: ModelItem) => {
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
props.onClose?.()
}
const selectModel = (item: ModelItem) => { const selectModel = (item: ModelItem) => {
dismiss.preventTriggerRestore() restoreTrigger = false
setOpen(false) setOpen(false)
dismiss.afterClose(() => props.select(item)) afterClose(() => select(item))
} }
const manage = () => { const manage = () => {
dismiss.preventTriggerRestore() restoreTrigger = false
setOpen(false) setOpen(false)
dismiss.afterClose(props.onManage) afterClose(() => {
void import("./dialog-manage-models").then((x) => {
dialog.show(() => <x.DialogManageModelsV2 />)
})
})
} }
const selectActive = () => { const selectActive = () => {
const item = models().find((item) => modelKey(item) === store.active) const item = models().find((item) => modelKey(item) === store.active)
@@ -358,7 +343,10 @@ function ModelSelectorPopoverV2View(props: {
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" })) queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
} }
const setSearch = (value: string) => { const setSearch = (value: string) => {
const first = props.models(value)[0] const search = value.trim()
const first = [...allModels()]
.sort((a, b) => a.name.localeCompare(b.name))
.find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
setStore({ search: value, active: first ? modelKey(first) : manageKey }) setStore({ search: value, active: first ? modelKey(first) : manageKey })
} }
@@ -374,14 +362,18 @@ function ModelSelectorPopoverV2View(props: {
return ( return (
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}> <MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
<MenuV2.Trigger as={props.trigger} /> <MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
{props.children}
</MenuV2.Trigger>
<MenuV2.Portal> <MenuV2.Portal>
<MenuV2.Content <MenuV2.Content
ref={(element: HTMLDivElement) => (contentRef = element)} ref={(el: HTMLDivElement) => (contentRef = el)}
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none" class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onPointerDownOutside={dismiss.preventTriggerRestore} onPointerDownOutside={() => (restoreTrigger = false)}
onFocusOutside={dismiss.preventTriggerRestore} onFocusOutside={() => (restoreTrigger = false)}
onCloseAutoFocus={dismiss.onCloseAutoFocus} onCloseAutoFocus={(event) => {
if (!restoreTrigger) event.preventDefault()
}}
> >
<div class="flex flex-col p-0.5"> <div class="flex flex-col p-0.5">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted"> <div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
@@ -401,9 +393,9 @@ function ModelSelectorPopoverV2View(props: {
event.stopPropagation() event.stopPropagation()
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault() event.preventDefault()
dismiss.preventTriggerRestore() restoreTrigger = false
setOpen(false) setOpen(false)
dismiss.afterClose(props.onClose) afterClose(() => props.onClose?.())
return return
} }
if (event.altKey || event.metaKey) return if (event.altKey || event.metaKey) return
@@ -453,7 +445,7 @@ function ModelSelectorPopoverV2View(props: {
<MenuV2.GroupLabel class="gap-2 px-3"> <MenuV2.GroupLabel class="gap-2 px-3">
<span class="min-w-0 truncate">{group.items[0].provider.name}</span> <span class="min-w-0 truncate">{group.items[0].provider.name}</span>
</MenuV2.GroupLabel> </MenuV2.GroupLabel>
<MenuV2.RadioGroup value={props.current()}> <MenuV2.RadioGroup value={current()}>
<For each={group.items}> <For each={group.items}>
{(item) => ( {(item) => (
<TooltipV2 <TooltipV2
@@ -473,7 +465,7 @@ function ModelSelectorPopoverV2View(props: {
<MenuV2.RadioItem <MenuV2.RadioItem
value={modelKey(item)} value={modelKey(item)}
data-option-key={modelKey(item)} data-option-key={modelKey(item)}
data-selected-model={props.current() === modelKey(item) ? true : undefined} data-selected-model={current() === modelKey(item) ? true : undefined}
class="scroll-my-6 w-full" class="scroll-my-6 w-full"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }} classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => { onMouseEnter={() => {
@@ -16,7 +16,6 @@ import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { detectServerProtocol } from "@/utils/server-protocol"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
@@ -264,13 +263,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
setStore("addServer", { error: language.t("dialog.server.add.error") }) setStore("addServer", { error: language.t("dialog.server.add.error") })
return return
} }
if (
!settings.general.newLayoutDesigns() &&
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd() resetAdd()
if (options.navigateOnAdd === false) { if (options.navigateOnAdd === false) {
@@ -315,13 +307,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
setStore("editServer", { error: language.t("dialog.server.add.error") }) setStore("editServer", { error: language.t("dialog.server.add.error") })
return return
} }
if (
!settings.general.newLayoutDesigns() &&
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === input.original.http.url) { if (normalized === input.original.http.url) {
server.add(conn) server.add(conn)
} else { } else {
@@ -359,10 +344,7 @@ export function useServerManagementController(options: { onSelect?: () => void;
) )
const sortedItems = createMemo(() => { const sortedItems = createMemo(() => {
const raw = items() const list = items()
const list = settings.general.newLayoutDesigns()
? raw
: raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
if (!list.length) return list if (!list.length) return list
const active = current() const active = current()
const order = new Map(list.map((url, index) => [url, index] as const)) const order = new Map(list.map((url, index) => [url, index] as const))
@@ -17,7 +17,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) {
const platform = usePlatform() const platform = usePlatform()
const runAction = () => { const runAction = () => {
if (props.link) platform.openExternal(props.link) if (props.link) platform.openLink(props.link)
props.onClose?.() props.onClose?.()
dialog.close() dialog.close()
} }
@@ -133,10 +133,10 @@ test("scopes file autocomplete to the current browser root", () => {
test("resolves directory autocomplete from the current browser root", async () => { test("resolves directory autocomplete from the current browser root", async () => {
const directories: string[] = [] const directories: string[] = []
const sdk = { const sdk = {
api: { client: {
file: { find: {
find: (input: { location?: { directory?: string } }) => { files: (input: { directory: string }) => {
directories.push(input.location?.directory ?? "") directories.push(input.directory)
return Promise.resolve({ data: [] }) return Promise.resolve({ data: [] })
}, },
}, },
@@ -152,29 +152,6 @@ test("resolves directory autocomplete from the current browser root", async () =
expect(directories).toEqual(["/repo", "/repo/src"]) expect(directories).toEqual(["/repo", "/repo/src"])
}) })
test("searches from an absolute root without a default base", async () => {
const directories: string[] = []
const sdk = {
api: {
file: {
list: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
return Promise.resolve({
data: [
{ path: "Users/", type: "directory" },
{ path: "tmp/", type: "directory" },
],
})
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined })
expect(await search("/")).toEqual(["/Users", "/tmp"])
expect(directories).toEqual(["/"])
})
test("identifies the next directory level to preload", () => { test("identifies the next directory level to preload", () => {
expect( expect(
preloadTreeDirectories("src/", [ preloadTreeDirectories("src/", [
@@ -326,15 +326,15 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
let current = 0 let current = 0
const scoped = (value: string) => { const scoped = (value: string) => {
const raw = normalizePickerDrive(value)
const root = pickerRoot(raw)
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
const base = args.base() const base = args.base()
if (!base) return if (!base) return
const raw = normalizePickerDrive(value)
if (!raw) return { directory: trimPickerPath(base), path: "" } if (!raw) return { directory: trimPickerPath(base), path: "" }
const home = args.home() const home = args.home()
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" } if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) } if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
const root = pickerRoot(raw)
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
return { directory: trimPickerPath(base), path: raw } return { directory: trimPickerPath(base), path: raw }
} }
@@ -342,17 +342,14 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const key = trimPickerPath(directory) const key = trimPickerPath(directory)
const existing = cache.get(key) const existing = cache.get(key)
if (existing) return existing if (existing) return existing
const request = args.sdk.api.file const request = args.sdk.client.file
.list({ location: { directory: key } }) .list({ directory: key, path: "" })
.then((result) => result.data) .then((result) => result.data ?? [])
.catch(() => []) .catch(() => [])
.then((nodes) => .then((nodes) =>
nodes nodes
.filter((node) => node.type === "directory") .filter((node) => node.type === "directory")
.map((node) => { .map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })),
const relative = trimPickerPath(normalizePickerDrive(node.path))
return { name: getFilename(relative), absolute: joinPickerPath(key, relative) }
}),
) )
cache.set(key, request) cache.set(key, request)
return request return request
@@ -374,9 +371,9 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/") const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
const query = normalizePickerDrive(input.path) const query = normalizePickerDrive(input.path)
if (!pathInput) { if (!pathInput) {
const results = await args.sdk.api.file const results = await args.sdk.client.find
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 }) .files({ directory: input.directory, query, type: "directory", limit: 50 })
.then((result) => result.data.map((entry) => entry.path)) .then((result) => result.data ?? [])
.catch(() => []) .catch(() => [])
if (!active()) return [] if (!active()) return []
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50) return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
+7 -21
View File
@@ -1,7 +1,6 @@
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query" import { useMutation } from "@tanstack/solid-query"
import { normalizeProjectInfo } from "@/context/global-sync/utils"
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
@@ -71,26 +70,13 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
const start = store.startup.trim() const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") { if (props.project.id && props.project.id !== "global") {
if ((await serverCtx().sdk.protocol) !== "v1") return await serverCtx().sdk.client.project.update({
const project = await serverCtx() projectID: props.project.id,
.sdk.client.project.update({ directory: props.project.worktree,
projectID: props.project.id, name,
directory: props.project.worktree, icon: { color: store.color || "", override: store.iconOverride || "" },
name, commands: { start },
icon: { color: store.color || "", override: store.iconOverride || "" }, })
commands: { start },
})
.then((result) => result.data)
if (!project) return
// const project = await serverCtx().sdk.api.project.update({
// projectID: props.project.id,
// name,
// icon: { color: store.color || "", override: store.iconOverride || "" },
// commands: { start },
// })
serverCtx().sync.set("project", (items) =>
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
)
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined) serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close() dialog.close()
return return
@@ -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>
)
}
+3 -32
View File
@@ -16,7 +16,6 @@ export function TabsInfoPopup() {
const settings = useSettings() const settings = useSettings()
const platform = usePlatform() const platform = usePlatform()
const [drawerOpen, setDrawerOpen] = createSignal(false) const [drawerOpen, setDrawerOpen] = createSignal(false)
const windows = () => platform.platform === "desktop" && platform.os === "windows"
return ( return (
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right"> <Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
@@ -71,40 +70,12 @@ export function TabsInfoPopup() {
</button> </button>
</div> </div>
</Show> </Show>
<DrawerContent <DrawerContent>
style={ <div class="flex h-[52px] w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted p-4">
windows()
? {
inset: "0 0 0 auto",
"max-height": "100vh",
"max-width": "100vw",
"border-radius": "0",
}
: undefined
}
>
<Show when={windows()}>
<DrawerClose
as={IconButtonV2}
type="button"
size="small"
variant="neutral"
aria-label="Close"
icon={<IconV2 name="xmark-small" />}
class="absolute top-[10px] left-[-36px]"
/>
</Show>
<div
class="flex w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted"
classList={{
"h-[40px] px-4": windows(),
"h-[52px] p-4": !windows(),
}}
>
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted"> <p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
July 14 July 14
</p> </p>
<Show when={!windows()}> <Show when={platform.platform !== "desktop" || platform.os !== "windows"}>
<DrawerClose <DrawerClose
as={IconButtonV2} as={IconButtonV2}
type="button" type="button"
+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>
)
}
+90 -91
View File
@@ -5,7 +5,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/v2/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" import type { Prompt, ReferenceInfo } from "@opencode-ai/sdk/v2/client"
import { createEffect, createMemo, on, Show } from "solid-js" import { createEffect, createMemo, on, Show } from "solid-js"
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model" import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
@@ -36,10 +36,11 @@ import {
export type PromptInputV2ComposerProps = { export type PromptInputV2ComposerProps = {
class?: string class?: string
controller: PromptInputV2ComposerController controller: PromptInputV2ComposerController
borderUnderlay?: boolean edit?: PromptInputProps["edit"]
onEditLoaded?: PromptInputProps["onEditLoaded"]
} }
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission"> export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "edit" | "onEditLoaded" | "submission">
export type PromptInputV2ComposerController = PromptInputV2Interaction & { export type PromptInputV2ComposerController = PromptInputV2Interaction & {
readonly model: PromptInputProps["controls"]["model"] readonly model: PromptInputProps["controls"]["model"]
} }
@@ -49,15 +50,14 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
useCommands(props)
useEditHandler(props)
return ( return (
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<PromptInputV2 <PromptInputV2
controller={props.controller} controller={props.controller}
borderUnderlay={props.borderUnderlay}
class={props.class} class={props.class}
variantControlVisible={!props.controller.model.loading}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
modelControl={ modelControl={
<PromptInputV2ModelControl <PromptInputV2ModelControl
loading={props.controller.model.loading} loading={props.controller.model.loading}
@@ -78,6 +78,70 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
) )
} }
const useEditHandler = (props: PromptInputV2ComposerProps) => {
const prompt = usePrompt()
createEffect(
on(
() => props.edit?.id,
(id) => {
const edit = props.edit
if (!id || !edit) return
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
edit.context.forEach((item) =>
prompt.context.add({
type: item.type,
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
}),
)
props.controller.dispatch({ type: "mode.normal" })
props.controller.resetHistory()
prompt.set(edit.prompt, promptLength(edit.prompt))
props.controller.restoreFocus()
props.onEditLoaded?.()
},
{ defer: true },
),
)
}
const useCommands = (props: PromptInputV2ComposerProps) => {
const command = useCommand()
const language = useLanguage()
command.register("prompt-input", () => [
{
id: "file.attach",
title: language.t("prompt.action.attachFile"),
category: language.t("command.category.file"),
keybind: "mod+u",
disabled: props.controller.state.mode !== "normal",
onSelect: () => props.controller.attach(),
},
{
id: "prompt.mode.shell",
title: language.t("command.prompt.mode.shell"),
category: language.t("command.category.session"),
keybind: "mod+shift+x",
disabled: props.controller.state.mode === "shell",
onSelect: () => props.controller.dispatch({ type: "mode.shell" }),
},
{
id: "prompt.mode.normal",
title: language.t("command.prompt.mode.normal"),
category: language.t("command.category.session"),
keybind: "mod+shift+e",
disabled: props.controller.state.mode === "normal",
onSelect: () => props.controller.dispatch({ type: "mode.normal" }),
},
])
}
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController { export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
@@ -242,7 +306,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
) )
const resources = createMemo(() => const resources = createMemo(() =>
Object.values(sync().data.mcp_resource).map((resource) => ({ Object.values(sync().data.mcp_resource).map((resource) => ({
id: `resource:${resource.server}:${resource.uri}`, id: `resource:${resource.client}:${resource.uri}`,
kind: "resource" as const, kind: "resource" as const,
label: `@${resource.name}`, label: `@${resource.name}`,
path: resource.uri, path: resource.uri,
@@ -259,7 +323,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
source: { source: {
type: "resource" as const, type: "resource" as const,
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 }, text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
clientName: resource.server, clientName: resource.client,
uri: resource.uri, uri: resource.uri,
}, },
}, },
@@ -344,7 +408,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
if (item?.commentID) comments.remove(item.path, item.commentID) if (item?.commentID) comments.remove(item.path, item.commentID)
}, },
openAttachment: (attachment) => openAttachment: (attachment) =>
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />), dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />),
openContext(key) { openContext(key) {
const item = controller.contextItem(key) const item = controller.contextItem(key)
if (item) openComment(item, props, sync, layout, files, comments) if (item) openComment(item, props, sync, layout, files, comments)
@@ -368,7 +432,6 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
title: language.t("prompt.toast.pasteUnsupported.title"), title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"), description: language.t("prompt.toast.pasteUnsupported.description"),
}), }),
duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
onError: (error) => onError: (error) =>
showToast({ showToast({
variant: "error", variant: "error",
@@ -377,25 +440,21 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
}), }),
readClipboardImage: platform.readClipboardImage, readClipboardImage: platform.readClipboardImage,
getPathForFile: platform.getPathForFile, getPathForFile: platform.getPathForFile,
store: platform.draftStore?.putBlob,
}, },
view: { view: {
placeholder: designPlaceholder, placeholder: designPlaceholder,
get agent() { agent:
return props.controls.agents.visible && props.controls.agents.options.length > 0 props.controls.agents.visible && props.controls.agents.options.length > 0
? { ? {
options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })), options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
current: () => props.controls.agents.current, current: () => props.controls.agents.current,
onSelect: (value: string) => props.controls.agents.select(value), onSelect: props.controls.agents.select,
keybind: () => command.keybindParts("agent.cycle"),
} }
: undefined : undefined,
},
variant: { variant: {
options: () => variants().map((value) => ({ id: value, label: value })), options: () => variants().map((value) => ({ id: value, label: value })),
current: () => props.controls.model.selection.variant.current() ?? "default", current: () => props.controls.model.selection.variant.current() ?? "default",
onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value), onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value),
keybind: () => command.keybindParts("model.variant.cycle"),
}, },
submit: { submit: {
stopping, stopping,
@@ -406,62 +465,6 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
}, },
}) })
Object.defineProperty(controller, "model", { get: () => props.controls.model }) Object.defineProperty(controller, "model", { get: () => props.controls.model })
command.register("prompt-input", () => [
{
id: "file.attach",
title: language.t("prompt.action.attachFile"),
category: language.t("command.category.file"),
keybind: "mod+u",
disabled: controller.state.mode !== "normal",
onSelect: () => controller.attach(),
},
{
id: "prompt.mode.shell",
title: language.t("command.prompt.mode.shell"),
category: language.t("command.category.session"),
keybind: "mod+shift+x",
disabled: controller.state.mode === "shell",
onSelect: () => controller.dispatch({ type: "mode.shell" }),
},
{
id: "prompt.mode.normal",
title: language.t("command.prompt.mode.normal"),
category: language.t("command.category.session"),
keybind: "mod+shift+e",
disabled: controller.state.mode === "normal",
onSelect: () => controller.dispatch({ type: "mode.normal" }),
},
])
createEffect(
on(
() => props.edit?.id,
(id) => {
const edit = props.edit
if (!id || !edit) return
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
edit.context.forEach((item) =>
prompt.context.add({
type: item.type,
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
}),
)
controller.dispatch({ type: "mode.normal" })
controller.resetHistory()
prompt.set(edit.prompt, promptLength(edit.prompt))
controller.restoreFocus()
props.onEditLoaded?.()
},
{ defer: true },
),
)
return controller as PromptInputV2ComposerController return controller as PromptInputV2ComposerController
} }
@@ -511,7 +514,6 @@ function PromptInputV2ModelControl(props: {
fallback={ fallback={
<ButtonV2 <ButtonV2
data-action="prompt-model" data-action="prompt-model"
data-control-type="dialog"
variant="ghost-muted" variant="ghost-muted"
size="normal" size="normal"
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group" class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
@@ -525,22 +527,19 @@ function PromptInputV2ModelControl(props: {
> >
<ModelSelectorPopoverV2 <ModelSelectorPopoverV2
model={props.model} model={props.model}
trigger={(triggerProps) => ( triggerAs={ButtonV2}
<ButtonV2 triggerProps={{
{...triggerProps} variant: "ghost-muted",
variant="ghost-muted" size: "normal",
size="normal" style: { height: "28px" },
style={{ height: "28px" }} class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group" classList: { "animate-in fade-in": shouldAnimate() },
classList={{ "animate-in fade-in": shouldAnimate() }} "data-action": "prompt-model",
data-action="prompt-model" }}
data-control-type="popover"
>
{content()}
</ButtonV2>
)}
onClose={props.onClose} onClose={props.onClose}
/> >
{content()}
</ModelSelectorPopoverV2>
</Show> </Show>
</TooltipV2> </TooltipV2>
</Show> </Show>
+26 -28
View File
@@ -591,7 +591,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type: "resource", type: "resource",
name: resource.name, name: resource.name,
uri: resource.uri, uri: resource.uri,
client: resource.server, client: resource.client,
display: resource.name, display: resource.name,
description: resource.description, description: resource.description,
mime: resource.mimeType, mime: resource.mimeType,
@@ -709,7 +709,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
title: cmd.name, title: cmd.name,
description: cmd.description, description: cmd.description,
type: "custom" as const, type: "custom" as const,
// source: cmd.source, source: cmd.source,
})) }))
return [...custom, ...builtin] return [...custom, ...builtin]
@@ -1460,10 +1460,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
t={(key) => language.t(key as Parameters<typeof language.t>[0])} t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/> />
<DockShellForm <DockShellForm
data-dock-border-underlay="legacy"
onSubmit={handleSubmit} onSubmit={handleSubmit}
classList={{ classList={{
"group/prompt-input": true, "group/prompt-input": true,
"focus-within:shadow-xs-border": true,
"border-icon-info-active border-dashed": store.draggingType !== null, "border-icon-info-active border-dashed": store.draggingType !== null,
[props.class ?? ""]: !!props.class, [props.class ?? ""]: !!props.class,
}} }}
@@ -1489,7 +1489,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<PromptImageAttachments <PromptImageAttachments
attachments={imageAttachments()} attachments={imageAttachments()}
onOpen={(attachment) => onOpen={(attachment) =>
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />) dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
} }
onRemove={removeAttachment} onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")} removeLabel={language.t("prompt.attachment.remove")}
@@ -1723,31 +1723,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
> >
<ModelSelectorPopover <ModelSelectorPopover
model={props.controls.model.selection} model={props.controls.model.selection}
trigger={(triggerProps) => ( triggerAs={Button}
<Button triggerProps={{
{...triggerProps} variant: "ghost",
variant="ghost" size: "normal",
size="normal" style: control(),
style={control()} class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
class="min-w-0 max-w-[320px] text-13-regular text-text-base group" "data-action": "prompt-model",
data-action="prompt-model" }}
>
<Show when={props.controls.model.selection.current()?.provider?.id}>
<ProviderIcon
id={props.controls.model.selection.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{props.controls.model.selection.current()?.name ??
language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
)}
onClose={restoreFocus} onClose={restoreFocus}
/> >
<Show when={props.controls.model.selection.current()?.provider?.id}>
<ProviderIcon
id={props.controls.model.selection.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{props.controls.model.selection.current()?.name ??
language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
</TooltipKeybind> </TooltipKeybind>
</Show> </Show>
</div> </div>
@@ -3,13 +3,28 @@ import { makeEventListener } from "@solid-primitives/event-listener"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt" import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { uuid } from "@/utils/uuid" import { uuid } from "@/utils/uuid"
import { getCursorPosition } from "./editor-dom" import { getCursorPosition } from "./editor-dom"
import { createBlobReference, type DraftStore } from "@/utils/draft-store"
import { attachmentMime } from "./files" import { attachmentMime } from "./files"
import { normalizePaste, pasteMode } from "./paste" 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 PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined } type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
@@ -21,7 +36,6 @@ type PromptAttachmentsCoreInput = {
warn?: () => void warn?: () => void
readClipboardImage?: () => Promise<File | null> readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string getPathForFile?: (file: File) => string
draftStore?: DraftStore
} }
export type PromptAttachmentsInput = { export type PromptAttachmentsInput = {
@@ -51,13 +65,16 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
return false return false
} }
const url = await dataUrl(file, mime)
if (!url) return false
const attachment: ImageAttachmentPart = { const attachment: ImageAttachmentPart = {
type: "image", type: "image",
id: uuid(), id: uuid(),
filename: file.name, filename: file.name,
sourcePath: input.getPathForFile?.(file) || undefined, sourcePath: input.getPathForFile?.(file) || undefined,
mime, mime,
blob: input.draftStore ? await input.draftStore.putBlob(file) : await createBlobReference(file), dataUrl: url,
} }
target.prompt.set([...target.prompt.current(), attachment], target.cursor) target.prompt.set([...target.prompt.current(), attachment], target.cursor)
return true return true
@@ -149,10 +166,8 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
export function createPromptAttachments(input: PromptAttachmentsInput) { export function createPromptAttachments(input: PromptAttachmentsInput) {
const language = useLanguage() const language = useLanguage()
const platform = usePlatform()
const attachments = createPromptAttachmentsCore({ const attachments = createPromptAttachmentsCore({
...input, ...input,
draftStore: platform.draftStore,
capture: input.prompt.capture, capture: input.prompt.capture,
warn: () => { warn: () => {
showToast({ showToast({
@@ -22,7 +22,7 @@ type ContextFile = {
type BuildRequestPartsInput = { type BuildRequestPartsInput = {
prompt: Prompt prompt: Prompt
context: ContextFile[] context: ContextFile[]
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[] images: ImageAttachmentPart[]
text: string text: string
messageID: string messageID: string
sessionID: string sessionID: string
@@ -89,15 +89,13 @@ const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID:
} }
export function buildRequestParts(input: BuildRequestPartsInput) { export function buildRequestParts(input: BuildRequestPartsInput) {
const requestParts: PromptRequestPart[] = input.text.trim() const requestParts: PromptRequestPart[] = [
? [ {
{ id: Identifier.ascending("part"),
id: Identifier.ascending("part"), type: "text",
type: "text", text: input.text,
text: input.text, },
}, ]
]
: []
const files = input.prompt.filter(isFileAttachment).map((attachment) => { const files = input.prompt.filter(isFileAttachment).map((attachment) => {
const path = absolute(input.sessionDirectory, attachment.path) const path = absolute(input.sessionDirectory, attachment.path)
@@ -1,13 +1,7 @@
import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import type { Prompt } from "@/context/prompt" import type { Prompt } from "@/context/prompt"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { import { prependHistoryEntry, type PromptHistoryComment, type PromptHistoryStoredEntry } from "./history"
clonePromptHistoryComments,
clonePromptParts,
prependHistoryEntry,
type PromptHistoryComment,
type PromptHistoryStoredEntry,
} from "./history"
export type PromptInputHistory = { export type PromptInputHistory = {
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[] entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
@@ -41,23 +35,13 @@ export function createPromptInputHistory(): PromptInputHistory {
} }
export function createPersistedPromptInputHistory() { export function createPersistedPromptInputHistory() {
const [normal, setNormal, normalInit] = persisted( const [normal, setNormal] = persisted(
Persist.prompt(Persist.global("prompt-history", ["prompt-history.v1"])), Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<PromptHistoryState>({ entries: [] }), createStore<PromptHistoryState>({ entries: [] }),
) )
const [shell, setShell, shellInit] = persisted( const [shell, setShell] = persisted(
Persist.prompt(Persist.global("prompt-history-shell", ["prompt-history-shell.v1"])), Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
createStore<PromptHistoryState>({ entries: [] }), createStore<PromptHistoryState>({ entries: [] }),
) )
const history = createPromptInputHistoryStore(normal, setNormal, shell, setShell) return 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))
},
}
} }
@@ -112,7 +112,7 @@ describe("prompt-input history", () => {
end: 12, end: 12,
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 }, 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) const copy = clonePromptParts(original)
expect(copy).not.toBe(original) expect(copy).not.toBe(original)
@@ -100,7 +100,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
} }
> >
<img <img
src={attachment.blob.url} src={attachment.dataUrl}
alt={attachment.filename} alt={attachment.filename}
class={props.newLayoutDesigns ? imageClassV2 : imageClass} class={props.newLayoutDesigns ? imageClassV2 : imageClass}
onClick={() => props.onOpen(attachment)} onClick={() => props.onOpen(attachment)}
@@ -7,11 +7,6 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = [] const createdClients: string[] = []
const createdSessions: string[] = [] const createdSessions: string[] = []
const sessionCreateInputs: Array<{
agent?: string
model?: { id: string; providerID: string; variant?: string }
location?: { directory: string }
}> = []
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = [] const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
const optimistic: Array<{ const optimistic: Array<{
directory?: string directory?: string
@@ -25,14 +20,9 @@ const optimistic: Array<{
const optimisticSeeded: boolean[] = [] const optimisticSeeded: boolean[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {} const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const promoted: Array<{ directory: string; sessionID: string }> = [] const promoted: Array<{ directory: string; sessionID: string }> = []
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = [] const sentShell: string[] = []
const syncedDirectories: string[] = [] const syncedDirectories: string[] = []
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = [] const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
const sentPrompts: string[] = []
const promptInputs: unknown[] = []
const sentCommands: unknown[] = []
const commands: Array<{ name: string }> = []
let serverSessionSyncs = 0
let params: { id?: string } = {} let params: { id?: string } = {}
let search: { draftId?: string } = {} let search: { draftId?: string } = {}
@@ -41,7 +31,7 @@ let variant: string | undefined
let permissionServer = "server-a" let permissionServer = "server-a"
let createSessionGate: Promise<void> | undefined let createSessionGate: Promise<void> | undefined
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const [promptStore, setPromptStore] = createStore<PromptStore>({ const [promptStore, setPromptStore] = createStore<PromptStore>({
prompt: promptValue, prompt: promptValue,
cursor: 0, cursor: 0,
@@ -73,39 +63,23 @@ const prompt = {
const clientFor = (directory: string) => { const clientFor = (directory: string) => {
createdClients.push(directory) createdClients.push(directory)
return { return {
api: {
session: {
create: async (input: (typeof sessionCreateInputs)[number]) => {
await createSessionGate
const location = input.location?.directory ?? directory
createdSessions.push(location)
sessionCreateInputs.push(input)
return {
id: `session-${createdSessions.length}`,
projectID: "project",
agent: input.agent,
model: input.model,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
title: `New session ${createdSessions.length}`,
location: { directory: location },
}
},
prompt: async (input: unknown) => {
sentPrompts.push(directory)
promptInputs.push(input)
return { data: undefined }
},
command: async (input: unknown) => {
sentCommands.push(input)
},
shell: async (input: { sessionID: string; id?: string; command: string }) => {
sentShell.push(input)
},
},
},
session: { session: {
create: async () => {
await createSessionGate
createdSessions.push(directory)
return {
data: {
id: `session-${createdSessions.length}`,
title: `New session ${createdSessions.length}`,
},
}
},
shell: async () => {
sentShell.push(directory)
return { data: undefined }
},
prompt: async () => ({ data: undefined }),
promptAsync: async () => ({ data: undefined }),
command: async () => ({ data: undefined }), command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }), abort: async () => ({ data: undefined }),
}, },
@@ -198,7 +172,6 @@ beforeAll(async () => {
scope: "local", scope: "local",
directory: "/repo/main", directory: "/repo/main",
client: rootClient, client: rootClient,
api: rootClient.api,
url: "http://localhost:4096", url: "http://localhost:4096",
createClient(opts: any) { createClient(opts: any) {
return clientFor(opts.directory) return clientFor(opts.directory)
@@ -210,7 +183,7 @@ beforeAll(async () => {
mock.module("@/context/sync", () => ({ mock.module("@/context/sync", () => ({
useSync: () => () => ({ useSync: () => () => ({
data: { command: commands }, data: { command: [] },
session: { session: {
optimistic: { optimistic: {
add: (value: { add: (value: {
@@ -237,9 +210,6 @@ beforeAll(async () => {
session: { session: {
remember: () => undefined, remember: () => undefined,
set: () => undefined, set: () => undefined,
sync: async () => {
serverSessionSyncs++
},
}, },
child: (directory: string) => { child: (directory: string) => {
syncedDirectories.push(directory) syncedDirectories.push(directory)
@@ -281,17 +251,11 @@ beforeAll(async () => {
beforeEach(() => { beforeEach(() => {
createdClients.length = 0 createdClients.length = 0
createdSessions.length = 0 createdSessions.length = 0
sessionCreateInputs.length = 0
enabledAutoAccept.length = 0 enabledAutoAccept.length = 0
optimistic.length = 0 optimistic.length = 0
optimisticSeeded.length = 0 optimisticSeeded.length = 0
promoted.length = 0 promoted.length = 0
promotedDrafts.length = 0 promotedDrafts.length = 0
sentPrompts.length = 0
promptInputs.length = 0
sentCommands.length = 0
commands.length = 0
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
params = {} params = {}
search = {} search = {}
sentShell.length = 0 sentShell.length = 0
@@ -300,7 +264,6 @@ beforeEach(() => {
variant = undefined variant = undefined
permissionServer = "server-a" permissionServer = "server-a"
createSessionGate = undefined createSessionGate = undefined
serverSessionSyncs = 0
for (const key of Object.keys(storedSessions)) delete storedSessions[key] for (const key of Object.keys(storedSessions)) delete storedSessions[key]
}) })
@@ -334,24 +297,8 @@ describe("prompt submit worktree selection", () => {
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sessionCreateInputs).toEqual([ expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
{
agent: "agent",
model: { id: "model", providerID: "provider", variant: undefined },
location: { directory: "/repo/worktree-a" },
},
{
agent: "agent",
model: { id: "model", providerID: "provider", variant: undefined },
location: { directory: "/repo/worktree-b" },
},
])
expect(sentShell).toEqual([
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"]) expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
expect(serverSessionSyncs).toBe(0)
expect(promoted).toEqual([ expect(promoted).toEqual([
{ directory: "/repo/worktree-a", sessionID: "session-1" }, { directory: "/repo/worktree-a", sessionID: "session-1" },
{ directory: "/repo/worktree-b", sessionID: "session-2" }, { directory: "/repo/worktree-b", sessionID: "session-2" },
@@ -472,7 +419,6 @@ describe("prompt submit worktree selection", () => {
const event = { preventDefault: () => undefined } as unknown as Event const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event) await submit.handleSubmit(event)
await Bun.sleep(0)
expect(optimistic).toHaveLength(1) expect(optimistic).toHaveLength(1)
expect(optimistic[0]).toMatchObject({ expect(optimistic[0]).toMatchObject({
@@ -481,56 +427,6 @@ describe("prompt submit worktree selection", () => {
model: { providerID: "provider", modelID: "model", variant: "high" }, model: { providerID: "provider", modelID: "model", variant: "high" },
}, },
}) })
expect(sentPrompts).toEqual(["/repo/main"])
expect(promptInputs[0]).toMatchObject({
sessionID: "session-1",
text: "ls",
files: [],
agents: [],
})
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
expect((promptInputs[0] as { legacyParts?: { id: string; type: string; text?: string }[] }).legacyParts).toEqual([
{ id: expect.stringMatching(/^prt_/), type: "text", text: "ls" },
])
})
test("submits slash commands through the current session API", async () => {
params = { id: "session-1" }
variant = "high"
commands.push({ name: "review" })
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }),
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(sentCommands).toEqual([
{
sessionID: "session-1",
id: expect.stringMatching(/^msg_/),
command: "review",
arguments: "staged changes",
agent: "agent",
model: { id: "model", providerID: "provider", variant: "high" },
files: [],
},
])
expect(serverSessionSyncs).toBe(0)
}) })
test("uses an injected model selection", async () => { test("uses an injected model selection", async () => {
@@ -591,8 +487,7 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event) await submit.handleSubmit(event)
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1) expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
expect(optimisticSeeded).toEqual([true]) expect(optimisticSeeded).toEqual([true])
}) })
}) })
@@ -20,9 +20,6 @@ import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope" import { ScopedKey } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state" 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 = { type PendingPrompt = {
abort: AbortController abort: AbortController
@@ -42,7 +39,7 @@ export type FollowupDraft = {
} }
type FollowupSendInput = { type FollowupSendInput = {
api: DirectorySDK["api"]["session"] client: DirectorySDK["client"]
serverSync: ServerSync serverSync: ServerSync
sync: DirectorySync sync: DirectorySync
draft: FollowupDraft draft: FollowupDraft
@@ -84,24 +81,20 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
return false return false
} }
const messageID = Identifier.ascending("message") await input.client.session.command({
await input.api.command({
sessionID: input.draft.sessionID, sessionID: input.draft.sessionID,
id: messageID,
command: cmd, command: cmd,
arguments: tail.join(" "), arguments: tail.join(" "),
agent: input.draft.agent, agent: input.draft.agent,
model: { model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
id: input.draft.model.modelID, variant: input.draft.variant,
providerID: input.draft.model.providerID, parts: images.map((attachment) => ({
variant: input.draft.variant, id: Identifier.ascending("part"),
}, type: "file" as const,
files: await Promise.all( mime: attachment.mime,
images.map(async (attachment) => ({ url: attachment.dataUrl,
uri: await blobDataUrl(attachment.blob, attachment.mime), filename: attachment.filename,
name: attachment.filename, })),
})),
),
}) })
return true return true
} catch (err) { } catch (err) {
@@ -111,16 +104,10 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
} }
const messageID = input.messageID ?? Identifier.ascending("message") 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({ const { requestParts, optimisticParts } = buildRequestParts({
prompt: input.draft.prompt, prompt: input.draft.prompt,
context: input.draft.context, context: input.draft.context,
images: encodedImages, images,
text, text,
sessionID: input.draft.sessionID, sessionID: input.draft.sessionID,
messageID, messageID,
@@ -165,37 +152,13 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
return false return false
} }
await input.api.prompt({ await input.client.session.promptAsync({
sessionID: input.draft.sessionID, sessionID: input.draft.sessionID,
id: messageID,
agent: input.draft.agent, agent: input.draft.agent,
model: input.draft.model, model: input.draft.model,
messageID,
parts: requestParts,
variant: input.draft.variant, variant: input.draft.variant,
legacyParts: requestParts,
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
files: requestParts.flatMap((part) => {
if (part.type !== "file") return []
const text = part.source?.text
return [
{
uri: part.url,
name: part.filename,
mention: text ? { start: text.start, end: text.end, text: text.value } : undefined,
},
]
}),
agents: requestParts.flatMap((part) =>
part.type === "agent"
? [
{
name: part.name,
mention: part.source
? { start: part.source.start, end: part.source.end, text: part.source.value }
: undefined,
},
]
: [],
),
}) })
return true return true
} catch (err) { } catch (err) {
@@ -247,7 +210,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID) const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
const errorMessage = (err: unknown) => { const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
if (err && typeof err === "object" && "data" in err) { if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message if (data?.message) return data.message
@@ -273,7 +235,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return Promise.resolve() return Promise.resolve()
} }
return sdk() return sdk()
.api.session.interrupt({ sessionID }) .client.session.abort({
sessionID,
})
.catch(() => {}) .catch(() => {})
} }
@@ -400,13 +364,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
let session = input.info() let session = input.info()
if (!session && isNewSession) { if (!session && isNewSession) {
const created = await sdk() const created = await client.session
.api.session.create({ .create()
agent: currentAgent.name, .then((x) => x.data ?? undefined)
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
location: { directory: sessionDirectory },
})
.then(normalizeSessionInfo)
.catch((err) => { .catch((err) => {
showToast({ showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"), title: language.t("prompt.toast.sessionCreateFailed.title"),
@@ -490,14 +450,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (mode === "shell") { if (mode === "shell") {
clearInput() clearInput()
const eventID = Event.ID.create() client.session
sdk() .shell({
.api.session.shell({
sessionID: session.id, sessionID: session.id,
id: eventID,
command: text,
agent, agent,
model, model,
command: text,
}) })
.catch((err) => { .catch((err) => {
showToast({ showToast({
@@ -515,25 +473,23 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const customCommand = sync().data.command.find((c) => c.name === commandName) const customCommand = sync().data.command.find((c) => c.name === commandName)
if (customCommand) { if (customCommand) {
clearInput() clearInput()
const messageID = Identifier.ascending("message") client.session
serverSync().session.set("session_status", session.id, { type: "busy" }) .command({
sdk()
.api.session.command({
sessionID: session.id, sessionID: session.id,
id: messageID,
command: commandName, command: commandName,
arguments: args.join(" "), arguments: args.join(" "),
agent, agent,
model: { id: model.modelID, providerID: model.providerID, variant }, model: `${model.providerID}/${model.modelID}`,
files: await Promise.all( variant,
images.map(async (attachment) => ({ parts: images.map((attachment) => ({
uri: await blobDataUrl(attachment.blob, attachment.mime), id: Identifier.ascending("part"),
name: attachment.filename, type: "file" as const,
})), mime: attachment.mime,
), url: attachment.dataUrl,
filename: attachment.filename,
})),
}) })
.catch((err) => { .catch((err) => {
serverSync().session.set("session_status", session.id, { type: "idle" })
showToast({ showToast({
title: language.t("prompt.toast.commandSendFailed.title"), title: language.t("prompt.toast.commandSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")), description: formatServerError(err, language.t, language.t("common.requestFailed")),
@@ -617,7 +573,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
void sendFollowupDraft({ void sendFollowupDraft({
api: sdk().api.session, client,
sync: sync(), sync: sync(),
serverSync: serverSync(), serverSync: serverSync(),
draft, draft,
@@ -18,7 +18,6 @@ import { useLanguage } from "@/context/language"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import { handleDocumentSearchKeydown } from "@/utils/search-keydown" import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
export type PromptProject = { export type PromptProject = {
name?: string name?: string
@@ -198,8 +197,8 @@ export function PromptProjectSelector(props: {
}) { }) {
const [triggerReady, setTriggerReady] = createSignal(false) const [triggerReady, setTriggerReady] = createSignal(false)
let contentRef: HTMLDivElement | undefined let contentRef: HTMLDivElement | undefined
const dismiss = createMenuDismissController(() => contentRef)
let triggerFrame: number | undefined let triggerFrame: number | undefined
let restoreTrigger = true
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption. // Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
const setTriggerRef = (element: HTMLButtonElement) => { const setTriggerRef = (element: HTMLButtonElement) => {
@@ -222,15 +221,25 @@ export function PromptProjectSelector(props: {
props.controller.active() props.controller.active()
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`) ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
: undefined : undefined
const afterClose = (callback: () => void) => {
const complete = () => {
if (contentRef?.isConnected) {
requestAnimationFrame(complete)
return
}
requestAnimationFrame(() => requestAnimationFrame(callback))
}
requestAnimationFrame(complete)
}
const selectProject = (project: PromptProject) => { const selectProject = (project: PromptProject) => {
dismiss.preventTriggerRestore() restoreTrigger = false
props.controller.setOpen(false) props.controller.setOpen(false)
dismiss.afterClose(() => props.controller.select(project)) afterClose(() => props.controller.select(project))
} }
const selectAction = (server?: string) => { const selectAction = (server?: string) => {
dismiss.preventTriggerRestore() restoreTrigger = false
props.controller.setOpen(false) props.controller.setOpen(false)
dismiss.afterClose(() => props.controller.add(server)) afterClose(() => props.controller.add(server))
} }
const selectActive = () => { const selectActive = () => {
const project = props.controller.activeProject() const project = props.controller.activeProject()
@@ -258,7 +267,7 @@ export function PromptProjectSelector(props: {
) )
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap")) .filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
.findLast((element) => element.offsetParent !== null) .findLast((element) => element.offsetParent !== null)
dismiss.preventTriggerRestore() restoreTrigger = false
target?.focus() target?.focus()
queueMicrotask(() => { queueMicrotask(() => {
if (props.controller.open()) props.controller.setOpen(false) if (props.controller.open()) props.controller.setOpen(false)
@@ -282,10 +291,7 @@ export function PromptProjectSelector(props: {
placement={props.placement ?? "bottom"} placement={props.placement ?? "bottom"}
gutter={4} gutter={4}
modal={false} modal={false}
onOpenChange={(open) => { onOpenChange={(open) => props.controller.setOpen(open)}
if (open) dismiss.allowTriggerRestore()
props.controller.setOpen(open)
}}
> >
<DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} /> <DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
<DropdownMenu.Portal> <DropdownMenu.Portal>
@@ -294,9 +300,11 @@ export function PromptProjectSelector(props: {
id="prompt-project-menu" id="prompt-project-menu"
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none" class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
onOpenAutoFocus={(event) => event.preventDefault()} onOpenAutoFocus={(event) => event.preventDefault()}
onPointerDownOutside={dismiss.preventTriggerRestore} onPointerDownOutside={() => (restoreTrigger = false)}
onFocusOutside={dismiss.preventTriggerRestore} onFocusOutside={() => (restoreTrigger = false)}
onCloseAutoFocus={dismiss.onCloseAutoFocus} onCloseAutoFocus={(event) => {
if (!restoreTrigger) event.preventDefault()
}}
> >
<div class="flex flex-col p-0.5"> <div class="flex flex-col p-0.5">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted"> <div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
@@ -356,45 +364,41 @@ export function PromptProjectSelector(props: {
</button> </button>
</Show> </Show>
</div> </div>
<div class="max-h-[224px] overflow-y-auto"> <Show
<Show when={props.controller.servers().length > 1}
when={props.controller.servers().length > 1} fallback={
fallback={ <DropdownMenu.RadioGroup value={selectedValue()}>
<DropdownMenu.RadioGroup value={selectedValue()}> <For each={props.controller.projects()}>
<For each={props.controller.projects()}> {(project) => (
{(project) => ( <ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)}
</For>
</DropdownMenu.RadioGroup>
}
>
<For
each={props.controller
.servers()
.filter((server) =>
props.controller.projects().some((project) => project.server?.key === server!.key),
)} )}
> </For>
{(server) => ( </DropdownMenu.RadioGroup>
<div> }
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint"> >
{server!.name} <For
</div> each={props.controller
<DropdownMenu.RadioGroup value={selectedValue()}> .servers()
<For .filter((server) =>
each={props.controller.projects().filter((project) => project.server?.key === server!.key)} props.controller.projects().some((project) => project.server?.key === server!.key),
>
{(project) => (
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)}
</For>
</DropdownMenu.RadioGroup>
</div>
)} )}
</For> >
</Show> {(server) => (
</div> <div>
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
{server!.name}
</div>
<DropdownMenu.RadioGroup value={selectedValue()}>
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
{(project) => (
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)}
</For>
</DropdownMenu.RadioGroup>
</div>
)}
</For>
</Show>
</div> </div>
<div class="h-px bg-v2-border-border-muted" /> <div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5"> <div class="flex flex-col p-0.5">
@@ -93,36 +93,24 @@ export function PromptWorkspaceSelector(props: {
</MenuV2.Content> </MenuV2.Content>
</MenuV2.Portal> </MenuV2.Portal>
</MenuV2> </MenuV2>
<PromptGitStatus branch={props.branch} /> <Show when={props.branch}>
{(branch) => (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<TooltipV2
placement="top"
value={branch()}
class="min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{branch()}</span>
</div>
</TooltipV2>
</>
)}
</Show>
</> </>
) )
} }
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
const language = useLanguage()
const label = () => {
if (props.noGit) return language.t("session.new.git.none")
return props.branch
}
return (
<Show when={label()}>
{(value) => (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<TooltipV2
placement="top"
value={value()}
class="min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{value()}</span>
</div>
</TooltipV2>
</>
)}
</Show>
)
}
@@ -15,47 +15,9 @@ export const ServerRowMenu: Component<{
}> = (props) => { }> = (props) => {
const language = useLanguage() const language = useLanguage()
const key = ServerConnection.key(props.server) const key = ServerConnection.key(props.server)
return ( const builtin = ServerConnection.builtin(props.server)
<ServerRowMenuView const isDefault = () => props.controller.defaultKey() === key
server={props.server}
labels={serverMenuLabels(language)}
canDefault={props.controller.canDefault()}
isDefault={props.controller.defaultKey() === key}
onEdit={props.onEdit}
onSetDefault={() => props.controller.setDefault(key)}
onRemoveDefault={() => props.controller.setDefault(null)}
onRemove={() => props.controller.handleRemove(key)}
open={props.open}
onOpenChange={props.onOpenChange}
/>
)
}
export function serverMenuLabels(language: ReturnType<typeof useLanguage>) {
return {
more: language.t("common.moreOptions"),
server: language.t("settings.section.server"),
edit: language.t("dialog.server.menu.edit"),
default: language.t("dialog.server.menu.default"),
defaultRemove: language.t("dialog.server.menu.defaultRemove"),
delete: language.t("dialog.server.menu.delete"),
}
}
export const ServerRowMenuView: Component<{
server: ServerConnection.Any
labels: ReturnType<typeof serverMenuLabels>
canDefault: boolean
isDefault: boolean
onEdit: (server: ServerConnection.Http) => void
onSetDefault: () => void
onRemoveDefault: () => void
onRemove: () => void
open?: boolean
onOpenChange?: (open: boolean) => void
}> = (props) => {
const builtin = () => ServerConnection.builtin(props.server)
const httpServer = () => (props.server.type === "http" ? props.server : undefined)
return ( return (
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}> <MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
<MenuV2.Trigger <MenuV2.Trigger
@@ -63,30 +25,31 @@ export const ServerRowMenuView: Component<{
variant="ghost-muted" variant="ghost-muted"
size="small" size="small"
icon={<IconV2 name="outline-dots" />} icon={<IconV2 name="outline-dots" />}
aria-label={props.labels.more} aria-label={language.t("common.moreOptions")}
/> />
<MenuV2.Portal> <MenuV2.Portal>
<MenuV2.Content> <MenuV2.Content>
<MenuV2.Group> <MenuV2.Group>
<MenuV2.GroupLabel>{props.labels.server}</MenuV2.GroupLabel> <MenuV2.GroupLabel>{language.t("settings.section.server")}</MenuV2.GroupLabel>
<MenuV2.Item <MenuV2.Item
disabled={builtin() || !httpServer()} disabled={builtin || props.server.type !== "http"}
onSelect={() => { onSelect={() => props.onEdit(props.server as ServerConnection.Http)}
const server = httpServer()
if (server) props.onEdit(server)
}}
> >
{props.labels.edit} {language.t("dialog.server.menu.edit")}
</MenuV2.Item> </MenuV2.Item>
<Show when={props.canDefault && !props.isDefault}> <Show when={props.controller.canDefault() && !isDefault()}>
<MenuV2.Item onSelect={props.onSetDefault}>{props.labels.default}</MenuV2.Item> <MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
{language.t("dialog.server.menu.default")}
</MenuV2.Item>
</Show> </Show>
<Show when={props.canDefault && props.isDefault}> <Show when={props.controller.canDefault() && isDefault()}>
<MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item> <MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
{language.t("dialog.server.menu.defaultRemove")}
</MenuV2.Item>
</Show> </Show>
<MenuV2.Separator /> <MenuV2.Separator />
<MenuV2.Item disabled={builtin()} onSelect={props.onRemove}> <MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}>
{props.labels.delete} {language.t("dialog.server.menu.delete")}
</MenuV2.Item> </MenuV2.Item>
</MenuV2.Group> </MenuV2.Group>
</MenuV2.Content> </MenuV2.Content>
@@ -4,3 +4,4 @@ export { SortableTab, FileVisual } from "./session-sortable-tab"
export { SortableTabV2 } from "./session-sortable-tab-v2" export { SortableTabV2 } from "./session-sortable-tab-v2"
export { SortableTerminalTab } from "./session-sortable-terminal-tab" export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view" export { NewSessionView } from "./session-new-view"
export { NewSessionDesignView } from "./session-new-design-view"
@@ -289,9 +289,9 @@ export function SessionHeader() {
return ( return (
<> <>
<Show when={search() && centerMount()} keyed> <Show when={search() && centerMount()}>
{(mount) => ( {(mount) => (
<Portal mount={mount}> <Portal mount={mount()}>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -308,10 +308,10 @@ export function SessionHeader() {
</span> </span>
</div> </div>
<Show when={hotkey()} keyed> <Show when={hotkey()}>
{(keybind) => ( {(keybind) => (
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker"> <Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
{keybind} {keybind()}
</Keybind> </Keybind>
)} )}
</Show> </Show>
@@ -319,9 +319,9 @@ export function SessionHeader() {
</Portal> </Portal>
)} )}
</Show> </Show>
<Show when={rightMount()} keyed> <Show when={rightMount()}>
{(mount) => ( {(mount) => (
<Portal mount={mount}> <Portal mount={mount()}>
<Show <Show
when={isV2} when={isV2}
fallback={ fallback={
@@ -0,0 +1,16 @@
import type { JSX } from "solid-js"
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
export function NewSessionDesignView(props: { children: JSX.Element }) {
return (
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
<div class="mt-8">{props.children}</div>
</div>
</div>
</div>
)
}
@@ -29,7 +29,7 @@ import {
} from "@/context/settings" } from "@/context/settings"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { ExternalLink } from "./external-link" import { Link } from "./link"
import { SettingsList } from "./settings-list" import { SettingsList } from "./settings-list"
let demoSoundState = { let demoSoundState = {
@@ -127,14 +127,11 @@ export const SettingsGeneral: Component = () => {
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
const [shells] = createResource( const [shells] = createResource(
async () => { () =>
const sdk = serverSdk() serverSdk()
if ((await sdk.protocol) === "v1") { .client.pty.shells()
return (await sdk.client.pty.shells()).data ?? [] .then((res) => res.data ?? [])
} .catch(() => [] as ShellOption[]),
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[]
},
{ initialValue: [] as ShellOption[] }, { initialValue: [] as ShellOption[] },
) )
@@ -482,7 +479,7 @@ export const SettingsGeneral: Component = () => {
description={ description={
<> <>
{language.t("settings.general.row.theme.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>
</> </>
} }
> >
+62 -290
View File
@@ -30,8 +30,6 @@ type KeybindMeta = {
type KeybindMap = Record<string, string | undefined> type KeybindMap = Record<string, string | undefined>
type CommandContext = ReturnType<typeof useCommand> type CommandContext = ReturnType<typeof useCommand>
type LanguageContext = ReturnType<typeof useLanguage>
type SettingsContext = ReturnType<typeof useSettings>
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"] const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
@@ -124,7 +122,7 @@ function keybinds(value: unknown): KeybindMap {
return value as KeybindMap return value as KeybindMap
} }
function listFor(command: Pick<CommandContext, "catalog" | "options">, map: KeybindMap, palette: string) { function listFor(command: CommandContext, map: KeybindMap, palette: string) {
const out = new Map<string, KeybindMeta>() const out = new Map<string, KeybindMeta>()
out.set(PALETTE_ID, { title: palette, group: "General" }) out.set(PALETTE_ID, { title: palette, group: "General" })
@@ -264,274 +262,7 @@ function useKeyCapture(input: {
}) })
} }
export function createKeybindSettingsController(
input: {
command: Pick<CommandContext, "catalog" | "options" | "keybinds">
settings: {
current: { keybinds: unknown }
keybinds: Pick<SettingsContext["keybinds"], "get" | "set" | "resetAll">
}
target?: Document
notify?: (toast: { title: string; description: string }) => void
},
language: Pick<LanguageContext, "locale" | "t"> = useLanguage(),
) {
const [store, setStore] = createStore({ active: null as string | null })
const overrides = createMemo(() => keybinds(input.settings.current.keybinds))
const list = createMemo(() => {
language.locale()
return listFor(input.command, overrides(), language.t("command.palette"))
})
const grouped = createMemo(() => groupedFor(list()))
const title = (id: string) => list().get(id)?.title ?? ""
const effective = (id: string) => {
if (id === PALETTE_ID) return input.settings.keybinds.get(id) ?? DEFAULT_PALETTE_KEYBIND
const custom = input.settings.keybinds.get(id)
if (typeof custom === "string") return custom
const live = input.command.options.find((item) => item.id === id)
if (live?.keybind) return live.keybind
return input.command.catalog.find((item) => item.id === id)?.keybind
}
const used = createMemo(() => {
const value = new Map<string, { id: string; title: string }[]>()
for (const id of list().keys()) {
for (const signature of signatures(effective(id))) {
const items = value.get(signature)
if (items) {
items.push({ id, title: title(id) })
continue
}
value.set(signature, [{ id, title: title(id) }])
}
}
return value
})
const stop = () => {
if (!store.active) return
setStore("active", null)
input.command.keybinds(true)
}
const toggle = (id: string) => {
if (store.active === id) {
stop()
return
}
if (store.active) stop()
setStore("active", id)
input.command.keybinds(false)
}
const notify = input.notify ?? ((toast: { title: string; description: string }) => showToast(toast))
const handle = (event: KeyboardEvent) => {
const id = store.active
if (!id) return
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
if (event.key === "Escape") {
stop()
return
}
const clear =
(event.key === "Backspace" || event.key === "Delete") &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey
if (clear) {
input.settings.keybinds.set(id, "none")
stop()
return
}
const next = recordKeybind(event)
if (!next) return
const conflicts = new Map<string, string>()
for (const signature of signatures(next)) {
for (const item of used().get(signature) ?? []) {
if (item.id === id) continue
conflicts.set(item.id, item.title)
}
}
if (conflicts.size > 0) {
notify({
title: language.t("settings.shortcuts.conflict.title"),
description: language.t("settings.shortcuts.conflict.description", {
keybind: formatKeybind(next, language.t),
titles: [...conflicts.values()].join(", "),
}),
})
return
}
input.settings.keybinds.set(id, next)
stop()
}
const target = input.target ?? (typeof document === "object" ? document : undefined)
if (target) makeEventListener(target, "keydown", handle, { capture: true })
onCleanup(() => {
if (store.active) input.command.keybinds(true)
})
return {
catalog: {
groups: GROUPS,
filtered: (query: string) =>
filteredFor(query, list(), grouped(), (id) => formatKeybind(effective(id) ?? "", language.t)),
title,
keybind: (id: string) => formatKeybind(effective(id) ?? "", language.t),
},
capture: {
active: () => store.active,
toggle,
},
settings: {
hasOverrides: () => Object.values(overrides()).some((value) => typeof value === "string"),
reset: () => {
stop()
input.settings.keybinds.resetAll()
notify({
title: language.t("settings.shortcuts.reset.toast.title"),
description: language.t("settings.shortcuts.reset.toast.description"),
})
},
},
}
}
function SettingsKeybindsV2() {
const command = useCommand()
const settings = useSettings()
const controller = createKeybindSettingsController({
command,
settings,
})
return (
<SettingsKeybindsV2View
groups={controller.catalog.groups}
filtered={controller.catalog.filtered}
title={controller.catalog.title}
keybind={controller.catalog.keybind}
active={controller.capture.active}
onCapture={controller.capture.toggle}
hasOverrides={controller.settings.hasOverrides}
onReset={controller.settings.reset}
/>
)
}
function SettingsKeybindsV2View(props: {
groups: KeybindGroup[]
filtered: (query: string) => Map<KeybindGroup, string[]>
title: (id: string) => string
keybind: (id: string) => string
active: () => string | null
onCapture: (id: string) => void
hasOverrides: () => boolean
onReset: () => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({ filter: "" })
const filtered = createMemo(() => props.filtered(store.filter))
const hasResults = createMemo(() => props.groups.some((group) => (filtered().get(group)?.length ?? 0) > 0))
return (
<>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</ButtonV2>
</div>
<div class="settings-v2-tab-search">
<TextInputV2
type="search"
appearance="base"
value={store.filter}
onInput={(event) => setStore("filter", event.currentTarget.value)}
placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.shortcuts.search.placeholder")}
/>
<Show when={store.filter}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
class="settings-v2-tab-search-clear"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setStore("filter", "")}
/>
</Show>
</div>
</div>
<div class="settings-v2-tab-body">
<div class="settings-v2-shortcuts flex flex-col gap-8">
<For each={props.groups}>
{(group) => (
<Show when={(filtered().get(group) ?? []).length > 0}>
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t(groupKey[group])}</h3>
<SettingsListV2>
<For each={filtered().get(group) ?? []}>
{(id) => (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<span>{props.title(id)}</span>
<button
type="button"
data-keybind-id={id}
classList={{
"settings-v2-keybind-button": true,
"settings-v2-keybind-button--active": props.active() === id,
}}
onClick={() => props.onCapture(id)}
>
<Show
when={props.active() === id}
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
>
{language.t("settings.shortcuts.pressKeys")}
</Show>
</button>
</div>
)}
</For>
</SettingsListV2>
</div>
</Show>
)}
</For>
<Show when={store.filter && !hasResults()}>
<div class="settings-v2-shortcuts-status">
<span>{language.t("settings.shortcuts.search.empty")}</span>
<span class="settings-v2-shortcuts-status-filter">&quot;{store.filter}&quot;</span>
</div>
</Show>
</div>
</div>
</>
)
}
export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => { export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
if (props.v2) return <SettingsKeybindsV2 />
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
@@ -745,37 +476,78 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
) )
return ( return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"> <Show
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]"> when={props.v2}
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]"> fallback={
<div class="flex items-center justify-between gap-4"> <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2> <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}> <div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
{language.t("settings.shortcuts.reset.button")} <div class="flex items-center justify-between gap-4">
</Button> <h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
</div> <Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</Button>
</div>
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base"> <div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" /> <Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField <TextField
variant="ghost" variant="ghost"
type="text" type="text"
value={store.filter}
onChange={(v) => setStore("filter", v)}
placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="flex-1"
/>
<Show when={store.filter}>
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
</Show>
</div>
</div>
</div>
{groups}
</div>
}
>
<>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
<ButtonV2 variant="ghost" onClick={resetAll} disabled={!hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</ButtonV2>
</div>
<div class="settings-v2-tab-search">
<TextInputV2
type="search"
appearance="base"
value={store.filter} value={store.filter}
onChange={(v) => setStore("filter", v)} onInput={(event) => setStore("filter", event.currentTarget.value)}
placeholder={language.t("settings.shortcuts.search.placeholder")} placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false} spellcheck={false}
autocorrect="off" autocorrect="off"
autocomplete="off" autocomplete="off"
autocapitalize="off" autocapitalize="off"
class="flex-1" aria-label={language.t("settings.shortcuts.search.placeholder")}
/> />
<Show when={store.filter}> <Show when={store.filter}>
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} /> <IconButtonV2
type="button"
variant="ghost-muted"
size="small"
class="settings-v2-tab-search-clear"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setStore("filter", "")}
/>
</Show> </Show>
</div> </div>
</div> </div>
</div> <div class="settings-v2-tab-body">{groups}</div>
{groups} </>
</div> </Show>
) )
} }
@@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js" import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider" import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
import { DialogCustomProvider } from "./dialog-custom-provider" import { DialogCustomProvider } from "./dialog-custom-provider"
@@ -39,9 +39,8 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const protocol = useServerProtocol()
const serverSync = useServerSync() const serverSync = useServerSync()
const providers = useProviders(() => undefined) const providers = useProviders()
const providerConnect = useProviderConnectController({ onBack: props.onBack }) const providerConnect = useProviderConnectController({ onBack: props.onBack })
const connect = (provider?: string) => { const connect = (provider?: string) => {
@@ -84,8 +83,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
return language.t("settings.providers.tag.other") return language.t("settings.providers.tag.other")
} }
const canDisconnect = (item: ProviderItem) => const canDisconnect = (item: ProviderItem) => source(item) !== "env"
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
@@ -98,7 +96,6 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
} }
const disableProvider = async (providerID: string, name: string) => { const disableProvider = async (providerID: string, name: string) => {
if (protocol() !== "v1") return
const before = serverSync().data.config.disabled_providers ?? [] const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID] const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next) serverSync().set("config", "disabled_providers", next)
@@ -221,33 +218,31 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
)} )}
</For> </For>
<Show when={protocol() === "v1"}> <div
<div class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3" data-component="custom-provider-section"
data-component="custom-provider-section" >
> <div class="flex flex-col min-w-0">
<div class="flex flex-col min-w-0"> <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1"> <ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" /> <span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span> <Tag>{language.t("settings.providers.tag.custom")}</Tag>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</div>
<span class="text-12-regular text-text-weak pl-8">
{language.t("settings.providers.custom.description")}
</span>
</div> </div>
<Button <span class="text-12-regular text-text-weak pl-8">
size="large" {language.t("settings.providers.custom.description")}
variant="secondary" </span>
icon="plus-small"
onClick={() => {
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
}}
>
{language.t("common.connect")}
</Button>
</div> </div>
</Show> <Button
size="large"
variant="secondary"
icon="plus-small"
onClick={() => {
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
}}
>
{language.t("common.connect")}
</Button>
</div>
</SettingsList> </SettingsList>
<Button <Button
@@ -1,4 +1,4 @@
import { Component, createMemo, createSignal, startTransition } from "solid-js" import { Component, createSignal, startTransition } from "solid-js"
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2" import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@@ -11,9 +11,6 @@ import { SettingsModelsV2 } from "./models"
import "./settings-v2.css" import "./settings-v2.css"
import { SettingsServersV2 } from "./servers" import { SettingsServersV2 } from "./servers"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLayout } from "@/context/layout"
import { useTabs } from "@/context/tabs"
import { useServerSync } from "@/context/server-sync"
export const DialogSettings: Component<{ export const DialogSettings: Component<{
sessionID?: string sessionID?: string
@@ -22,20 +19,7 @@ export const DialogSettings: Component<{
const language = useLanguage() const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog() const dialog = useDialog()
const layout = useLayout()
const tabs = useTabs()
const serverSync = useServerSync()
const [tab, setTab] = createSignal(props.defaultValue ?? "general") const [tab, setTab] = createSignal(props.defaultValue ?? "general")
const directory = createMemo(() => {
const route = layout.route()
if (route.type === "dir-new-sesssion") return route.dir
if (route.type === "draft") {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return draft?.type === "draft" ? draft.directory : undefined
}
if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory
return undefined
})
const showProviders = () => { const showProviders = () => {
void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />) void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />)
@@ -103,7 +87,7 @@ export const DialogSettings: Component<{
<SettingsServersV2 /> <SettingsServersV2 />
</TabsV2.Content> </TabsV2.Content>
<TabsV2.Content value="providers" class="settings-v2-panel"> <TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 directory={directory} onBack={showProviders} /> <SettingsProvidersV2 onBack={showProviders} />
</TabsV2.Content> </TabsV2.Content>
<TabsV2.Content value="models" class="settings-v2-panel"> <TabsV2.Content value="models" class="settings-v2-panel">
<SettingsModelsV2 /> <SettingsModelsV2 />
@@ -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,179 @@
import { Component, Show, createMemo, createResource } from "solid-js" import { Component, Show, createMemo, createResource, onMount } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2" import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-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 { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "../updater-action" import { useUpdaterAction } from "../updater-action"
import { useSettings } from "@/context/settings" import {
import { ExternalLink } from "../external-link" 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 { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row" import { SettingsRowV2 } from "./parts/row"
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition" 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" import "./settings-v2.css"
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"] let demoSoundState = {
const fontSettings = { cleanup: undefined as (() => void) | undefined,
ui: { timeout: undefined as NodeJS.Timeout | undefined,
action: "settings-ui-font", run: 0,
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>
)
} }
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => { type ThemeOption = {
const language = useLanguage() id: string
const options = createMemo(() => name: string
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>
)
} }
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => { type ShellOption = {
const language = useLanguage() path: string
return ( name: string
<div class="settings-v2-section"> acceptable: boolean
<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>
)
} }
const FontSetting: Component<{ type ShellSelectOption = {
kind: "ui" | "code" | "terminal" id: string
fonts: AppearanceSettingsController["fonts"] value: string
}> = (props) => { label: string
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>
)
} }
const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => { // To prevent audio from overlapping/playing very quickly when navigating the settings menus,
const language = useLanguage() // delay the playback by 100ms during quick selection changes and pause existing sounds.
return ( const stopDemoSound = () => {
<div class="settings-v2-section"> demoSoundState.run += 1
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3> if (demoSoundState.cleanup) {
<SettingsListV2> demoSoundState.cleanup()
<SoundSetting kind="agent" channel={props.controller.agent} /> }
<SoundSetting kind="permissions" channel={props.controller.permissions} /> clearTimeout(demoSoundState.timeout)
<SoundSetting kind="errors" channel={props.controller.errors} /> demoSoundState.cleanup = undefined
</SettingsListV2>
</div>
)
} }
const SoundSetting: Component<{ const playDemoSound = (id: string | undefined) => {
kind: "agent" | "permissions" | "errors" stopDemoSound()
channel: SoundSettingsController["agent"] if (!id) return
}> = (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 LanguageSetting = () => { const run = ++demoSoundState.run
const language = useLanguage() demoSoundState.timeout = setTimeout(() => {
const options = createMemo(() => void playSoundById(id).then((cleanup) => {
language.locales.map((locale) => ({ if (demoSoundState.run !== run) {
value: locale, cleanup?.()
label: language.label(locale), return
})), }
) demoSoundState.cleanup = cleanup
return ( })
<SettingsRowV2 }, 100)
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>
)
} }
export const SettingsGeneralV2: Component<{ export const SettingsGeneralV2: Component<{
sessionID?: string sessionID?: string
}> = (props) => { }> = (props) => {
const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const permission = usePermission()
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog() const dialog = useDialog()
const settings = useSettings() const settings = useSettings()
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const mobile = createMediaQuery("(max-width: 767px)") const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction() const updater = useUpdaterAction()
const permissionScope = createPermissionScopeController(() => props.sessionID)
const shell = createShellSettingsController() const dir = createMemo(() => {
const appearance = createAppearanceSettingsController() if (!props.sessionID) return undefined
const sounds = createSoundSettingsController() 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 desktop = createMemo(() => platform.platform === "desktop")
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const [shells] = createResource(
() =>
serverSdk()
.client.pty.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
)
const [pinchZoom, { mutate: setPinchZoom }] = createResource( const [pinchZoom, { mutate: setPinchZoom }] = createResource(
() => desktop() && "getPinchZoomEnabled" in platform, () => (desktop() && platform.getPinchZoomEnabled ? true : false),
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false), () => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
{ initialValue: 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) => { const onPinchZoomChange = (checked: boolean) => {
setPinchZoom(checked) setPinchZoom(checked)
const update = platform.setPinchZoomEnabled?.(checked) const update = platform.setPinchZoomEnabled?.(checked)
@@ -299,6 +181,52 @@ export const SettingsGeneralV2: Component<{
void update.catch(() => setPinchZoom(!checked)) 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 = () => ( const InterfaceSection = () => (
<LayoutTransitionToggle <LayoutTransitionToggle
title={language.t("settings.general.row.newInterface.title")} title={language.t("settings.general.row.newInterface.title")}
@@ -320,18 +248,59 @@ export const SettingsGeneralV2: Component<{
title={language.t("settings.general.row.newInterfaceNotice.title")} title={language.t("settings.general.row.newInterfaceNotice.title")}
description={language.t("settings.general.row.newInterfaceNotice.description")} description={language.t("settings.general.row.newInterfaceNotice.description")}
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")} dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
onDismiss={() => settings.general.dismissNewInterfaceNotice()} onDismiss={settings.general.dismissNewInterfaceNotice}
/> />
) )
const GeneralSection = () => ( const GeneralSection = () => (
<div class="settings-v2-section"> <div class="settings-v2-section">
<SettingsListV2> <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 <SettingsRowV2
title={language.t("settings.general.row.reasoningSummaries.title")} title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -442,6 +411,124 @@ export const SettingsGeneralV2: Component<{
</div> </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 = () => ( const NotificationsSection = () => (
<div class="settings-v2-section"> <div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3> <h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
@@ -486,6 +573,68 @@ export const SettingsGeneralV2: Component<{
</div> </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 = () => ( const UpdatesSection = () => (
<div class="settings-v2-section"> <div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3> <h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
@@ -507,7 +656,7 @@ export const SettingsGeneralV2: Component<{
title={language.t("settings.updates.row.check.title")} title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")} 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)} {language.t(updater.action().label)}
</ButtonV2> </ButtonV2>
</SettingsRowV2> </SettingsRowV2>
@@ -552,11 +701,11 @@ export const SettingsGeneralV2: Component<{
<GeneralSection /> <GeneralSection />
<AppearanceSection controller={appearance} /> <AppearanceSection />
<NotificationsSection /> <NotificationsSection />
<SoundsSection controller={sounds} /> <SoundsSection />
<Show when={desktop()}> <Show when={desktop()}>
<UpdatesSection /> <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 { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { type Component, For, Show } from "solid-js" import { type Component, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models" import { useModels } from "@/context/models"
import { useServerSDK } from "@/context/server-sdk"
import { popularProviders } from "@/hooks/use-providers" import { popularProviders } from "@/hooks/use-providers"
import { Persist, persisted } from "@/utils/persist"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row" import { SettingsRowV2 } from "./parts/row"
import "./settings-v2.css" import "./settings-v2.css"
@@ -22,11 +19,6 @@ const PROVIDER_ICON_SIZE = 16
export const SettingsModelsV2: Component = () => { export const SettingsModelsV2: Component = () => {
const language = useLanguage() const language = useLanguage()
const models = useModels() 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>({ const list = useFilteredList<ModelItem>({
items: (_filter) => models.list(), items: (_filter) => models.list(),
@@ -102,82 +94,41 @@ export const SettingsModelsV2: Component = () => {
} }
> >
<For each={list.grouped.latest}> <For each={list.grouped.latest}>
{(group) => { {(group) => (
const searching = () => list.filter().length > 0 <div class="settings-v2-section" data-component="settings-models-provider">
const expanded = () => searching() || !store.collapsed[group.category] <div class="settings-v2-models-group-header">
<ProviderIcon
return ( id={group.category}
<div width={PROVIDER_ICON_SIZE}
class="settings-v2-section" height={PROVIDER_ICON_SIZE}
data-component="settings-models-provider" class="settings-v2-models-provider-icon shrink-0"
data-expanded={expanded() ? "" : undefined} />
> <h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
<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>
</div> </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> </For>
</Show> </Show>
</Show> </Show>
@@ -4,9 +4,9 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Accessor, type Component, For, Show } from "solid-js" import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider" import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
import { DialogCustomProvider } from "../dialog-custom-provider" import { DialogCustomProvider } from "../dialog-custom-provider"
@@ -29,21 +29,17 @@ const PROVIDER_NOTES = [
const PROVIDER_ICON_SIZE = 16 const PROVIDER_ICON_SIZE = 16
export const SettingsProvidersV2: Component<{ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) => {
directory: Accessor<string | undefined>
onBack?: () => void
}> = (props) => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const serverSync = useServerSync() const serverSync = useServerSync()
const providers = useProviders(props.directory) const providers = useProviders()
const providerConnect = useProviderConnectController({ onBack: props.onBack }) const providerConnect = useProviderConnectController({ onBack: props.onBack })
const connect = (provider?: string) => { const connect = (provider?: string) => {
providerConnect.select(provider) providerConnect.select(provider)
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />) void dialog.show(() => <DialogConnectProvider controller={providerConnect} />)
} }
const connected = createMemo(() => { const connected = createMemo(() => {
@@ -81,8 +77,7 @@ export const SettingsProvidersV2: Component<{
return language.t("settings.providers.tag.other") return language.t("settings.providers.tag.other")
} }
const canDisconnect = (item: ProviderItem) => const canDisconnect = (item: ProviderItem) => source(item) !== "env"
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
@@ -95,7 +90,6 @@ export const SettingsProvidersV2: Component<{
} }
const disableProvider = async (providerID: string, name: string) => { const disableProvider = async (providerID: string, name: string) => {
if (protocol() !== "v1") return
const before = serverSync().data.config.disabled_providers ?? [] const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID] const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next) serverSync().set("config", "disabled_providers", next)
@@ -224,37 +218,33 @@ export const SettingsProvidersV2: Component<{
)} )}
</For> </For>
<Show when={protocol() === "v1"}> <div class="settings-v2-provider-row" data-component="custom-provider-section">
<div class="settings-v2-provider-row" data-component="custom-provider-section"> <div class="settings-v2-provider-lead">
<div class="settings-v2-provider-lead"> <ProviderIcon
<ProviderIcon id="synthetic"
id="synthetic" width={PROVIDER_ICON_SIZE}
width={PROVIDER_ICON_SIZE} height={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE} class="settings-v2-provider-icon shrink-0"
class="settings-v2-provider-icon shrink-0" />
/> <div class="settings-v2-provider-copy">
<div class="settings-v2-provider-copy"> <div class="settings-v2-provider-main">
<div class="settings-v2-provider-main"> <span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span>
<span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span> <Tag>{language.t("settings.providers.tag.custom")}</Tag>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</div>
<p class="settings-v2-provider-description">
{language.t("settings.providers.custom.description")}
</p>
</div> </div>
<p class="settings-v2-provider-description">{language.t("settings.providers.custom.description")}</p>
</div> </div>
<ButtonV2
size="normal"
variant="neutral"
icon="plus"
onClick={() => {
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
}}
>
{language.t("common.connect")}
</ButtonV2>
</div> </div>
</Show> <ButtonV2
size="normal"
variant="neutral"
icon="plus"
onClick={() => {
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
}}
>
{language.t("common.connect")}
</ButtonV2>
</div>
</SettingsListV2> </SettingsListV2>
<button type="button" class="settings-v2-providers-view-all" onClick={() => connect()}> <button type="button" class="settings-v2-providers-view-all" onClick={() => connect()}>
@@ -133,10 +133,9 @@
} }
[data-slot="settings-v2-row-description"] { [data-slot="settings-v2-row-description"] {
margin-block: -3.5px;
font-size: 13px; font-size: 13px;
font-weight: 440; font-weight: 440;
line-height: 20px; line-height: 1;
color: var(--v2-text-text-muted); color: var(--v2-text-text-muted);
} }
@@ -271,10 +270,10 @@
} }
.settings-v2-provider-description { .settings-v2-provider-description {
margin-block: -3.5px; margin: 0;
font-size: 13px; font-size: 13px;
font-weight: 440; font-weight: 440;
line-height: 20px; line-height: 1;
color: var(--v2-text-text-muted); color: var(--v2-text-text-muted);
} }
@@ -373,64 +372,14 @@
} }
.settings-v2-models { .settings-v2-models {
gap: 12px; gap: 24px;
}
.settings-v2-models .settings-v2-section {
gap: 12px;
}
.settings-v2-models .settings-v2-section[data-expanded] {
padding-bottom: 12px;
} }
.settings-v2-models-group-header { .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; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding-bottom: 8px;
} }
.settings-v2-models .settings-v2-section-title { .settings-v2-models .settings-v2-section-title {
@@ -444,10 +393,6 @@
color: var(--v2-icon-icon-base); 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"] { .settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] {
margin-top: 0; margin-top: 0;
} }
@@ -16,7 +16,6 @@ import { type ServerHealth } from "@/utils/server-health"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useMcpToggle } from "@/context/mcp" import { useMcpToggle } from "@/context/mcp"
import { useServerProtocol } from "@/context/server-sdk"
const pluginEmptyMessage = (value: string, file: string): JSXElement => { const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file) const parts = value.split(file)
@@ -258,7 +257,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const language = useLanguage() const language = useLanguage()
const navigate = useNavigate() const navigate = useNavigate()
const settings = useSettings() const settings = useSettings()
const protocol = useServerProtocol()
const fail = (err: unknown) => { const fail = (err: unknown) => {
showToast({ showToast({
@@ -278,12 +276,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
dialogDead = true dialogDead = true
dialogRun += 1 dialogRun += 1
}) })
const sortedServers = createMemo(() => { const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
const list = settings.general.newLayoutDesigns()
? global.servers.list()
: global.servers.list().filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
return listServersByHealth(list, server.key, global.servers.health)
})
const toggleMcp = useMcpToggle() const toggleMcp = useMcpToggle()
const defaultServer = useDefaultServerKey(platform.getDefaultServer) const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
@@ -310,7 +303,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10"> <Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
{!settings.general.newLayoutDesigns() && ( {!settings.general.newLayoutDesigns() && (
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular"> <Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""} {global.servers.list().length > 0 ? `${global.servers.list().length} ` : ""}
{language.t("status.popover.tab.servers")} {language.t("status.popover.tab.servers")}
</Tabs.Trigger> </Tabs.Trigger>
)} )}
@@ -322,12 +315,10 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
{lspCount() > 0 ? `${lspCount()} ` : ""} {lspCount() > 0 ? `${lspCount()} ` : ""}
{language.t("status.popover.tab.lsp")} {language.t("status.popover.tab.lsp")}
</Tabs.Trigger> </Tabs.Trigger>
<Show when={protocol() === "v1"}> <Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular"> {pluginCount() > 0 ? `${pluginCount()} ` : ""}
{pluginCount() > 0 ? `${pluginCount()} ` : ""} {language.t("status.popover.tab.plugins")}
{language.t("status.popover.tab.plugins")} </Tabs.Trigger>
</Tabs.Trigger>
</Show>
</Tabs.List> </Tabs.List>
{!settings.general.newLayoutDesigns() && ( {!settings.general.newLayoutDesigns() && (
@@ -487,27 +478,25 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</div> </div>
</Tabs.Content> </Tabs.Content>
<Show when={protocol() === "v1"}> <Tabs.Content value="plugins">
<Tabs.Content value="plugins"> <div class="flex flex-col px-2 pb-2">
<div class="flex flex-col px-2 pb-2"> <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14"> <Show
<Show when={plugins().length > 0}
when={plugins().length > 0} fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>} >
> <For each={plugins()}>
<For each={plugins()}> {(plugin) => (
{(plugin) => ( <div class="flex items-center gap-2 w-full px-2 py-1">
<div class="flex items-center gap-2 w-full px-2 py-1"> <div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" /> <span class="text-14-regular text-text-base truncate">{plugin}</span>
<span class="text-14-regular text-text-base truncate">{plugin}</span> </div>
</div> )}
)} </For>
</For> </Show>
</Show>
</div>
</div> </div>
</Tabs.Content> </div>
</Show> </Tabs.Content>
</Tabs> </Tabs>
</div> </div>
) )
@@ -1,21 +1,11 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
hasNonBlockingServiceIssue,
hasServiceNeedingAttention,
serverStatusDotClass,
} from "./status-popover-indicator"
describe("serverStatusDotClass", () => { describe("serverStatusDotClass", () => {
test("uses the success token while the server and services are healthy", () => { test("uses the success token while the server and services are healthy", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base") expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
}) })
test("uses the session attention token when a service needs attention", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, attention: true, issue: true })).toBe(
"bg-v2-background-bg-accent",
)
})
test("uses the warning token for non-blocking issues while the server is online", () => { test("uses the warning token for non-blocking issues while the server is online", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base") expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
}) })
@@ -36,7 +26,7 @@ describe("hasNonBlockingServiceIssue", () => {
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true) expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true) expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true) expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false) expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false)
}) })
test("detects LSP failures that do not block chatting", () => { test("detects LSP failures that do not block chatting", () => {
@@ -44,15 +34,3 @@ describe("hasNonBlockingServiceIssue", () => {
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false) expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
}) })
}) })
describe("hasServiceNeedingAttention", () => {
test("detects MCP states that need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
})
test("ignores states that do not need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["failed"] })).toBe(false)
expect(hasServiceNeedingAttention({ mcp: ["connected", "pending", "disabled"] })).toBe(false)
})
})
@@ -1,29 +1,18 @@
import type { LspStatus } from "@opencode-ai/sdk/v2/client" import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client"
import type { McpServer } from "@opencode-ai/client/promise"
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
}
export function hasNonBlockingServiceIssue(input: { export function hasNonBlockingServiceIssue(input: {
mcp: Array<McpServer["status"]["status"]> mcp: Array<McpStatus["status"]>
lsp: Array<LspStatus["status"]> lsp: Array<LspStatus["status"]>
}) { }) {
return ( return (
input.mcp.some((status) => status !== "connected" && status !== "pending" && status !== "disabled") || input.mcp.some((status) => status !== "connected" && status !== "disabled") ||
input.lsp.some((status) => status === "error") input.lsp.some((status) => status === "error")
) )
} }
export function serverStatusDotClass(input: { export function serverStatusDotClass(input: { ready: boolean; serverHealth: boolean | undefined; issue: boolean }) {
ready: boolean
serverHealth: boolean | undefined
attention?: boolean
issue: boolean
}) {
if (input.serverHealth === false) return "bg-icon-critical-base" if (input.serverHealth === false) return "bg-icon-critical-base"
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base" if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
if (input.attention) return "bg-v2-background-bg-accent"
if (input.issue) return "bg-icon-warning-base" if (input.issue) return "bg-icon-warning-base"
if (input.serverHealth === true) return "bg-icon-success-base" if (input.serverHealth === true) return "bg-icon-success-base"
return "bg-border-weak-base" return "bg-border-weak-base"
+1 -19
View File
@@ -9,11 +9,7 @@ import { ServerConnection, useServer } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
hasNonBlockingServiceIssue,
hasServiceNeedingAttention,
serverStatusDotClass,
} from "./status-popover-indicator"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody }))) const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody }))) const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
@@ -26,11 +22,6 @@ export function StatusPopover() {
const [shown, setShown] = createSignal(false) const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const attention = createMemo(() =>
hasServiceNeedingAttention({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
}),
)
const issue = createMemo(() => const issue = createMemo(() =>
hasNonBlockingServiceIssue({ hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
@@ -58,7 +49,6 @@ export function StatusPopover() {
class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({ class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
ready: ready(), ready: ready(),
serverHealth: serverHealth(), serverHealth: serverHealth(),
attention: attention(),
issue: issue(), issue: issue(),
})}`} })}`}
/> />
@@ -95,11 +85,6 @@ function DirectoryStatusPopover() {
const [shown, setShown] = createSignal(false) const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const attention = createMemo(() =>
hasServiceNeedingAttention({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
}),
)
const issue = createMemo(() => const issue = createMemo(() =>
hasNonBlockingServiceIssue({ hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
@@ -110,7 +95,6 @@ function DirectoryStatusPopover() {
shown: shown(), shown: shown(),
ready: ready(), ready: ready(),
serverHealth: serverHealth(), serverHealth: serverHealth(),
attention: attention(),
issue: issue(), issue: issue(),
label: language.t("status.popover.trigger"), label: language.t("status.popover.trigger"),
onOpenChange: setShown, onOpenChange: setShown,
@@ -134,7 +118,6 @@ function ServerStatusPopover() {
shown: shown(), shown: shown(),
ready: serverHealth() !== undefined, ready: serverHealth() !== undefined,
serverHealth: serverHealth(), serverHealth: serverHealth(),
attention: false,
issue: false, issue: false,
label: language.t("status.popover.trigger"), label: language.t("status.popover.trigger"),
onOpenChange: setShown, onOpenChange: setShown,
@@ -152,7 +135,6 @@ type StatusPopoverState = {
shown: boolean shown: boolean
ready: boolean ready: boolean
serverHealth: boolean | undefined serverHealth: boolean | undefined
attention: boolean
issue: boolean issue: boolean
label: string label: string
onOpenChange: (value: boolean) => void onOpenChange: (value: boolean) => void
+28 -74
View File
@@ -178,6 +178,7 @@ export const Terminal = (props: TerminalProps) => {
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session. // Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
const connection = useServerSDK()().server const connection = useServerSDK()().server
const directory = sdk().directory const directory = sdk().directory
const client = sdk().client
const url = sdk().url const url = sdk().url
const auth = connection.http const auth = connection.http
const username = auth?.username ?? "opencode" const username = auth?.username ?? "opencode"
@@ -240,21 +241,10 @@ export const Terminal = (props: TerminalProps) => {
} }
} }
const pushSize = async (cols: number, rows: number) => { const pushSize = (cols: number, rows: number) => {
if ((await sdk().protocol) === "v1") { return client.pty
return sdk() .update({
.client.pty.update({
ptyID: id,
size: { cols, rows },
})
.catch((err) => {
debugTerminal("failed to sync terminal size", err)
})
}
return sdk()
.api.pty.update({
ptyID: id, ptyID: id,
location: { directory },
size: { cols, rows }, size: { cols, rows },
}) })
.catch((err) => { .catch((err) => {
@@ -302,12 +292,7 @@ export const Terminal = (props: TerminalProps) => {
const scheduleSize = (cols: number, rows: number) => { const scheduleSize = (cols: number, rows: number) => {
if (disposed) return if (disposed) return
if (lastSize?.cols === cols && lastSize?.rows === rows) { if (lastSize?.cols === cols && lastSize?.rows === rows) return
pendingSize = undefined
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
sizeTimer = undefined
return
}
pendingSize = { cols, rows } pendingSize = { cols, rows }
@@ -332,10 +317,8 @@ export const Terminal = (props: TerminalProps) => {
createEffect(() => { createEffect(() => {
const colors = terminalColors() const colors = terminalColors()
const mode = theme.mode() === "dark" ? "dark" : "light"
if (!term) return if (!term) return
setOptionIfSupported(term, "theme", colors) setOptionIfSupported(term, "theme", colors)
setOptionIfSupported(term, "colorScheme", mode)
}) })
createEffect(() => { createEffect(() => {
@@ -382,11 +365,7 @@ export const Terminal = (props: TerminalProps) => {
event.preventDefault() event.preventDefault()
event.stopImmediatePropagation() event.stopImmediatePropagation()
if (URL.canParse(text) && new URL(text).protocol === "file:" && platform.openLocalFile) { platform.openLink(text)
platform.openLocalFile(text)
return
}
platform.openExternal(text)
} }
onMount(() => { onMount(() => {
@@ -417,7 +396,6 @@ export const Terminal = (props: TerminalProps) => {
} }
_ghostty = g _ghostty = g
term = t term = t
setOptionIfSupported(t, "colorScheme", theme.mode() === "dark" ? "dark" : "light")
output = terminalWriter((data, done) => output = terminalWriter((data, done) =>
t.write(data, () => { t.write(data, () => {
done?.() done?.()
@@ -536,54 +514,34 @@ export const Terminal = (props: TerminalProps) => {
local.onConnectError?.(err) local.onConnectError?.(err)
} }
const gone = async () => { const gone = () =>
if ((await sdk().protocol) === "v1") { client.pty
return sdk() .get({ ptyID: id }, { throwOnError: false })
.client.pty.get({ ptyID: id }, { throwOnError: false }) .then((result) => result.response.status === 404)
.then((result) => result.response.status === 404)
.catch((err) => {
debugTerminal("failed to inspect terminal session", err)
return false
})
}
return sdk()
.api.pty.get({ ptyID: id, location: { directory } })
.then((result) => result.data.status === "exited")
.catch((err) => { .catch((err) => {
if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true
debugTerminal("failed to inspect terminal session", err) debugTerminal("failed to inspect terminal session", err)
return false return false
}) })
}
const connectToken = async () => { const connectToken = async () => {
if ((await sdk().protocol) === "v1") { const result = await client.pty
const result = await sdk() .connectToken(
.client.pty.connectToken( { ptyID: id, directory },
{ ptyID: id, directory }, {
{ throwOnError: false,
throwOnError: false, headers: { "x-opencode-ticket": "1" },
headers: { "x-opencode-ticket": "1" }, },
}, )
) .catch((err: unknown) => {
.catch((err: unknown) => { if (err instanceof Error && err.message.includes("Request is not supported")) return
if (err instanceof Error && err.message.includes("Request is not supported")) return throw err
throw err })
}) if (!result) return
if (!result) return if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket if (result.response.status === 404 || result.response.status === 405) return
if (result.response.status === 404 || result.response.status === 405) return if (result.response.status === 403)
if (result.response.status === 403) throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") throw new Error(`PTY connect ticket failed with ${result.response.status}`)
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
}
// return sdk()
// .api.pty.connectToken({
// ptyID: id,
// location: { directory },
// "x-opencode-ticket": "1",
// })
// .then((result) => result.data.ticket)
} }
const retry = (err: unknown) => { const retry = (err: unknown) => {
@@ -613,14 +571,11 @@ export const Terminal = (props: TerminalProps) => {
fail(err) fail(err)
return undefined return undefined
}) })
const protocol = await sdk().protocol
// if (protocol === "v2" && !ticket) return
if (once.value) return if (once.value) return
if (disposed) return if (disposed) return
const socket = new WebSocket( const socket = new WebSocket(
terminalWebSocketURL({ terminalWebSocketURL({
protocol,
url, url,
id, id,
directory, directory,
@@ -640,7 +595,6 @@ export const Terminal = (props: TerminalProps) => {
tries = 0 tries = 0
local.onConnect?.() local.onConnect?.()
scheduleSize(t.cols, t.rows) scheduleSize(t.cols, t.rows)
if (t.getMode(2031)) t.write("\x1b[?996n")
} }
const handleMessage = (event: MessageEvent) => { const handleMessage = (event: MessageEvent) => {
@@ -12,6 +12,6 @@ export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTM
if (typeof ref === "function") ref(element) if (typeof ref === "function") ref(element)
} }
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, pending: boolean) { export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
return !dragging && !editing && !pending return !dragging && !editing && !committing
} }
@@ -9,86 +9,53 @@
justify-content: center; 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"] { [data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
display: none; display: none;
} }
[data-titlebar-tab-list] { [data-titlebar-tab-list] {
gap: 6px; gap: 13.5px;
} }
[data-titlebar-tab-slot] { [data-titlebar-tab-slot] {
--tab-separator: var(--v2-background-bg-layer-03);
position: relative; 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 { [data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before {
content: ""; content: "";
position: absolute; position: absolute;
top: 8px; top: 8px;
left: -3.75px; left: -6.75px;
width: 1.5px; width: 1.5px;
height: 12px; height: 12px;
border-radius: 9999px; 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 { [data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before {
display: none; display: none;
} }
[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover)::before, [data-titlebar-tab] [data-slot="tab-close"]::before {
[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover) + [data-titlebar-tab-slot]::before { content: "";
position: absolute;
top: 0;
height: 28px;
width: 16px;
pointer-events: none;
background: linear-gradient(to right, transparent, var(--tab-bg));
display: none; display: none;
} }
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] { [data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before {
--tab-title-fade-offset: 4px; display: block;
-webkit-mask-image: linear-gradient( right: 0;
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"]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-titlebar-tab][data-title-overflow="true"]:hover:not([data-editing="true"]) [data-slot="tab-close"]::before,
[data-slot="tab-link"] { [data-titlebar-tab][data-title-overflow="true"][data-active="true"]:not([data-editing="true"])
--tab-title-fade-offset: 24px; [data-slot="tab-close"]::before {
right: 100%;
} }
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"]) [data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
+112 -87
View File
@@ -1,13 +1,14 @@
import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js" import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMutation } from "@tanstack/solid-query"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/server" import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers" import { displayName, projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { showToast } from "@/utils/toast"
import type { Session } from "@opencode-ai/sdk/v2" import type { Session } from "@opencode-ai/sdk/v2"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture" import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import { TabPreviewPopover } from "./titlebar-tab-popover" import { TabPreviewPopover } from "./titlebar-tab-popover"
@@ -22,7 +23,8 @@ export function TabNavItem(props: {
server: ServerConnection.Key server: ServerConnection.Key
session: () => Session | undefined session: () => Session | undefined
fallbackTitle?: string fallbackTitle?: string
onRename: (title: string) => Promise<void> onTitleChange?: (title: string) => void
onTitleChangeFailed?: (title: string) => void
onClose: () => void onClose: () => void
onNavigate: () => void onNavigate: () => void
active?: boolean active?: boolean
@@ -32,12 +34,13 @@ export function TabNavItem(props: {
pressed?: boolean pressed?: boolean
hidden?: boolean hidden?: boolean
}) { }) {
const language = useLanguage()
const [editing, setEditing] = createSignal(false) const [editing, setEditing] = createSignal(false)
const [titleOverflowing, setTitleOverflowing] = createSignal(false) const [titleOverflowing, setTitleOverflowing] = createSignal(false)
let tabRoot!: HTMLDivElement let tabRoot!: HTMLDivElement
let titleEl!: HTMLSpanElement let titleEl!: HTMLSpanElement
let committing = false
let measureFrame: number | undefined let measureFrame: number | undefined
const rename = createMutation(() => ({ mutationFn: props.onRename }))
const closeTab = (event: MouseEvent) => { const closeTab = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
@@ -113,20 +116,41 @@ export function TabNavItem(props: {
selection?.addRange(range) selection?.addRange(range)
} }
const rename = async (title: string) => {
const ctx = serverCtx()
const session = props.session()
if (!ctx || !session) return
const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true })
await client.session.update({ sessionID: session.id, title })
}
const closeRename = async (save: boolean) => { const closeRename = async (save: boolean) => {
if (rename.isPending || !editing()) return if (committing || !editing()) return
committing = true
const original = props.session()?.title ?? "" const original = props.session()?.title ?? ""
const next = (titleEl.textContent ?? "").trim() const next = (titleEl.textContent ?? "").trim()
titleEl.scrollLeft = 0 titleEl.scrollLeft = 0
if (save && next && next !== original) props.onTitleChange?.(next)
setEditing(false) setEditing(false)
if (!save || !next || next === original) { if (!save || !next || next === original) {
committing = false
return return
} }
await rename.mutateAsync(next) try {
await rename(next)
} catch (err) {
props.onTitleChangeFailed?.(original)
showToast({
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : undefined,
})
}
committing = false
} }
createEffect(() => { createEffect(() => {
@@ -140,7 +164,7 @@ export function TabNavItem(props: {
const openRename = (event: MouseEvent) => { const openRename = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return if (!canOpenTabRename(props.dragging, editing(), committing)) return
const session = props.session() const session = props.session()
if (!session) return if (!session) return
titleEl.textContent = session.title titleEl.textContent = session.title
@@ -180,11 +204,11 @@ export function TabNavItem(props: {
data-slot="titlebar-tab-item" data-slot="titlebar-tab-item"
data-title-overflow={titleOverflowing()} data-title-overflow={titleOverflowing()}
data-editing={editing()} 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 }} classList={{ invisible: props.hidden }}
data-active={props.active} data-active={props.active}
data-dragging={props.dragging} data-dragging={props.dragging}
data-state={props.active || props.pressed ? "pressed" : undefined} data-pressed={props.pressed}
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return if (event.button !== MIDDLE_MOUSE_BUTTON) return
event.preventDefault() event.preventDefault()
@@ -195,89 +219,90 @@ export function TabNavItem(props: {
closeTab(event) closeTab(event)
}} }}
> >
<a <Show when={title() !== undefined}>
data-slot="tab-link" <a
data-titlebar-tab-link data-slot="tab-link"
href={props.href} data-titlebar-tab-link
draggable={false} href={props.href}
onDragStart={(event) => { draggable={false}
event.preventDefault() onDragStart={(event) => {
event.stopPropagation()
}}
onMouseDown={(event) => {
// Navigate on mousedown to shave the press-release delay off tab switches.
if (event.button !== 0) return
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
onClick={(event) => {
event.preventDefault()
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (event.detail > 0) return
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<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" />
}
>
{(session) => (
<SessionTabAvatar
project={project()}
directory={session.directory}
sessionId={session.id}
server={props.server}
/>
)}
</Show>
</span>
<span
ref={(el) => {
titleEl = el
titleEl.textContent = title() ?? ""
}}
data-slot="tab-title"
data-titlebar-tab-title
class="min-w-0 flex-1 outline-none leading-4"
classList={{
"overflow-hidden text-clip whitespace-nowrap": !editing(),
"select-text": editing(),
}}
contenteditable={editing() ? true : undefined}
onDblClick={openRename}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void closeRename(true)
return
}
if (event.key !== "Escape") return
event.preventDefault() event.preventDefault()
titleEl.textContent = props.session()?.title ?? ""
void closeRename(false)
}}
onBlur={() => void closeRename(true)}
onPointerDown={(event) => {
if (!editing()) return
event.stopPropagation() event.stopPropagation()
}} }}
onMouseDown={(event) => {
// Navigate on mousedown to shave the press-release delay off tab switches.
if (event.button !== 0) return
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
onClick={(event) => { onClick={(event) => {
if (!editing()) return
event.preventDefault() event.preventDefault()
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (event.detail > 0) return
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}} }}
/> class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
</a> >
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
<Show
when={props.session()}
fallback={
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
}
>
{(session) => (
<SessionTabAvatar
project={project()}
directory={session().directory}
sessionId={session().id}
server={props.server}
/>
)}
</Show>
</span>
<span
ref={(el) => {
titleEl = el
titleEl.textContent = title() ?? ""
}}
data-slot="tab-title"
data-titlebar-tab-title
class="min-w-0 flex-1 outline-none leading-4"
classList={{
"overflow-hidden text-clip whitespace-nowrap": !editing(),
"select-text": editing(),
}}
contenteditable={editing() ? true : undefined}
onDblClick={openRename}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void closeRename(true)
return
}
if (event.key !== "Escape") return
event.preventDefault()
titleEl.textContent = props.session()?.title ?? ""
void closeRename(false)
}}
onBlur={() => void closeRename(true)}
onPointerDown={(event) => {
if (!editing()) return
event.stopPropagation()
}}
onClick={(event) => {
if (!editing()) return
event.preventDefault()
}}
/>
</a>
</Show>
<div data-slot="tab-close"> <div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<IconButtonV2 <IconButtonV2
size="small" size="small"
variant="ghost-muted" variant="ghost-muted"
@@ -335,8 +360,8 @@ export function DraftTabItem(props: {
data-slot="titlebar-tab-item" data-slot="titlebar-tab-item"
data-active={props.active} data-active={props.active}
data-dragging={props.dragging} data-dragging={props.dragging}
data-state={props.active || props.pressed ? "pressed" : undefined} 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] px-1.5 [container-type:inline-size] whitespace-nowrap" 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 }} classList={{ invisible: props.hidden }}
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return if (event.button !== MIDDLE_MOUSE_BUTTON) return
@@ -382,7 +407,7 @@ export function DraftTabItem(props: {
{props.title} {props.title}
</span> </span>
</a> </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 <IconButtonV2
size="small" size="small"
variant="ghost-muted" variant="ghost-muted"
@@ -1,23 +0,0 @@
import { describe, expect, test } from "bun:test"
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
describe("adjacentTabKey", () => {
test("follows the visible left-to-right order", () => {
expect(adjacentTabKey(["c", "a", "b"], "c", 1)).toBe("a")
expect(adjacentTabKey(["c", "a", "b"], "a", -1)).toBe("c")
})
test("skips tabs omitted from the visible order", () => {
expect(adjacentTabKey(["a", "c"], "a", 1)).toBe("c")
expect(adjacentTabKey(["a", "c"], "c", 1)).toBe("a")
})
})
test("merges reordered visible tabs around hidden tabs", () => {
expect(mergeVisibleTabOrder(["a", "hidden", "b", "c"], ["a", "b", "c"], ["c", "a", "b"])).toEqual([
"c",
"hidden",
"a",
"b",
])
})
@@ -1,12 +0,0 @@
export function adjacentTabKey(order: string[], current: string | undefined, offset: -1 | 1) {
if (!current || order.length === 0) return
const index = order.indexOf(current)
if (index === -1) return
return order[(index + offset + order.length) % order.length]
}
export function mergeVisibleTabOrder(all: string[], current: string[], next: string[]) {
const visible = new Set(current)
const reordered = next.values()
return all.map((key) => (visible.has(key) ? (reordered.next().value ?? key) : key))
}
@@ -1,5 +1,4 @@
import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount, Show } from "solid-js" import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable" import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
@@ -16,10 +15,7 @@ import { useCommand } from "@/context/command"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { createTabPromptState } from "@/context/prompt" import { createTabPromptState } from "@/context/prompt"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { showToast } from "@/utils/toast"
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture" import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
import type { Session } from "@opencode-ai/sdk/v2"
function SessionTabSlot(props: { function SessionTabSlot(props: {
tab: SessionTab tab: SessionTab
@@ -27,12 +23,12 @@ function SessionTabSlot(props: {
index: () => number index: () => number
active: () => boolean active: () => boolean
forceTruncate: boolean forceTruncate: boolean
session: () => Session | undefined serverCtx: () => ServerCtx | undefined
fallbackTitle?: string
onRename: (title: string) => Promise<void>
onNavigate: (element: HTMLDivElement) => void onNavigate: (element: HTMLDivElement) => void
onClose: () => void onClose: () => void
}) { }) {
const tabs = useTabs()
const language = useLanguage()
const sortable = useSortable({ const sortable = useSortable({
get id() { get id() {
return props.id return props.id
@@ -42,47 +38,6 @@ function SessionTabSlot(props: {
}, },
}) })
let ref!: HTMLDivElement let ref!: HTMLDivElement
return (
<div
ref={sortable.ref}
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
>
<TabNavItem
ref={(el) => {
ref = el
}}
href={tabHref(props.tab)}
server={props.tab.server}
session={props.session}
fallbackTitle={props.fallbackTitle}
onRename={props.onRename}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active()}
forceTruncate={props.forceTruncate}
dragging={sortable.isDragSource()}
/>
</div>
)
}
function SessionTabEntry(props: {
tab: SessionTab
id: string
index: () => number
active: () => boolean
forceTruncate: boolean
serverCtx: () => ServerCtx | undefined
onVisibleChange: (visible: boolean) => void
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
}) {
const tabs = useTabs()
const language = useLanguage()
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null) const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId)) const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
const persisted = createMemo(() => tabs.info[props.id]) const persisted = createMemo(() => tabs.info[props.id])
@@ -95,30 +50,8 @@ function SessionTabEntry(props: {
) )
const session = createMemo(() => cachedSession() ?? loadedSession()) const session = createMemo(() => cachedSession() ?? loadedSession())
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session()) const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title)
let prefetched = false let prefetched = false
const rename = async (title: string) => {
const value = session()
const ctx = props.serverCtx()
if (!value || !ctx) return
ctx.sync.session.remember({ ...value, title })
try {
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
} catch (err) {
const current = session()
const currentCtx = props.serverCtx()
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
showToast({
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : undefined,
})
}
}
createEffect(() => props.onVisibleChange(visible()))
createEffect(() => { createEffect(() => {
const ctx = props.serverCtx() const ctx = props.serverCtx()
const value = session() const value = session()
@@ -150,20 +83,39 @@ function SessionTabEntry(props: {
}) })
return ( return (
<Show when={visible()}> <div
<SessionTabSlot ref={sortable.ref}
tab={props.tab} data-titlebar-tab-slot
id={props.id} data-tab-key={props.id}
index={props.index} data-active={props.active()}
active={props.active} class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
forceTruncate={props.forceTruncate} classList={{ hidden: !session() && !missingSession() && !persisted()?.title }}
>
<TabNavItem
ref={(el) => {
ref = el
}}
href={tabHref(props.tab)}
server={props.tab.server}
session={session} session={session}
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)} fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
onRename={rename} onTitleChange={(title) => {
onNavigate={props.onNavigate} const value = session()
const ctx = props.serverCtx()
if (value && ctx) ctx.sync.session.remember({ ...value, title })
}}
onTitleChangeFailed={(title) => {
const value = session()
const ctx = props.serverCtx()
if (value && ctx) ctx.sync.session.remember({ ...value, title })
}}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose} onClose={props.onClose}
active={props.active()}
forceTruncate={props.forceTruncate}
dragging={sortable.isDragSource()}
/> />
</Show> </div>
) )
} }
@@ -220,39 +172,11 @@ export function TitlebarTabStrip(props: {
}) { }) {
const global = useGlobal() const global = useGlobal()
const language = useLanguage() const language = useLanguage()
const command = useCommand()
let scrollRef!: HTMLDivElement let scrollRef!: HTMLDivElement
let listRef!: HTMLDivElement let listRef!: HTMLDivElement
let resizeFrame: number | undefined let resizeFrame: number | undefined
const [visibility, setVisibility] = createStore<Record<string, boolean>>({})
const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)]))
const visibleTabIds = () => visibleTabs().map(tabKey)
command.register("titlebar-tab-cycle", () => [ const tabIds = () => props.tabs.map(tabKey)
{
id: `tab.prev`,
category: "tab",
title: "",
keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
hidden: true,
onSelect: () => selectAdjacentTab(-1),
},
{
id: `tab.next`,
category: "tab",
title: "",
keybind: `mod+option+ArrowRight,ctrl+tab`,
hidden: true,
onSelect: () => selectAdjacentTab(1),
},
])
function selectAdjacentTab(offset: -1 | 1) {
const current = props.currentTab()
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
const next = props.tabs.find((tab) => tabKey(tab) === key)
if (next) props.onNavigate(next)
}
function refreshOverflow() { function refreshOverflow() {
if (!scrollRef) return if (!scrollRef) return
@@ -280,7 +204,7 @@ export function TitlebarTabStrip(props: {
createEffect(() => { createEffect(() => {
props.tabs.length props.tabs.length
visibleTabIds() tabIds()
refreshOverflow() refreshOverflow()
}) })
@@ -316,29 +240,22 @@ export function TitlebarTabStrip(props: {
props.onNavigate(tab, tabEl ?? undefined) props.onNavigate(tab, tabEl ?? undefined)
}} }}
onDragEnd={(event) => { onDragEnd={(event) => {
const current = visibleTabIds() const current = tabIds()
const source = event.operation.source const source = event.operation.source
if (event.canceled || !isSortable(source)) return if (event.canceled || !isSortable(source)) return
const { initialIndex, index } = source const { initialIndex, index } = source
if (initialIndex !== index) { if (initialIndex !== index) {
props.onReorder( props.onReorder(arrayMove(current, source.initialIndex, source.index))
mergeVisibleTabOrder(
props.tabs.map(tabKey),
current,
arrayMove(current, source.initialIndex, source.index),
),
)
} }
}} }}
> >
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}> <div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
<For each={props.tabs}> <For each={props.tabs}>
{(tab) => { {(tab, index) => {
const id = tabKey(tab) const id = tabKey(tab)
let ref!: HTMLDivElement let ref!: HTMLDivElement
const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id) useTabShortcut(index, () => props.onNavigate(tab, ref))
useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref))
const serverCtx = createMemo(() => { const serverCtx = createMemo(() => {
if (tab.type !== "session") return if (tab.type !== "session") return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server) const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
@@ -347,14 +264,13 @@ export function TitlebarTabStrip(props: {
if (tab.type === "session") { if (tab.type === "session") {
return ( return (
<SessionTabEntry <SessionTabSlot
tab={tab} tab={tab}
id={id} id={id}
index={visibleIndex} index={index}
active={() => props.currentTab() === tab} active={() => props.currentTab() === tab}
forceTruncate={props.forceTruncate} forceTruncate={props.forceTruncate}
serverCtx={serverCtx} serverCtx={serverCtx}
onVisibleChange={(visible) => setVisibility(id, visible)}
onNavigate={(element) => { onNavigate={(element) => {
ref = element ref = element
props.onNavigate(tab, element) props.onNavigate(tab, element)
@@ -368,7 +284,7 @@ export function TitlebarTabStrip(props: {
<DraftTabSlot <DraftTabSlot
tab={tab} tab={tab}
id={id} id={id}
index={visibleIndex} index={index}
active={() => props.currentTab() === tab} active={() => props.currentTab() === tab}
title={language.t("command.session.new")} title={language.t("command.session.new")}
onNavigate={(element) => { onNavigate={(element) => {
@@ -402,7 +318,7 @@ function useTabShortcut(index: () => number, onSelect: () => void) {
command.register(() => { command.register(() => {
const number = index() + 1 const number = index() + 1
if (number < 1 || number > 9) return [] if (number > 9) return []
return [ return [
{ {
id: `tab.${number}`, id: `tab.${number}`,
+155 -53
View File
@@ -5,6 +5,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
@@ -27,13 +28,32 @@ import { tabKey, useTabs } from "@/context/tabs"
import type { PromptSession } from "@/context/prompt" import type { PromptSession } from "@/context/prompt"
import "./titlebar.css" import "./titlebar.css"
import { newTabTooltipKeybind } from "./command-tooltip-keybind" import { newTabTooltipKeybind } from "./command-tooltip-keybind"
import { normalizeSessionInfo } from "@/utils/session"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
toggleMaximize?: () => Promise<void>
}
type TauriThemeWindow = {
setTheme?: (theme?: "light" | "dark" | null) => Promise<void>
}
type TauriApi = {
window?: {
getCurrentWindow?: () => TauriDesktopWindow
}
webviewWindow?: {
getCurrentWebviewWindow?: () => TauriThemeWindow
}
}
const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__
const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.()
const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.()
const legacyTitlebarHeight = 40 const legacyTitlebarHeight = 40
const v2TitlebarHeight = 36 const v2TitlebarHeight = 36
const minTitlebarZoom = 0.25 const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each. const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const macTrafficLightsBaseWidth = 84
export type TitlebarUpdate = { export type TitlebarUpdate = {
version: () => string | undefined version: () => string | undefined
@@ -47,12 +67,13 @@ export function useTitlebarRightMount() {
return mount return mount
} }
export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) { export function Titlebar(props: { update?: TitlebarUpdate }) {
const layout = useLayout() const layout = useLayout()
const platform = usePlatform() const platform = usePlatform()
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
const theme = useTheme()
const server = useServer() const server = useServer()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
@@ -63,9 +84,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const electronWindows = createMemo(() => windows() && !tauriApi())
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const web = createMemo(() => platform.platform === "web") const web = createMemo(() => platform.platform === "web")
const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.())
const zoom = () => platform.webviewZoom?.() ?? 1 const zoom = () => platform.webviewZoom?.() ?? 1
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom()) const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1) const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
@@ -154,6 +175,56 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
}, },
]) ])
const getWin = () => {
if (platform.platform !== "desktop") return
return currentDesktopWindow()
}
createEffect(() => {
if (platform.platform !== "desktop") return
const scheme = theme.colorScheme()
const value = scheme === "system" ? null : scheme
const win = currentThemeWindow()
if (!win?.setTheme) return
void win.setTheme(value).catch(() => undefined)
})
const interactive = (target: EventTarget | null) => {
if (!(target instanceof Element)) return false
const selector =
"button, a, input, textarea, select, option, [role='button'], [role='menuitem'], [contenteditable='true'], [contenteditable='']"
return !!target.closest(selector)
}
const drag = (e: MouseEvent) => {
if (platform.platform !== "desktop") return
if (e.buttons !== 1) return
if (interactive(e.target)) return
const win = getWin()
if (!win?.startDragging) return
e.preventDefault()
void win.startDragging().catch(() => undefined)
}
const maximize = (e: MouseEvent) => {
if (platform.platform !== "desktop") return
if (interactive(e.target)) return
if (e.target instanceof Element && e.target.closest("[data-tauri-decorum-tb]")) return
const win = getWin()
if (!win?.toggleMaximize) return
e.preventDefault()
void win.toggleMaximize().catch(() => undefined)
}
return ( return (
<header <header
data-slot={useV2Titlebar() ? "titlebar-v2" : undefined} data-slot={useV2Titlebar() ? "titlebar-v2" : undefined}
@@ -166,12 +237,16 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
style={{ style={{
"min-height": minHeight(), "min-height": minHeight(),
// Keep native macOS traffic lights clear even when the desktop window is narrow. // Keep native macOS traffic lights clear even when the desktop window is narrow.
"padding-left": macTrafficLights() ? `${macTrafficLightsBaseWidth / zoom()}px` : 0, "padding-left": mac() ? `${84 / zoom()}px` : 0,
width: windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined, width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
"max-width": windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined, "max-width": electronWindows()
"align-self": windows() ? "flex-start" : undefined, ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
: undefined,
"align-self": electronWindows() ? "flex-start" : undefined,
}} }}
data-tauri-drag-region data-tauri-drag-region
onMouseDown={drag}
onDblClick={maximize}
> >
<Switch> <Switch>
<Match when={useV2Titlebar()}> <Match when={useV2Titlebar()}>
@@ -192,9 +267,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
}, },
({ route, sdk }) => ({ route, sdk }) =>
sdk.api.session sdk.client.session
.get({ sessionID: route.sessionId }) .get({ sessionID: route.sessionId })
.then(normalizeSessionInfo) .then((x) => x.data)
.catch(() => {}), .catch(() => {}),
) )
@@ -338,6 +413,40 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
keybind: "mod+shift+t", keybind: "mod+shift+t",
onSelect: () => tabsStoreActions.reopenClosedTab(), onSelect: () => tabsStoreActions.reopenClosedTab(),
}, },
{
id: `tab.prev`,
category: "tab",
title: "",
keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
hidden: true,
onSelect: () => {
let index = tabsStore.findIndex((tab) => tab === currentTab())
if (index === -1) return
index -= 1
if (index === -1) index = tabsStore.length - 1
const next = tabsStore[index]
if (next) tabs.select(next)
},
},
{
id: `tab.next`,
category: "tab",
title: "",
keybind: `mod+option+ArrowRight,ctrl+tab`,
hidden: true,
onSelect: () => {
let index = tabsStore.findIndex((tab) => tab === currentTab())
if (index === -1) return
index += 1
if (index === tabsStore.length) index = 0
const next = tabsStore[index]
if (next) tabs.select(next)
},
},
].filter((v) => v !== undefined) ].filter((v) => v !== undefined)
}) })
@@ -349,11 +458,11 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
classList={{ classList={{
"pt-2": !bottom(), "pt-2": !bottom(),
"pb-2": bottom(), "pb-2": bottom(),
"md:pl-2": macTrafficLights(), "md:pl-2": mac(),
"md:pl-4": !macTrafficLights(), "md:pl-4": !mac(),
}} }}
> >
<ChannelIndicator debugTools={props.debugTools} /> <ChannelIndicator />
<Show when={windows() || linux()}> <Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} variant="v2" /> <WindowsAppMenu command={command} platform={platform} variant="v2" />
</Show> </Show>
@@ -395,27 +504,32 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
}} }}
onReorder={(keys) => tabsStoreActions.reorder(keys)} onReorder={(keys) => tabsStoreActions.reorder(keys)}
/> />
<TooltipV2 <Show when={!creating()}>
placement="bottom" <TooltipV2
value={ placement="bottom"
<> value={
{language.t("command.session.new")} <>
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" /> {language.t("command.session.new")}
</> <KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
} </>
> }
<IconButtonV2 >
type="button" <IconButtonV2
variant="ghost-muted" type="button"
size="large" variant="ghost-muted"
class="shrink-0" size="large"
icon={<IconV2 name="plus" />} class="shrink-0"
onClick={openNewTab} icon={<IconV2 name="plus" />}
aria-label={language.t("command.session.new")} onClick={openNewTab}
/> aria-label={language.t("command.session.new")}
</TooltipV2> />
</TooltipV2>
</Show>
<div class="flex-1" /> <div class="flex-1" />
<TitlebarV2Right state={v2RightState()} /> <TitlebarV2Right state={v2RightState()} />
<Show when={windows() && !electronWindows()}>
<div data-tauri-decorum-tb class="flex flex-row" />
</Show>
</div> </div>
) )
}} }}
@@ -428,13 +542,14 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
<div <div
classList={{ classList={{
"flex items-center min-w-0": true, "flex items-center min-w-0": true,
"pl-2": !macTrafficLights(), "pl-2": !mac(),
}} }}
> >
<Show when={windows() || linux()}> <Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} /> <WindowsAppMenu command={command} platform={platform} />
</Show> </Show>
<Show when={mac()}> <Show when={mac()}>
{/*<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />*/}
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center"> <div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
<IconButton <IconButton
icon="menu" icon="menu"
@@ -545,9 +660,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
</div> </div>
</Show> </Show>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" /> <div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<ChannelIndicator />
</div> </div>
</div> </div>
<ChannelIndicator debugTools={props.debugTools} />
</div> </div>
</div> </div>
@@ -564,10 +679,12 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
"pr-2": !windows(), "pr-2": !windows(),
}} }}
data-tauri-drag-region data-tauri-drag-region
onMouseDown={drag}
> >
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" /> <div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}> <Show when={windows()}>
<div class="shrink-0" style={{ width: windowsControlsWidth() }} /> {!tauriApi() && <div class="shrink-0" style={{ width: windowsControlsWidth() }} />}
<div data-tauri-decorum-tb class="flex flex-row" />
</Show> </Show>
</div> </div>
</div> </div>
@@ -630,27 +747,12 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
) )
} }
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) { function ChannelIndicator() {
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
if (channel === "dev" && props.debugTools) {
return (
<button
type="button"
class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono cursor-pointer"
onClick={props.debugTools.toggle}
aria-label="Toggle debug tools"
aria-pressed={props.debugTools.visible}
>
DEV
</button>
)
}
return ( return (
<> <>
{["beta", "dev"].includes(channel) && ( {["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono"> <div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{channel.toUpperCase()} {import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
</div> </div>
)} )}
</> </>
@@ -43,7 +43,7 @@ export function WindowsAppMenu(props: {
runAction(entry.action) runAction(entry.action)
return return
} }
if (entry.href) props.platform.openExternal(entry.href) if (entry.href) props.platform.openLink(entry.href)
} }
return ( return (
+7 -22
View File
@@ -1,11 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { import { commandPaletteOptions, resolveKeybindOption, upsertCommandRegistration, type CommandOption } from "./command"
activeCommandRegistrations,
addCommandRegistration,
commandPaletteOptions,
resolveKeybindOption,
type CommandOption,
} from "./command"
const paletteOptions: CommandOption[] = [ const paletteOptions: CommandOption[] = [
{ id: "settings.open", title: "Open settings" }, { id: "settings.open", title: "Open settings" },
@@ -21,31 +15,22 @@ describe("commandPaletteOptions", () => {
}) })
}) })
describe("command registrations", () => { describe("upsertCommandRegistration", () => {
test("shadows keyed registrations while retaining the previous owner", () => { test("replaces keyed registrations", () => {
const one = () => [{ id: "one", title: "One" }] const one = () => [{ id: "one", title: "One" }]
const two = () => [{ id: "two", title: "Two" }] const two = () => [{ id: "two", title: "Two" }]
const registrations = addCommandRegistration([{ key: "layout", options: one }], { const next = upsertCommandRegistration([{ key: "layout", options: one }], { key: "layout", options: two })
key: "layout",
options: two,
})
const active = activeCommandRegistrations(registrations)
expect(registrations).toHaveLength(2) expect(next).toHaveLength(1)
expect(active).toHaveLength(1) expect(next[0]?.options).toBe(two)
expect(active[0]?.options).toBe(two)
const restored = activeCommandRegistrations(registrations.filter((entry) => entry.options !== two))
expect(restored).toHaveLength(1)
expect(restored[0]?.options).toBe(one)
}) })
test("keeps unkeyed registrations additive", () => { test("keeps unkeyed registrations additive", () => {
const one = () => [{ id: "one", title: "One" }] const one = () => [{ id: "one", title: "One" }]
const two = () => [{ id: "two", title: "Two" }] const two = () => [{ id: "two", title: "Two" }]
const next = activeCommandRegistrations(addCommandRegistration([{ options: one }], { options: two })) const next = upsertCommandRegistration([{ options: one }], { options: two })
expect(next).toHaveLength(2) expect(next).toHaveLength(2)
expect(next[0]?.options).toBe(two) expect(next[0]?.options).toBe(two)
+5 -14
View File
@@ -114,18 +114,9 @@ export type CommandRegistration = {
options: Accessor<CommandOption[]> options: Accessor<CommandOption[]>
} }
export function addCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) { export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) {
return [entry, ...registrations] if (entry.key === undefined) return [entry, ...registrations]
} return [entry, ...registrations.filter((x) => x.key !== entry.key)]
export function activeCommandRegistrations(registrations: CommandRegistration[]) {
const keys = new Set<string>()
return registrations.filter((entry) => {
if (entry.key === undefined) return true
if (keys.has(entry.key)) return false
keys.add(entry.key)
return true
})
} }
export function parseKeybind(config: string): Keybind[] { export function parseKeybind(config: string): Keybind[] {
@@ -290,7 +281,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const seen = new Set<string>() const seen = new Set<string>()
const all: CommandOption[] = [] const all: CommandOption[] = []
for (const reg of activeCommandRegistrations(store.registrations)) { for (const reg of store.registrations) {
for (const opt of reg.options()) { for (const opt of reg.options()) {
if (seen.has(opt.id)) { if (seen.has(opt.id)) {
if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) { if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
@@ -434,7 +425,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
key: id, key: id,
options, options,
} }
setStore("registrations", (arr) => addCommandRegistration(arr, entry)) setStore("registrations", (arr) => upsertCommandRegistration(arr, entry))
onCleanup(() => { onCleanup(() => {
setStore("registrations", (arr) => arr.filter((x) => x !== entry)) setStore("registrations", (arr) => arr.filter((x) => x !== entry))
}) })
+5 -7
View File
@@ -5,7 +5,6 @@ import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
import type { createServerSdkContext } from "./server-sdk" import type { createServerSdkContext } from "./server-sdk"
import type { createServerSyncContextInner } from "./server-sync" import type { createServerSyncContextInner } from "./server-sync"
import type { State } from "./global-sync/types" import type { State } from "./global-sync/types"
import { normalizeSessionInfo } from "@/utils/session"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const sessionFields = new Set([ const sessionFields = new Set([
@@ -16,7 +15,6 @@ const sessionFields = new Set([
"permission", "permission",
"question", "question",
"message", "message",
"session_message",
"part", "part",
"part_text_accum_delta", "part_text_accum_delta",
]) ])
@@ -116,6 +114,7 @@ export const createDirSyncContext = (
await serverSync.session.sync(sessionID, options) await serverSync.session.sync(sessionID, options)
index(sessionID) index(sessionID)
}, },
diff: serverSync.session.diff,
todo: serverSync.session.todo, todo: serverSync.session.todo,
history: serverSync.session.history, history: serverSync.session.history,
evict(sessionID: string) { evict(sessionID: string) {
@@ -124,9 +123,9 @@ export const createDirSyncContext = (
fetch: async (count = 10) => { fetch: async (count = 10) => {
const [store, setStore] = current() const [store, setStore] = current()
setStore("limit", (value) => value + count) setStore("limit", (value) => value + count)
const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" }) const response = await client.session.list()
const sessions = response.data const sessions = (response.data ?? [])
.map(normalizeSessionInfo) .filter((session) => !!session?.id)
.sort((a, b) => cmp(a.id, b.id)) .sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit) .slice(0, store.limit)
sessions.forEach(serverSync.session.remember) sessions.forEach(serverSync.session.remember)
@@ -134,8 +133,7 @@ export const createDirSyncContext = (
}, },
more: createMemo(() => current()[0].session.length >= current()[0].limit), more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => { archive: async (sessionID: string) => {
if ((await serverSDK.protocol) !== "v1") return await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } })
await serverSDK.client.session.update({ sessionID, directory, time: { archived: Date.now() } })
current()[1]( current()[1](
"session", "session",
produce((draft) => { produce((draft) => {

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