Compare commits

..

1 commit

Author SHA1 Message Date
Aiden Cline
65b64d5472 fix(core): restore xAI OAuth in v2 2026-07-14 20:13:09 +00:00
491 changed files with 9203 additions and 32271 deletions

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 }}

View file

@ -1,6 +1,6 @@
--- ---
description: translate English to other languages description: translate English to other languages
model: opencode/gpt-5.6-sol model: opencode/claude-opus-4-8
--- ---
run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time. run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.

136
bun.lock
View file

@ -29,7 +29,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.18.11", "version": "1.17.20",
"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.11", "version": "1.17.20",
"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.11", "version": "1.17.20",
"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.11", "version": "1.17.20",
"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.11", "version": "1.17.20",
"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.11", "version": "1.17.20",
"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.11", "version": "1.17.20",
"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.11", "version": "1.17.20",
"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.11", "version": "1.17.20",
"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.11", "version": "1.17.20",
"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.11", "version": "1.17.20",
"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.11", "version": "1.17.20",
"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.11", "version": "1.17.20",
"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.11", "version": "1.17.20",
"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.11", "version": "1.17.20",
"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.11", "version": "1.17.20",
"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.11", "version": "1.17.20",
"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.11", "version": "1.17.20",
"dependencies": { "dependencies": {
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@ -707,9 +706,9 @@
"typescript": "catalog:", "typescript": "catalog:",
}, },
"peerDependencies": { "peerDependencies": {
"@opentui/core": ">=0.4.5", "@opentui/core": ">=0.4.3",
"@opentui/keymap": ">=0.4.5", "@opentui/keymap": ">=0.4.3",
"@opentui/solid": ">=0.4.5", "@opentui/solid": ">=0.4.3",
}, },
"optionalPeers": [ "optionalPeers": [
"@opentui/core", "@opentui/core",
@ -766,7 +765,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.18.11", "version": "1.17.20",
"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.11", "version": "1.17.20",
"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.11", "version": "1.17.20",
"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.11", "version": "1.17.20",
"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.11", "version": "1.17.20",
"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.11", "version": "1.17.20",
"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.11", "version": "1.17.20",
"dependencies": { "dependencies": {
"@aws-sdk/client-firehose": "3.933.0", "@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
@ -938,7 +937,6 @@
"@types/node": "catalog:", "@types/node": "catalog:",
"@types/react": "18.0.25", "@types/react": "18.0.25",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0",
"solid-js": "catalog:", "solid-js": "catalog:",
"storybook": "^10.2.13", "storybook": "^10.2.13",
"storybook-solidjs-vite": "^10.0.9", "storybook-solidjs-vite": "^10.0.9",
@ -948,7 +946,7 @@
}, },
"packages/tui": { "packages/tui": {
"name": "@opencode-ai/tui", "name": "@opencode-ai/tui",
"version": "1.18.11", "version": "1.17.20",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
@ -975,7 +973,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.18.11", "version": "1.17.20",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
@ -1001,7 +999,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 +1024,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.18.11", "version": "1.17.20",
"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 +1073,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:",
@ -1104,9 +1099,9 @@
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.4.5", "@opentui/core": "0.4.3",
"@opentui/keymap": "0.4.5", "@opentui/keymap": "0.4.3",
"@opentui/solid": "0.4.5", "@opentui/solid": "0.4.3",
"@pierre/diffs": "1.2.10", "@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1", "@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0", "@sentry/solid": "10.36.0",
@ -1146,7 +1141,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 +1198,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=="],
@ -2044,27 +2038,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
"@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="], "@opentui/core": ["@opentui/core@0.4.3", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.3", "@opentui/core-darwin-x64": "0.4.3", "@opentui/core-linux-arm64": "0.4.3", "@opentui/core-linux-arm64-musl": "0.4.3", "@opentui/core-linux-x64": "0.4.3", "@opentui/core-linux-x64-musl": "0.4.3", "@opentui/core-win32-arm64": "0.4.3", "@opentui/core-win32-x64": "0.4.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-rrJfAk13tALDqldYjhc78eWQ+aKq1iknJgffIOg3OwyZoqQo+p6gtuqyhmWvXIfQzlNUbpgpCPcxbXlhMnlaHQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p5+7AAxpxGuDGagyQfewKtmTFnN7THvTVY4FyKqUtJomNaHdQXPHztapNNzMx0DGWbwOUbVKzpL+yc3CZY3chQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-+fh0vEUE0lwVC7RW5ijYLRlTLp5NfvCRj8SzxDVd7IL2j2ssB6YXcfIbXq2EW7UGnrejwPRXf1tgUrIXW9KmOw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-gl6qA5QJy6u8Cbt7gOtHbhhfMZ4qQDb0kEwFXHcMGmbnKzz4OHoq74D6tNjyvSQB9saoC7C6C0tvn2DcJOuNog=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="], "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-8p8g8/AEq/xFGpQ7XcIFKcAqjc0QwsZcv+Ll9RbCDpUA56FGH6jfLDir0KYTNTgYXJTIrBIENI9K46VuxMUMQA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-dXpJitiZdYE3hq2Pvx6e9I0uPQSOcnaLLp1pDgWAHv+3kvKSHEX//9Yr/pV/Ua6qqT7p+2D/K4vXNap/NKVo2w=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="], "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/QiFpCrpU2O7vy8QYmLIQYbvAtKDgmqcVjR7dGtqSzkiQk3ktNJoo5RozG7ueXnjung1Wp0nKldKxo2Csg/OrA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-Mx2zuOjrhm/z2SDS6RExIyjP/SnN/8QhhagxURUw0jQi/NssGSeAllu1cBAFFnhobJL5QLTE4FU4CRhUK9svgg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.3", "", { "os": "win32", "cpu": "x64" }, "sha512-NuoqvWKGXaYnmlqvu7Gg2lLI6yVMnS9OfWBvxp+7Q+McSgHFSTQmYBXaPpvQ8HikpQXE1nCeMPtuSG4PdZHe2w=="],
"@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="], "@opentui/keymap": ["@opentui/keymap@0.4.3", "", { "dependencies": { "@opentui/core": "0.4.3" }, "peerDependencies": { "@opentui/react": "0.4.3", "@opentui/solid": "0.4.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-sinX0pyQBRrEvo89PSSUbSUDIYpL3xWo81VEfec58VFoVRB5FG48/deAtvRTQfJ8w1kgbzN8hzdOXdSm61zBmw=="],
"@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="], "@opentui/solid": ["@opentui/solid@0.4.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-RcV0+S8HMdXOASyr7HmJUBuTUIaFPzAxMDa44VftS5C2JUgrmAuWo0Njv1q3TWRB1owjHnyKhEfWGKq7A82wxw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@ -3850,13 +3844,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 +5122,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 +5698,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 +6024,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 +6032,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 +6042,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 +6172,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 +6988,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 +7414,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=="],

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 }]
}, },
}, },
}, },

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",

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"

View file

@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-a7NyYa9vRUEqDfZNDPXXmFO58RDEgioyuGSl5CPBvxo=", "x86_64-linux": "sha256-F1luclnqCPQk9yxfmeSYGaM/nScf28yBu9K3Fv+Xd24=",
"aarch64-linux": "sha256-l4OJtSEllHvRhktjcaJYwkXBSaJvsIoyoLusbZfYMcM=", "aarch64-linux": "sha256-XW0XZnsCRkU3MFJH9TjMRYZHffzVy3cQyiNCkec2gl4=",
"aarch64-darwin": "sha256-IIl0BQGs1/HLFh0auiQjiwfSQ2nfHcK2G2BAphYW59c=", "aarch64-darwin": "sha256-bf8kvORs3Fs2UYLp3PekF+AJR7NKOcHb+fIQA79RtMk=",
"x86_64-darwin": "sha256-vVeuPyd4ZIRYrHouphTuEb4rkRZLKKTAHe840jNh9rU=" "x86_64-darwin": "sha256-sBdQPkzd7JXNW6Lbi9JHiAsfHwdLwTKWY+uPeXAv2Nw="
} }
} }

View file

@ -40,9 +40,9 @@
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0", "@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@opentui/core": "0.4.5", "@opentui/core": "0.4.3",
"@opentui/keymap": "0.4.5", "@opentui/keymap": "0.4.3",
"@opentui/solid": "0.4.5", "@opentui/solid": "0.4.3",
"@tanstack/solid-virtual": "3.13.32", "@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0", "@shikijs/stream": "4.2.0",
"ulid": "3.0.1", "ulid": "3.0.1",
@ -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",

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.

View file

@ -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(),

View file

@ -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 }
} }

View file

@ -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, {})
}) })
} }

View file

@ -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()
})

View file

@ -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)
})

View file

@ -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")
})

View file

@ -54,15 +54,18 @@ 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="session-composer"]')
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('[data-component="prompt-variant-control"]')
await expectAppVisible(composer) await expectAppVisible(composer)
await idleComposer(page) await idleComposer(page)
await expect(control).toBeHidden()
await composer.hover()
await expect(control).toBeVisible() await expect(control).toBeVisible()
await control.click() await control.locator('[data-action="prompt-model-variant"]').click()
const high = page.getByRole("menuitemradio", { name: "high" }) const high = page.getByRole("menuitemradio", { name: "high" })
await expect(high).toBeVisible() await expect(high).toBeVisible()
await page.mouse.move(0, 0) await page.mouse.move(0, 0)

View file

@ -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, {})
}) })
} }

View file

@ -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",
})
} }

View file

@ -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)

View file

@ -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")

View file

@ -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,

View file

@ -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")

View file

@ -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()
}) })

View file

@ -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,

View file

@ -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 })

View file

@ -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 }) => {

View file

@ -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()

View file

@ -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)

View file

@ -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, {})
}) })
} }

View file

@ -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 }
}

View file

@ -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)

View file

@ -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 }
}

View file

@ -736,5 +736,5 @@ async function switchTitlebarSession(page: Page, sessionID: string, title: strin
} }
async function expectSessionReady(page: Page) { async function expectSessionReady(page: Page) {
await expectAppVisible(page.getByRole("textbox", { name: "Prompt" })) await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
} }

View file

@ -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")
})

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,
}
}

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",
() => { () => {

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.18.11", "version": "1.17.20",
"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:",

View file

@ -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()

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)

View file

@ -9,16 +9,7 @@ import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo" import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router"
type BaseRouterProps,
Navigate,
Route,
Router,
useLocation,
useNavigate,
useParams,
useSearchParams,
} from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect" import { Effect } from "effect"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
@ -38,7 +29,6 @@ import {
Show, Show,
} from "solid-js" } from "solid-js"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
import { CommandProvider, useCommand, type CommandOption } from "@/context/command" import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
import { CommentsProvider } from "@/context/comments" import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file" import { FileProvider } from "@/context/file"
@ -67,8 +57,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 +226,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__?: {

View file

@ -1,20 +1,17 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
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 { SessionInfo } from "@opencode-ai/client/promise"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useNavigate } from "@solidjs/router"
import { createMemo, onCleanup } from "solid-js" import { createMemo, onCleanup } from "solid-js"
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command" import { useCommand, type CommandOption } from "@/context/command"
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout, type LocalProject } from "@/context/layout" import { useLayout } from "@/context/layout"
import { ServerConnection } from "@/context/server" import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync"
import { useTabs } from "@/context/tabs"
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" import { decode64 } from "@/utils/base64"
export type CommandPaletteEntry = { export type CommandPaletteEntry = {
id: string id: string
@ -27,8 +24,6 @@ export type CommandPaletteEntry = {
path?: string path?: string
directory?: string directory?: string
sessionID?: string sessionID?: string
server?: ServerConnection.Key
project?: LocalProject
archived?: number archived?: number
updated?: number updated?: number
} }
@ -80,25 +75,28 @@ export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => vo
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) { export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
const command = useCommand() const command = useCommand()
const global = useGlobal()
const language = useLanguage() const language = useLanguage()
const layout = useLayout()
const file = useFile() const file = useFile()
const dialog = useDialog() const dialog = useDialog()
const navigate = useNavigate()
const serverSDK = useServerSDK()() const serverSDK = useServerSDK()()
const serverCtx = global.ensureServerCtx(serverSDK.server) const serverSync = useServerSync()
const appTabs = useTabs() const { params, tabs } = useSessionLayout()
const { tabs: sessionTabs } = useSessionLayout()
const openFile = createCommandPaletteFileOpener(props.onOpenFile) const openFile = createCommandPaletteFileOpener(props.onOpenFile)
const state = { cleanup: undefined as (() => void) | void, committed: false } const state = { cleanup: undefined as (() => void) | void, committed: false }
const filesOnly = () => props.filesOnly?.() ?? false const filesOnly = () => props.filesOnly?.() ?? false
const allowedCommands = createMemo(() => { const allowedCommands = createMemo(() => {
if (filesOnly()) return [] if (filesOnly()) return []
return commandPaletteOptions(command.options) return command.options.filter(
(option) =>
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
}) })
const commandEntries = createMemo(() => { const commandEntries = createMemo(() => {
const category = language.t("palette.group.commands") const category = language.t("palette.group.commands")
return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category)) return allowedCommands().map((option) => createCommandEntry(option, category))
}) })
const preferredCommandEntries = createMemo(() => { const preferredCommandEntries = createMemo(() => {
const all = allowedCommands() const all = allowedCommands()
@ -107,11 +105,11 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT) const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
const category = language.t("palette.group.commands") const category = language.t("palette.group.commands")
return sorted.map((option) => createCommandPaletteCommandEntry(option, category)) return sorted.map((option) => createCommandEntry(option, category))
}) })
const tabState = createSessionTabs({ const tabState = createSessionTabs({
tabs: sessionTabs, tabs,
pathFromTab: file.pathFromTab, pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
}) })
@ -142,11 +140,36 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
.map((path) => createCommandPaletteFileEntry(path, category)) .map((path) => createCommandPaletteFileEntry(path, category))
}) })
const sessions = createServerSessionEntries({ const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
server: ServerConnection.key(serverSDK.server), const project = createMemo(() => {
opened: serverCtx.projects.list, const directory = projectDirectory()
stored: () => serverCtx.sync.data.project, if (!directory) return undefined
load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }), return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
})
const workspaces = createMemo(() => {
const directory = projectDirectory()
const current = project()
if (!current) return directory ? [directory] : []
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => serverSync().data.path.home)
const sessions = createSessionEntries({
workspaces,
label: (directory) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = serverSync().child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
},
load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
untitled: () => language.t("command.session.new"), untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"), category: () => language.t("command.category.session"),
}) })
@ -168,17 +191,8 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
return return
} }
if (item.type === "session") { if (item.type === "session") {
if (!item.sessionID || !item.server) return if (!item.directory || !item.sessionID) return
const directory = item.project?.worktree ?? item.directory navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
if (directory) {
serverCtx.projects.open(directory)
serverCtx.projects.touch(directory)
}
const tab = appTabs.addSessionTab({
server: item.server,
sessionId: item.sessionID,
})
appTabs.select(tab)
return return
} }
if (!item.path) return if (!item.path) return
@ -204,7 +218,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
} }
} }
export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry { function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
return { return {
id: "command:" + option.id, id: "command:" + option.id,
type: "command", type: "command",
@ -216,66 +230,96 @@ export function createCommandPaletteCommandEntry(option: CommandOption, category
} }
} }
export function createServerSessionEntries(props: { function createSessionEntries(props: {
server: ServerConnection.Key workspaces: () => string[]
opened: () => LocalProject[] label: (directory: string) => string
stored: () => Project[] load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }>
untitled: () => string untitled: () => string
category: () => string category: () => string
}) { }) {
let abort: AbortController | undefined const state: {
token: number
inflight: Promise<CommandPaletteEntry[]> | undefined
cached: CommandPaletteEntry[] | undefined
} = { token: 0, inflight: undefined, cached: undefined }
onCleanup(() => abort?.abort()) return (text: string) => {
if (!text.trim()) {
return async (text: string): Promise<CommandPaletteEntry[]> => { state.token += 1
const search = text.trim() state.inflight = undefined
if (!search) { state.cached = undefined
abort?.abort() return [] as CommandPaletteEntry[]
return []
} }
abort?.abort() if (state.cached) return state.cached
const current = new AbortController() if (state.inflight) return state.inflight
abort = current
await new Promise<void>((resolve) => { const current = state.token
const timer = setTimeout(resolve, 100) const dirs = props.workspaces()
current.signal.addEventListener( if (dirs.length === 0) return [] as CommandPaletteEntry[]
"abort",
() => { state.inflight = Promise.all(
clearTimeout(timer) dirs.map((directory) => {
resolve() const description = props.label(directory)
}, return props
{ once: true }, .load(directory)
) .then((result) =>
}) (result.data ?? [])
if (current.signal.aborted) return [] .filter((session) => !!session?.id)
const opened = props.opened() .map((session) => ({
const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : []))) id: session.id,
const stored = props.stored().map((project) => ({ ...project, expanded: false })) title: session.title ?? props.untitled(),
const storedByID = new Map(stored.map((project) => [project.id, project] as const)) description,
return props directory,
.load(search, current.signal) archived: session.time?.archived,
.then((result) => updated: session.time?.updated,
result.data })),
.map(normalizeSessionInfo) )
.filter((session) => !session.time.archived) .catch(() => [] as SessionEntryInput[])
.map((session) => { }),
const project = )
projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID) .then((results) => {
return { if (state.token !== current) return [] as CommandPaletteEntry[]
id: `session:${props.server}:${session.id}`, const seen = new Set<string>()
type: "session" as const, const next = results
title: session.title || props.untitled(), .flat()
description: project ? displayName(project) : getFilename(session.directory), .filter((item) => {
category: props.category(), const key = `${item.directory}:${item.id}`
directory: session.directory, if (seen.has(key)) return false
sessionID: session.id, seen.add(key)
server: props.server, return true
project, })
updated: session.time.updated, .map((item) => createSessionEntry(item, props.category()))
} state.cached = next
}), return next
) })
.catch(() => [] as CommandPaletteEntry[]) .catch(() => [] as CommandPaletteEntry[])
.finally(() => {
state.inflight = undefined
})
return state.inflight
}
}
type SessionEntryInput = {
directory: string
id: string
title: string
description: string
archived?: number
updated?: number
}
function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry {
return {
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category,
directory: input.directory,
sessionID: input.id,
archived: input.archived,
updated: input.updated,
} }
} }

View file

@ -5,7 +5,6 @@ import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
type Mem = Performance & { type Mem = Performance & {
memory?: { memory?: {
@ -66,7 +65,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 +73,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>
) )
@ -116,55 +107,8 @@ function Cell(props: {
) )
} }
function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) {
const content = () => (
<button
type="button"
aria-label="Force focus styles on all interactive elements"
aria-pressed={props.active}
classList={{
"flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
"min-h-[20px] w-fit justify-start rounded px-1.5 py-0.5 text-left": !!props.inline,
"min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline,
"bg-surface-raised-base text-text-strong": props.active,
}}
onClick={props.onClick}
>
<span
classList={{
flex: true,
"-translate-y-px items-baseline gap-1.5": !!props.inline,
"flex-col items-center": !props.inline,
}}
>
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span>
<span
classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}
>
{props.active ? "ON" : "OFF"}
</span>
</span>
</button>
)
if (props.inline) {
return (
<TooltipV2 value="Force focus styles on all interactive elements" placement="top">
{content()}
</TooltipV2>
)
}
return (
<Tooltip value="Force focus styles on all interactive elements" placement="top">
{content()}
</Tooltip>
)
}
export function DebugBar(props: { inline?: boolean } = {}) { export function DebugBar(props: { inline?: boolean } = {}) {
const language = useLanguage() const language = useLanguage()
const platform = usePlatform()
const location = useLocation() const location = useLocation()
const routing = useIsRouting() const routing = useIsRouting()
const [state, setState] = createStore({ const [state, setState] = createStore({
@ -172,7 +116,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
delay: undefined as number | undefined, delay: undefined as number | undefined,
fps: undefined as number | undefined, fps: undefined as number | undefined,
gap: undefined as number | undefined, gap: undefined as number | undefined,
focus: false,
heap: { heap: {
limit: undefined as number | undefined, limit: undefined as number | undefined,
used: undefined as number | undefined, used: undefined as number | undefined,
@ -199,16 +142,6 @@ export function DebugBar(props: { inline?: boolean } = {}) {
} }
const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`) const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na())) const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
const toggleFocus = async () => {
if (!platform.setForceFocus) return
const enabled = !state.focus
await platform.setForceFocus(enabled)
setState("focus", enabled)
}
onCleanup(() => {
if (state.focus) void platform.setForceFocus?.(false).catch(() => undefined)
})
let prev = "" let prev = ""
let start = 0 let start = 0
@ -557,11 +490,8 @@ export function DebugBar(props: { inline?: boolean } = {}) {
bad={bad(heap(), 0.8)} bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined} dim={state.heap.used === undefined}
inline={props.inline} inline={props.inline}
wide={!platform.setForceFocus} wide
/> />
{platform.setForceFocus && (
<FocusCell active={state.focus} inline={props.inline} onClick={() => void toggleFocus()} />
)}
</div> </div>
</aside> </aside>
) )

View file

@ -5,20 +5,13 @@ import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-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 { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js" import { formatKeybindParts } from "@/context/command"
import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { useTabs } from "@/context/tabs"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { getRelativeTime } from "@/utils/time" import { getRelativeTime } from "@/utils/time"
import { import {
createCommandPaletteCommandEntry,
createCommandPaletteFileEntry, createCommandPaletteFileEntry,
createCommandPaletteModel, createCommandPaletteModel,
createServerSessionEntries,
uniqueCommandPaletteEntries, uniqueCommandPaletteEntries,
type CommandPaletteEntry, type CommandPaletteEntry,
} from "./command-palette" } from "./command-palette"
@ -37,6 +30,9 @@ function matchesEntry(entry: CommandPaletteEntry, query: string) {
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) { export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props) const palette = createCommandPaletteModel(props)
const [query, setQuery] = createSignal("")
const [active, setActive] = createSignal(0)
const loadItems = async (text: string) => { const loadItems = async (text: string) => {
const q = text.trim() const q = text.trim()
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()] if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
@ -45,104 +41,16 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v
const category = palette.language.t("palette.group.files") const category = palette.language.t("palette.group.files")
return [ return [
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)), ...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
...nextSessions, ...nextSessions.filter((entry) => matchesEntry(entry, q)),
...files.map((path) => createCommandPaletteFileEntry(path, category)), ...files.map((path) => createCommandPaletteFileEntry(path, category)),
] ]
} }
return ( const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] })
<CommandPaletteView
placeholder={palette.language.t("palette.search.placeholder")}
loadItems={loadItems}
highlight={palette.highlight}
select={palette.select}
close={palette.close}
/>
)
}
export function DialogHomeCommandPaletteV2(props: {
server: ServerConnection.Any
onSelectSession: (entry: CommandPaletteEntry) => void
}) {
const command = useCommand()
const dialog = useDialog()
const global = useGlobal()
const language = useLanguage()
const serverCtx = global.ensureServerCtx(props.server)
const state = { cleanup: undefined as (() => void) | void, committed: false }
const commandEntries = createMemo(() => {
const category = language.t("palette.group.commands")
return commandPaletteOptions(command.options).map((option) => createCommandPaletteCommandEntry(option, category))
})
const sessions = createServerSessionEntries({
server: ServerConnection.key(props.server),
opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project,
load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
const highlight = (item: CommandPaletteEntry | undefined) => {
state.cleanup?.()
state.cleanup = undefined
if (item?.type !== "command") return
state.cleanup = item.option?.onHighlight?.()
}
const select = (item: CommandPaletteEntry | undefined) => {
if (!item) return
state.committed = true
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
item.option?.onSelect?.("palette")
return
}
if (item.type === "session") props.onSelectSession(item)
}
const loadItems = async (text: string) => {
const query = text.trim()
if (!query) return commandEntries().slice(0, 5)
return [...commandEntries().filter((entry) => matchesEntry(entry, query)), ...(await sessions(query))]
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
return (
<CommandPaletteView
placeholder={language.t("palette.search.placeholder.home")}
loadItems={loadItems}
highlight={highlight}
select={select}
close={() => dialog.close()}
/>
)
}
function CommandPaletteView(props: {
placeholder: string
loadItems: (text: string) => CommandPaletteEntry[] | Promise<CommandPaletteEntry[]>
highlight: (item: CommandPaletteEntry | undefined) => void
select: (item: CommandPaletteEntry | undefined) => void
close: () => void
}) {
const language = useLanguage()
const tabs = useTabs()
const [query, setQuery] = createSignal("")
const [active, setActive] = createSignal(0)
const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] })
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke. // Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? [])) const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
const groupedEntries = createMemo(() => groups(visibleEntries())) const groupedEntries = createMemo(() => groups(visibleEntries()))
const activeEntry = createMemo(() => visibleEntries()[active()]) const activeEntry = createMemo(() => visibleEntries()[active()])
const openSessions = createMemo(
() => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))),
)
createEffect(() => { createEffect(() => {
query() query()
@ -151,7 +59,7 @@ function CommandPaletteView(props: {
}) })
createEffect(() => { createEffect(() => {
props.highlight(activeEntry()) palette.highlight(activeEntry())
}) })
let resultsRef: HTMLDivElement | undefined let resultsRef: HTMLDivElement | undefined
@ -178,12 +86,12 @@ function CommandPaletteView(props: {
} }
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault() event.preventDefault()
props.select(activeEntry()) palette.select(activeEntry())
return return
} }
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault() event.preventDefault()
props.close() palette.close()
} }
} }
@ -197,7 +105,7 @@ function CommandPaletteView(props: {
autocomplete="off" autocomplete="off"
spellcheck={false} spellcheck={false}
appearance="large" appearance="large"
placeholder={props.placeholder} placeholder={palette.language.t("palette.search.placeholder")}
leadingIcon={<Icon name="magnifying-glass" />} leadingIcon={<Icon name="magnifying-glass" />}
onInput={(event) => setQuery(event.currentTarget.value)} onInput={(event) => setQuery(event.currentTarget.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@ -209,7 +117,7 @@ function CommandPaletteView(props: {
when={visibleEntries().length > 0} when={visibleEntries().length > 0}
fallback={ fallback={
<div class="command-palette-v2-state"> <div class="command-palette-v2-state">
{entries.loading ? language.t("common.loading") : language.t("palette.empty")} {entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")}
</div> </div>
} }
> >
@ -224,14 +132,9 @@ function CommandPaletteView(props: {
<PaletteRow <PaletteRow
item={item} item={item}
active={activeEntry()?.id === item.id} active={activeEntry()?.id === item.id}
language={language} language={palette.language}
sessionOpen={
item.server && item.sessionID
? openSessions().has(`${item.server}\0${item.sessionID}`)
: false
}
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))} onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
onSelect={() => props.select(item)} onSelect={() => palette.select(item)}
/> />
)} )}
</For> </For>
@ -250,19 +153,13 @@ function PaletteRow(props: {
item: CommandPaletteEntry item: CommandPaletteEntry
active: boolean active: boolean
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
sessionOpen: boolean
onActive: () => void onActive: () => void
onSelect: () => void onSelect: () => void
}) { }) {
const session = () =>
props.item.server && props.item.directory && props.item.sessionID
? { server: props.item.server, directory: props.item.directory, sessionID: props.item.sessionID }
: undefined
return ( return (
<button <button
type="button" type="button"
class="command-palette-v2-row group" class="command-palette-v2-row"
role="option" role="option"
aria-selected={props.active} aria-selected={props.active}
data-active={props.active ? "" : undefined} data-active={props.active ? "" : undefined}
@ -300,25 +197,7 @@ function PaletteRow(props: {
</Match> </Match>
<Match when={props.item.type === "session"}> <Match when={props.item.type === "session"}>
<div class="command-palette-v2-row-main"> <div class="command-palette-v2-row-main">
<div class="relative shrink-0"> <Icon name="status" class="command-palette-v2-row-icon" />
<Show when={props.sessionOpen}>
<span
aria-hidden="true"
class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
style={{ right: "calc(100% + 4px)" }}
/>
</Show>
<Show when={session()}>
{(session) => (
<SessionTabAvatar
project={props.item.project}
directory={session().directory}
sessionId={session().sessionID}
server={session().server}
/>
)}
</Show>
</div>
<div class="command-palette-v2-row-text"> <div class="command-palette-v2-row-text">
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}> <span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
{props.item.title} {props.item.title}

View file

@ -1,73 +0,0 @@
// @ts-nocheck
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { mockProviderAuth } from "@/context/server-sync"
import { onCleanup, onMount } from "solid-js"
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
function ConnectProviderDialogStory() {
const dialog = useDialog()
const open = () => dialog.show(() => <DialogConnectProvider />)
onMount(open)
return (
<Button variant="secondary" onClick={open}>
Open connect provider dialog
</Button>
)
}
function ProviderConnectionDialogStory(props) {
onCleanup(mockProviderAuth(props.provider, props.methods))
const dialog = useDialog()
const controller = useProviderConnectController()
controller.select(props.provider)
const open = () => dialog.show(() => <DialogConnectProvider controller={controller} />)
onMount(open)
return (
<Button variant="secondary" onClick={open}>
Open {props.provider} connection dialog
</Button>
)
}
function renderConnection(provider, methods) {
return () => (
<QueryClientProvider client={new QueryClient()}>
<ProviderConnectionDialogStory provider={provider} methods={methods} />
</QueryClientProvider>
)
}
export default {
title: "App/Dialogs/Connect Provider",
id: "app-dialog-connect-provider",
}
export const V2 = {
render: () => (
<QueryClientProvider client={new QueryClient()}>
<ConnectProviderDialogStory />
</QueryClientProvider>
),
}
export const ApiKey = {
render: renderConnection("openrouter", [{ type: "api", label: "API key" }]),
}
export const OpenCodeZen = {
render: renderConnection("opencode", [{ type: "api", label: "API key" }]),
}
export const LoginMethods = {
render: renderConnection("openai", [
{ type: "oauth", label: "ChatGPT Pro/Plus (browser)" },
{ type: "oauth", label: "ChatGPT Pro/Plus (headless)" },
{ type: "api", label: "API key" },
]),
}

View file

@ -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"
@ -9,9 +9,6 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { Tag } from "@opencode-ai/ui/tag" import { Tag } from "@opencode-ai/ui/tag"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { import {
type Accessor, type Accessor,
@ -19,8 +16,6 @@ import {
createEffect, createEffect,
createMemo, createMemo,
createResource, createResource,
createUniqueId,
For,
Match, Match,
onCleanup, onCleanup,
onMount, onMount,
@ -28,18 +23,14 @@ 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 { 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 })
@ -59,22 +50,32 @@ export const DialogConnectProvider: Component<{
const fallback = useProviderConnectController() const fallback = useProviderConnectController()
const controller = props.controller ?? fallback const controller = props.controller ?? fallback
const language = useLanguage() const language = useLanguage()
const settings = useSettings()
const newLayout = settings.general.newLayoutDesigns
const reset = controller.back const reset = controller.back
const back = { current: reset } const back = { current: reset }
let focusHost: HTMLDivElement | undefined
const holdFocus = () => focusHost?.focus({ preventScroll: true })
const select = (provider?: string) => { const select = (provider?: string) => {
back.current = reset back.current = reset
controller.select(provider) controller.select(provider)
} }
function Content() { return (
return ( <Dialog
class="h-full"
transition
title={
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={() => back.current()}
aria-label={language.t("common.goBack")}
/>
</Show>
}
>
<Switch> <Switch>
<Match when={controller.selected() === CUSTOM_ID}> <Match when={controller.selected() === CUSTOM_ID}>
<CustomProviderForm autofocus={!newLayout()} /> <CustomProviderForm />
</Match> </Match>
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}> <Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
{(provider) => ( {(provider) => (
@ -87,77 +88,15 @@ export const DialogConnectProvider: Component<{
)} )}
</Match> </Match>
<Match when={true}> <Match when={true}>
<ProviderPicker <ProviderPicker directory={props.directory} onSelect={select} />
directory={props.directory}
onSelect={select}
onPrepare={newLayout() ? holdFocus : undefined}
/>
</Match> </Match>
</Switch> </Switch>
) </Dialog>
}
return (
<Show
when={newLayout()}
fallback={
<Dialog
class="h-full"
transition
title={
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={() => back.current()}
aria-label={language.t("common.goBack")}
/>
</Show>
}
>
<Content />
</Dialog>
}
>
<DialogV2
containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
>
<DialogHeader closeLabel={language.t("common.close")}>
<Show
when={controller.selected()}
fallback={<DialogTitle>{language.t("command.provider.connect")}</DialogTitle>}
>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => back.current()}
aria-label={language.t("common.goBack")}
>
<Icon name="arrow-left" size="small" />
</button>
</Show>
</DialogHeader>
<DialogBody class="min-h-0 flex-1 overflow-hidden px-2 pb-2">
<div ref={focusHost} tabIndex={-1} class="flex min-h-0 flex-1 flex-col outline-none">
<Content />
</div>
</DialogBody>
</DialogV2>
</Show>
) )
} }
function ProviderPicker(props: { function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSelect: (provider: string) => void }) {
directory?: Accessor<string | undefined> const providers = useProviders(props.directory)
onSelect: (provider: string) => void
onPrepare?: () => void
}) {
const settings = useSettings()
if (settings.general.newLayoutDesigns())
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
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")
@ -224,157 +163,6 @@ function ProviderPicker(props: {
) )
} }
function ProviderPickerV2(props: {
directory?: Accessor<string | undefined>
onSelect: (provider: string) => void
onPrepare?: () => void
}) {
const providers = useProviders(() => props.directory?.())
const language = useLanguage()
const [store, setStore] = createStore({
filter: "",
active: undefined as string | undefined,
connecting: undefined as string | undefined,
})
const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"]
const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
const all = createMemo(() => {
language.locale()
const query = store.filter.trim().toLowerCase()
const values = [custom(), ...providers.all().values()]
if (!query) return values
return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
})
const popular = createMemo(() =>
all()
.filter((provider) => featured.includes(provider.id))
.sort((a, b) => featured.indexOf(a.id) - featured.indexOf(b.id)),
)
const other = createMemo(() =>
all()
.filter((provider) => !featured.includes(provider.id))
.sort((a, b) => {
if (a.id === CUSTOM_ID) return -1
if (b.id === CUSTOM_ID) return 1
return a.name.localeCompare(b.name)
}),
)
const rows = createMemo(() => [...popular(), ...other()])
let picker: HTMLDivElement | undefined
let search: HTMLInputElement | undefined
onMount(() => search?.focus({ preventScroll: true }))
const connect = (provider: string) => {
props.onPrepare?.()
props.onSelect(provider)
}
const move = (event: KeyboardEvent, direction: number) => {
const items = rows()
if (items.length === 0) return
const index = items.findIndex((provider) => provider.id === store.active)
const next = index < 0 ? (direction > 0 ? 0 : items.length - 1) : (index + direction + items.length) % items.length
setStore("active", items[next].id)
picker
?.querySelector<HTMLElement>(`[data-provider-id="${CSS.escape(items[next].id)}"]`)
?.focus({ preventScroll: true })
event.preventDefault()
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowDown") return move(event, 1)
if (event.key === "ArrowUp") return move(event, -1)
if (event.key !== "Enter" || !store.active) return
connect(store.active)
event.preventDefault()
}
return (
<div ref={picker} class="flex min-h-0 flex-1 flex-col gap-4" onKeyDown={handleKeyDown}>
<div class="shrink-0 px-1 pt-px">
<TextInputV2
ref={search}
type="search"
class="!w-full [font-family:var(--v2-font-family-sans)]"
leadingIcon={<Icon name="magnifying-glass" size="small" />}
placeholder={language.t("dialog.provider.search.placeholder")}
value={store.filter}
onInput={(event) => {
setStore({ filter: event.currentTarget.value, active: undefined })
}}
/>
</div>
<div class="relative min-h-0 flex-1">
<div class="flex size-full min-h-0 flex-col gap-4 overflow-y-auto pb-8 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<For
each={[
{ title: language.t("dialog.provider.group.popular"), items: popular },
{ title: language.t("dialog.provider.group.other"), items: other },
]}
>
{(group) => (
<Show when={group.items().length > 0}>
<section class="flex flex-col">
<div class="px-3 pb-2 text-[13px] font-[440] leading-none tracking-[-0.04px] text-v2-text-text-muted">
{group.title}
</div>
<For each={group.items()}>
{(provider) => (
<button
type="button"
data-provider-id={provider.id}
class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-none tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === provider.id }}
onMouseEnter={() => setStore("active", provider.id)}
disabled={store.connecting !== undefined}
aria-busy={store.connecting === provider.id}
onClick={() => connect(provider.id)}
>
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-base" />
<span class="min-w-0 truncate font-[530] text-v2-text-text-base">{provider.name}</span>
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
<span class="min-w-0 truncate font-[440] text-v2-text-text-muted">
{language.t(
provider.id === "opencode"
? "dialog.provider.opencode.tagline"
: "dialog.provider.opencodeGo.tagline",
)}
</span>
<span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
{language.t("dialog.provider.tag.recommended")}
</span>
</Show>
<Show when={provider.id === CUSTOM_ID}>
<span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
{language.t("settings.providers.tag.custom")}
</span>
</Show>
<Show when={store.connecting === provider.id}>
<Spinner class="ml-auto size-4 shrink-0 text-v2-icon-icon-muted" />
</Show>
</button>
)}
</For>
</section>
</Show>
)}
</For>
<Show when={rows().length === 0}>
<div class="flex h-24 items-center justify-center text-[13px] font-[440] text-v2-text-text-muted">
{language.t("dialog.provider.empty")}
</div>
</Show>
</div>
<div
class="pointer-events-none absolute inset-x-0 bottom-0 h-10"
style={{ background: "linear-gradient(to bottom, transparent, var(--v2-background-bg-layer-01))" }}
/>
</div>
</div>
)
}
function ProviderConnection(props: { function ProviderConnection(props: {
provider: string provider: string
directory?: Accessor<string | undefined> directory?: Accessor<string | undefined>
@ -384,16 +172,8 @@ 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 providers = useProviders(props.directory)
const newLayout = settings.general.newLayoutDesigns
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,32 +188,28 @@ 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(
(method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
)
return values?.length ? values : fallback()
})
const [store, setStore] = createStore({ const [store, setStore] = createStore({
methodIndex: undefined as undefined | number, methodIndex: undefined 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: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
error: undefined as string | undefined, error: undefined as string | undefined,
@ -445,7 +221,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,20 +275,10 @@ 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 ?? ""
} }
const methodDetails = (value?: { type?: string; label?: string }) => {
const label = methodLabel(value)
const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i)
const hint = suffix?.[1]
return {
label: suffix ? label.slice(0, -suffix[0].length) : label,
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined,
}
}
function formatError(value: unknown, fallback: string): string { function formatError(value: unknown, fallback: string): string {
if (value && typeof value === "object" && "data" in value) { if (value && typeof value === "object" && "data" in value) {
const data = (value as { data?: { message?: unknown } }).data const data = (value as { data?: { message?: unknown } }).data
@ -540,22 +306,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 +360,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 +393,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 +498,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",
@ -727,37 +519,6 @@ function ProviderConnection(props: {
props.setBack(goBack) props.setBack(goBack)
function MethodSelection() { function MethodSelection() {
if (newLayout())
return (
<div class="flex flex-col gap-2">
<div class="px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
{language.t("provider.connect.selectMethod", { provider: provider().name })}
</div>
<div class="flex flex-col">
<For each={methods()}>
{(item, index) => {
const details = () => methodDetails(item)
return (
<button
type="button"
class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => void selectMethod(index())}
>
<span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
<span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
</span>
<span class="font-[530] text-v2-text-text-base">{details().label}</span>
<Show when={details().hint}>
{(hint) => <span class="font-[440] text-v2-text-text-muted">{hint()}</span>}
</Show>
</button>
)
}}
</For>
</div>
</div>
)
return ( return (
<> <>
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
@ -770,7 +531,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)
@ -791,18 +552,11 @@ function ProviderConnection(props: {
} }
function ApiAuthView() { function ApiAuthView() {
let apiKey: HTMLInputElement | undefined
const errorID = createUniqueId()
const [formStore, setFormStore] = createStore({ const [formStore, setFormStore] = createStore({
value: "", value: "",
error: undefined as string | undefined, error: undefined as string | undefined,
}) })
onMount(() => {
if (!newLayout()) return
apiKey?.focus({ preventScroll: true })
})
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
@ -816,67 +570,17 @@ 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()
} }
if (newLayout())
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<Show
when={provider().id === "opencode"}
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
>
<div class="flex flex-col gap-5">
<div>{language.t("provider.connect.opencodeZen.line1")}</div>
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
<div>
{language.t("provider.connect.opencodeZen.visit.prefix")}
<ExternalLink
href="https://opencode.ai/zen"
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
>
{language.t("provider.connect.opencodeZen.visit.link")}
</ExternalLink>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
</Show>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.apiKey.label", { provider: provider().name })}
<TextInputV2
ref={apiKey}
class="!w-full"
name="apiKey"
data-input="provider-api-key"
placeholder={language.t("provider.connect.apiKey.placeholder")}
value={formStore.value}
invalid={formStore.error !== undefined}
aria-describedby={formStore.error ? errorID : undefined}
autocomplete="off"
spellcheck={false}
onInput={(event) => setFormStore("value", event.currentTarget.value)}
/>
</label>
<Show when={formStore.error}>
{(error) => (
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
{error()}
</div>
)}
</Show>
<ButtonV2 type="submit" variant="contrast" data-action="provider-connect-submit">
{language.t("common.continue")}
</ButtonV2>
</form>
</div>
)
return ( return (
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<Switch> <Switch>
@ -886,9 +590,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>
@ -901,8 +605,7 @@ function ProviderConnection(props: {
</Switch> </Switch>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField <TextField
autofocus={!newLayout()} autofocus
ref={apiKey}
type="text" type="text"
label={language.t("provider.connect.apiKey.label", { provider: provider().name })} label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
placeholder={language.t("provider.connect.apiKey.placeholder")} placeholder={language.t("provider.connect.apiKey.placeholder")}
@ -921,18 +624,11 @@ function ProviderConnection(props: {
} }
function OAuthCodeView() { function OAuthCodeView() {
let codeInput: HTMLInputElement | undefined
const errorID = createUniqueId()
const [formStore, setFormStore] = createStore({ const [formStore, setFormStore] = createStore({
value: "", value: "",
error: undefined as string | undefined, error: undefined as string | undefined,
}) })
onMount(() => {
if (!newLayout()) return
codeInput?.focus({ preventScroll: true })
})
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
@ -947,13 +643,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()
@ -962,59 +657,16 @@ function ProviderConnection(props: {
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid"))) setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
} }
if (newLayout())
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>
{language.t("provider.connect.oauth.code.visit.prefix")}
<ExternalLink href={store.authorization!.url} class="text-v2-text-text-base">
{language.t("provider.connect.oauth.code.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
<TextInputV2
ref={codeInput}
class="!w-full"
name="code"
placeholder={language.t("provider.connect.oauth.code.placeholder")}
value={formStore.value}
invalid={formStore.error !== undefined}
aria-describedby={formStore.error ? errorID : undefined}
autocomplete="off"
spellcheck={false}
onInput={(event) => setFormStore("value", event.currentTarget.value)}
/>
</label>
<Show when={formStore.error}>
{(error) => (
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
{error()}
</div>
)}
</Show>
<ButtonV2 type="submit" variant="contrast">
{language.t("common.continue")}
</ButtonV2>
</form>
</div>
)
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.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">
<TextField <TextField
autofocus={!newLayout()} autofocus
ref={codeInput}
type="text" type="text"
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })} label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
placeholder={language.t("provider.connect.oauth.code.placeholder")} placeholder={language.t("provider.connect.oauth.code.placeholder")}
@ -1042,46 +694,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
@ -1100,19 +738,10 @@ function ProviderConnection(props: {
} }
return ( return (
<div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-3"}> <div class="flex flex-col gap-6 px-2.5 pb-3">
<div class={newLayout() ? "flex h-10 shrink-0 items-start gap-2 px-3" : "flex items-center gap-4 px-2.5"}> <div class="px-2.5 flex gap-4 items-center">
<ProviderIcon <ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
id={props.provider} <div class="text-16-medium text-text-strong">
class={newLayout() ? "mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" : "size-5 shrink-0 icon-strong-base"}
/>
<div
class={
newLayout()
? "text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base"
: "text-16-medium text-text-strong"
}
>
<Switch> <Switch>
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}> <Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
{language.t("provider.connect.title.anthropicProMax")} {language.t("provider.connect.title.anthropicProMax")}
@ -1121,12 +750,8 @@ function ProviderConnection(props: {
</Switch> </Switch>
</div> </div>
</div> </div>
<div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-10"}> <div class="px-2.5 pb-10 flex flex-col gap-6">
<div <div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
onKeyDown={handleKey}
tabIndex={newLayout() ? undefined : 0}
autofocus={!newLayout() && store.methodIndex === undefined ? true : undefined}
>
<Switch> <Switch>
<Match when={loading()}> <Match when={loading()}>
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
@ -1158,15 +783,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>

View file

@ -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"
@ -40,7 +40,7 @@ export function DialogCustomProvider(props: Props) {
) )
} }
export function CustomProviderForm(props: { autofocus?: boolean } = {}) { export function CustomProviderForm() {
const dialog = useDialog() const dialog = useDialog()
const serverSync = useServerSync() const serverSync = useServerSync()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
@ -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,15 +184,15 @@ 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>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<TextField <TextField
autofocus={props.autofocus ?? true} autofocus
label={language.t("provider.custom.field.providerID.label")} label={language.t("provider.custom.field.providerID.label")}
placeholder={language.t("provider.custom.field.providerID.placeholder")} placeholder={language.t("provider.custom.field.providerID.placeholder")}
description={language.t("provider.custom.field.providerID.description")} description={language.t("provider.custom.field.providerID.description")}

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)

View file

@ -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}

View file

@ -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">

View file

@ -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)

View file

@ -1,55 +0,0 @@
// @ts-nocheck
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createSignal, onMount } from "solid-js"
import { DialogSelectModelUnpaidV2 } from "./dialog-select-model-unpaid-v2"
const names = [
"MiMo V2.5 Free",
"Nemotron 3 Ultra Free",
"Deepseek V4 Flash Free",
"North Mini Code Free",
"Hy3 Free",
"Big Pickle",
]
function SelectModelWithoutProviders() {
const dialog = useDialog()
const models = names.map((name, index) => ({
id: name.toLowerCase().replaceAll(" ", "-"),
name,
provider: { id: "opencode", name: "OpenCode" },
cost: { input: 0, output: 0 },
limit: { context: 128_000 },
capabilities: {
reasoning: index !== 5,
input: { text: true, image: false, audio: false, video: false, pdf: false },
},
}))
const [current, setCurrent] = createSignal(models[2])
const model = {
list: () => models,
current,
set(value) {
setCurrent(models.find((item) => item.id === value?.modelID))
},
}
const open = () => dialog.show(() => <DialogSelectModelUnpaidV2 model={model} />)
onMount(open)
return (
<Button variant="secondary" onClick={open}>
Open select model dialog
</Button>
)
}
export default {
title: "App/Dialogs/Select Model",
id: "app-dialog-select-model",
}
export const WithoutProviders = {
render: () => <SelectModelWithoutProviders />,
}

View file

@ -1,26 +1,23 @@
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Tag } from "@opencode-ai/ui/v2/badge-v2" import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme } from "@opencode-ai/ui/theme"
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js" import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ModelTooltip } from "./model-tooltip" import { ModelTooltip } from "./model-tooltip"
type ModelState = ReturnType<typeof useLocal>["model"] type ModelState = ReturnType<typeof useLocal>["model"]
const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => { export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
const local = useLocal() const local = useLocal()
const model = props.model ?? local.model const model = props.model ?? local.model
const dialog = useDialog() const dialog = useDialog()
const theme = useTheme()
const directory = () => decode64(local.slug()) const directory = () => decode64(local.slug())
const providers = useProviders(directory) const providers = useProviders(directory)
const language = useLanguage() const language = useLanguage()
@ -31,7 +28,6 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
}) })
const isFree = (item: ReturnType<ModelState["list"]>[number]) => const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0) item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
const freeModels = createMemo(() => model.list().filter(isFree))
const openProviders = (provider?: string) => { const openProviders = (provider?: string) => {
void import("./dialog-connect-provider").then((x) => { void import("./dialog-connect-provider").then((x) => {
@ -66,110 +62,111 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
}) })
return ( return (
<DialogV2 <DialogV2 containerClass="!h-[min(calc(100vh_-_16px),480px)] !w-[min(calc(100vw_-_16px),560px)]">
fit
containerClass="!h-auto max-h-[calc(100vh_-_16px)] !w-[min(calc(100vw_-_16px),640px)]"
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
>
<DialogHeader closeLabel={language.t("common.close")}> <DialogHeader closeLabel={language.t("common.close")}>
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle> <DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
</DialogHeader> </DialogHeader>
<DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2"> <div class="h-px w-full shrink-0 bg-v2-border-border-muted" />
<div ref={listEl} class="flex min-h-0 flex-col"> <DialogBody class="min-h-0 flex-1 gap-0">
<div data-section="free-models" class="flex w-full flex-col items-start pb-3"> <ScrollView class="min-h-0 flex-1 w-full">
<div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2"> <div ref={listEl} class="flex min-h-full flex-col">
<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-fit w-full flex-col items-start gap-0.5 px-3.5 pb-3.5 pt-3">
{language.t("dialog.model.unpaid.freeModels.title")} <div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 pb-2 pt-1">
</div> <div class="flex h-5 flex-none flex-row items-center p-0 font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
</div> {language.t("dialog.model.unpaid.freeModels.title")}
<For each={freeModels()}>
{(item) => (
<TooltipV2
class="w-full"
placement="right-start"
gutter={6}
openDelay={0}
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
value={
<ModelTooltip
model={{ ...item, name: displayModelName(item.name) }}
latest={item.latest}
free={isFree(item)}
v2
/>
}
>
<button
type="button"
class="flex w-full scroll-my-3.5 flex-row items-center gap-1.5 rounded-md px-3 py-2 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-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => selectModel(item)}
>
<span class="min-w-0 truncate">{displayModelName(item.name)}</span>
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
<Show when={item.latest}>
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
</Show>
<Show when={currentKey() === modelKey(item)}>
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
</TooltipV2>
)}
</For>
</div>
<div class="flex w-full flex-col">
<div class="flex w-full flex-col items-start rounded-lg border-[0.5px] border-v2-border-border-muted bg-v2-background-bg-layer-02 p-2.5 pt-2">
<div class="flex h-8 w-full select-none items-center px-0.5 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]">
{language.t("dialog.model.unpaid.addMore.title")}
</div> </div>
</div> </div>
<div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2"> <For each={model.list()}>
<For {(item) => (
each={[...providers.popular()] <TooltipV2
.filter((provider) => featuredProviders.includes(provider.id)) class="w-full"
.sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))} placement="right-start"
> gutter={6}
{(provider) => ( openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item)} v2 />}
>
<button <button
type="button" type="button"
data-provider-id={provider.id} class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover 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" onClick={() => selectModel(item)}
classList={{
"border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
theme.mode() !== "dark",
"border-[0.5px] border-v2-border-border-strong": theme.mode() === "dark",
}}
onClick={() => openProviders(provider.id)}
> >
<ProviderIcon id={provider.id} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" /> <span class="min-w-0 truncate">{item.name}</span>
<span class="flex min-w-0 flex-col"> <Show when={isFree(item)}>
<span class="truncate">{provider.name}</span> <Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}> </Show>
<span class="truncate font-[440] text-v2-text-text-muted"> <Show when={item.latest}>
{language.t( <Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
provider.id === "opencode" </Show>
? "dialog.provider.opencode.tagline" <Show when={currentKey() === modelKey(item)}>
: "dialog.provider.opencodeGo.tagline", <Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
)} </Show>
</button>
</TooltipV2>
)}
</For>
</div>
<div class="flex w-full flex-col p-2.5 pt-0">
<div class="flex h-fit w-full flex-none grow-0 flex-col items-start gap-0.5 self-stretch rounded-lg bg-v2-background-bg-layer-02 p-1 shadow-[var(--v2-elevation-switch-off)]">
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 py-1.5">
<div class="flex h-5 flex-none flex-row items-center p-0 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.addMore.title")}
</div>
</div>
<div class="flex w-full flex-col">
<For
each={[...providers.popular()].sort((a, b) => {
if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) {
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
}
return a.name.localeCompare(b.name)
})}
>
{(provider) => (
<button
type="button"
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => openProviders(provider.id)}
>
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{provider.name}</span>
<Show when={provider.id === "opencode"}>
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.opencode.tagline")}
</span>
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={provider.id === "opencode-go"}>
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.opencodeGo.tagline")}
</span>
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={provider.id === "anthropic"}>
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.anthropic.note")}
</span> </span>
</Show> </Show>
</span> </button>
</button> )}
)} </For>
</For> <button
<button type="button"
type="button" class="flex h-9 w-full scroll-my-3.5 flex-row items-center justify-start gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
class="col-span-full flex h-8 w-full scroll-my-3.5 items-center justify-start rounded-md px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none" onClick={() => openProviders()}
onClick={() => openProviders()} >
> <span class="flex size-4 shrink-0 items-center justify-center text-v2-icon-icon-muted">
{language.t("dialog.model.unpaid.viewMoreProviders")} <Icon name="dot-grid" size="small" />
</button> </span>
<span class="min-w-0 truncate text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.viewAll")}
</span>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </ScrollView>
</DialogBody> </DialogBody>
</DialogV2> </DialogV2>
) )

View file

@ -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={() => {

View file

@ -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))

View file

@ -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()
} }

View file

@ -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/", [

View file

@ -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)

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

View file

@ -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>
)
}

View file

@ -8,21 +8,61 @@ import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
import homeImage from "@/assets/help/home.png" import homeImage from "@/assets/help/home.png"
import tabsImage from "@/assets/help/tabs.png" import tabsImage from "@/assets/help/tabs.png"
const helpIcon = (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
data-slot="icon-svg"
>
<path
d="M6.94235 10.5714V10.4854C6.94617 9.76302 7.01879 9.18777 7.16022 8.75968C7.30546 8.33158 7.50804 7.98567 7.76796 7.72193C8.02787 7.45819 8.34321 7.21548 8.71397 6.99379C8.93948 6.85619 9.14206 6.69374 9.32171 6.50645C9.50518 6.31916 9.64851 6.10511 9.75171 5.86431C9.85874 5.62351 9.91225 5.35404 9.91225 5.0559C9.91225 4.69661 9.82625 4.38509 9.65424 4.12136C9.48607 3.85762 9.26055 3.65504 8.9777 3.51362C8.69486 3.36837 8.38143 3.29575 8.03743 3.29575C7.73165 3.29575 7.43733 3.35882 7.15448 3.48495C6.87546 3.61108 6.6423 3.80984 6.45501 4.08122C6.26772 4.3526 6.15878 4.70425 6.12821 5.13617H4.56299C4.59357 4.47109 4.76557 3.9054 5.07899 3.43908C5.39242 2.96894 5.80522 2.61156 6.31741 2.36694C6.83341 2.12231 7.40675 2 8.03743 2C8.72161 2 9.31789 2.13378 9.82625 2.40134C10.3384 2.66507 10.734 3.0301 11.0131 3.49642C11.2959 3.96273 11.4373 4.49976 11.4373 5.1075C11.4373 5.53177 11.3724 5.914 11.2424 6.25418C11.1124 6.59436 10.9251 6.89823 10.6805 7.16579C10.4397 7.43335 10.1492 7.67033 9.80905 7.87673C9.48033 8.08313 9.21468 8.301 9.0121 8.53034C8.80952 8.75585 8.66237 9.02341 8.57063 9.33302C8.4789 9.64262 8.42921 10.0268 8.42156 10.4854V10.5714H6.94235ZM7.72782 14C7.43351 14 7.17933 13.8949 6.96528 13.6847C6.75506 13.4744 6.64994 13.2203 6.64994 12.9221C6.64994 12.6278 6.75506 12.3755 6.96528 12.1653C7.17933 11.9551 7.43351 11.85 7.72782 11.85C8.02214 11.85 8.27441 11.9551 8.48463 12.1653C8.69868 12.3755 8.8057 12.6278 8.8057 12.9221C8.8057 13.1209 8.75601 13.3024 8.65663 13.4668C8.55726 13.6273 8.4273 13.7573 8.26676 13.8567C8.10623 13.9522 7.92658 14 7.72782 14Z"
fill="var(--v2-icon-icon-base)"
/>
</svg>
)
const triggerClass =
"size-7 !rounded-full shrink-0 bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]"
// TODO: wire to changelog / seen-state when available // TODO: wire to changelog / seen-state when available
const showPopover = () => true const showPopover = () => true
export function HelpButton() {
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
const platform = usePlatform()
return (
<a
href="https://opencode.ai"
aria-label="Open the OpenCode website"
data-component="icon-button-v2"
data-size="large"
class={`${triggerClass} fixed bottom-5 right-5 z-50 flex items-center justify-center`}
onClick={(event) => {
event.preventDefault()
platform.openLink(event.currentTarget.href)
}}
>
{helpIcon}
</a>
)
}
// can remove this after the tabs rollout has been out for a while // can remove this after the tabs rollout has been out for a while
export function TabsInfoPopup() { export function TabsInfoPopup() {
const settings = useSettings() const settings = useSettings()
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">
<Show when={settings.general.shouldDisplayTabsToast()}> <Show when={settings.general.shouldDisplayTabsToast()}>
<div <div
class="fixed bottom-5 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]" class="fixed bottom-14 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
aria-label="Introducing Tabs. Organize your work and active sessions with tabs" aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
> >
<button <button
@ -71,49 +111,19 @@ 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() <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">
? { June 16
inset: "0 0 0 auto", </p>
"max-height": "100vh",
"max-width": "100vw",
"border-radius": "0",
}
: undefined
}
>
<Show when={windows()}>
<DrawerClose <DrawerClose
as={IconButtonV2} as={IconButtonV2}
type="button" type="button"
size="small" size="small"
variant="neutral" variant="ghost-muted"
aria-label="Close" aria-label="Close"
icon={<IconV2 name="xmark-small" />} icon={<IconV2 name="xmark-small" />}
class="absolute top-[10px] left-[-36px]"
/> />
</Show>
<div
class="flex w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted"
classList={{
"h-[40px] px-4": windows(),
"h-[52px] p-4": !windows(),
}}
>
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
July 14
</p>
<Show when={!windows()}>
<DrawerClose
as={IconButtonV2}
type="button"
size="small"
variant="ghost-muted"
aria-label="Close"
icon={<IconV2 name="xmark-small" />}
/>
</Show>
</div> </div>
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8"> <div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base"> <p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
@ -137,7 +147,7 @@ export function TabsInfoPopup() {
<p>When you reopen the app, your tabs are still open.</p> <p>When you reopen the app, your tabs are still open.</p>
<p> <p>
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout the previous layout , you can switch between layouts in Settings. Just keep in mind that the new layout
will become permanent in a few weeks. will become permanent in a few weeks.
</p> </p>
</div> </div>

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>
)
}

View file

@ -1,586 +0,0 @@
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
import { createEffect, createMemo, on, Show } from "solid-js"
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
import type { PromptInputProps } from "@/components/prompt-input/contracts"
import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history"
import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store"
import { promptDesignPlaceholder, promptPlaceholder } from "@/components/prompt-input/placeholder"
import { createPromptSubmit } from "@/components/prompt-input/submit"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
import { useComments } from "@/context/comments"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePermission } from "@/context/permission"
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { createSessionTabs } from "@/pages/session/helpers"
import { showToast } from "@/utils/toast"
import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
import {
createPromptInputV2Controller,
createPromptInputV2State,
type PromptInputV2Interaction,
} from "@opencode-ai/session-ui/v2/prompt-input/interaction"
export type PromptInputV2ComposerProps = {
class?: string
controller: PromptInputV2ComposerController
borderUnderlay?: boolean
}
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
readonly model: PromptInputProps["controls"]["model"]
}
export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
const dialog = useDialog()
const command = useCommand()
const language = useLanguage()
return (
<div class="flex flex-col gap-3">
<PromptInputV2
controller={props.controller}
borderUnderlay={props.borderUnderlay}
class={props.class}
variantControlVisible={!props.controller.model.loading}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
modelControl={
<PromptInputV2ModelControl
loading={props.controller.model.loading}
paid={props.controller.model.paid}
title={language.t("command.model.choose")}
keybind={command.keybindParts("model.choose")}
model={props.controller.model.selection}
providerID={props.controller.model.selection.current()?.provider?.id}
modelName={props.controller.model.selection.current()?.name ?? language.t("dialog.model.select.title")}
onClose={props.controller.restoreFocus}
onUnpaidClick={() =>
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controller.model.selection} />)
}
/>
}
/>
</div>
)
}
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
const sdk = useSDK()
const sync = useSync()
const files = useFile()
const layout = useLayout()
const comments = useComments()
const dialog = useDialog()
const command = useCommand()
const permission = usePermission()
const language = useLanguage()
const platform = usePlatform()
const prompt = props.state ?? usePrompt()
let editor: HTMLDivElement | undefined
const interaction = createPromptInputV2State()
const mode = () => interaction[0].mode
const history = props.history ?? createPersistedPromptInputHistory()
const tabs = () => props.controls.session.tabs
const activeFileTab = createSessionTabs({
tabs,
pathFromTab: files.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
}).activeFileTab
const recent = createMemo(() => {
const all = tabs().all()
const active = activeFileTab()
const order = active ? [active, ...all.filter((tab) => tab !== active)] : all
return order.reduce<string[]>((result, tab) => {
const path = files.pathFromTab(tab)
if (!path || result.includes(path)) return result
return [...result, path]
}, [])
})
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
const attachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
const commentCount = createMemo(() => {
if (mode() === "shell") return 0
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
})
const blank = createMemo(() => {
const text = prompt
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0
})
const stopping = createMemo(() => working() && blank())
const placeholder = createMemo(() =>
promptPlaceholder({
mode: mode(),
commentCount: commentCount(),
example: mode() === "shell" ? "git status" : "",
suggest: false,
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
}),
)
const designPlaceholder = () => promptDesignPlaceholder(mode(), placeholder())
const historyComments = () => {
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
return prompt.context.items().flatMap((item) => {
const comment = item.comment?.trim()
if (!comment) return []
const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
const nextSelection =
selection ??
(item.selection
? ({ start: item.selection.startLine, end: item.selection.endLine } satisfies SelectedLineRange)
: undefined)
if (!nextSelection) return []
return [
{
id: item.commentID ?? item.key,
path: item.path,
selection: { ...nextSelection },
comment,
time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(),
origin: item.commentOrigin,
preview: item.preview,
} satisfies PromptHistoryComment,
]
})
}
const restoreHistoryComments = (items: PromptHistoryComment[]) => {
comments.replace(
items.map((item) => ({
id: item.id,
file: item.path,
selection: { ...item.selection },
comment: item.comment,
time: item.time,
})),
)
prompt.context.replaceComments(
items.map((item) => ({
type: "file",
path: item.path,
selection: selectionFromLines(item.selection),
comment: item.comment,
commentID: item.id,
commentOrigin: item.origin,
preview: item.preview,
})),
)
}
const accepting = createMemo(() => {
const id = props.controls.session.id
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
return permission.isAutoAccepting(id, sdk().directory)
})
const submission = createPromptSubmit({
prompt,
info,
imageAttachments: attachments,
commentCount,
autoAccept: accepting,
mode,
working,
editor: () => editor,
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
promptLength,
addToHistory: (value, mode) => controller.addHistory(value, mode),
resetHistoryNavigation: () => controller.resetHistory(),
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
setPopover: (popover) => {
if (!popover) controller.dispatch({ type: "popover.close" })
},
newSessionWorktree: () => props.newSessionWorktree,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
shouldQueue: props.shouldQueue,
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
model: props.controls.model.selection,
})
const referenceDescription = (reference: ReferenceInfo) =>
reference.source.type === "git" ? reference.source.repository : reference.source.path
const references = createMemo(() =>
sync()
.data.reference.filter((reference) => !reference.hidden)
.map((reference) => ({
id: `reference:${reference.name}`,
kind: "reference" as const,
label: `@${reference.name}`,
path: reference.path,
description: reference.description ?? referenceDescription(reference),
mention: {
type: "file" as const,
path: reference.path,
content: `@${reference.name}`,
start: 0,
end: 0,
mime: "application/x-directory",
filename: reference.name,
},
})),
)
const resources = createMemo(() =>
Object.values(sync().data.mcp_resource).map((resource) => ({
id: `resource:${resource.server}:${resource.uri}`,
kind: "resource" as const,
label: `@${resource.name}`,
path: resource.uri,
description: resource.description,
mention: {
type: "file" as const,
path: resource.uri,
content: `@${resource.name}`,
start: 0,
end: 0,
mime: resource.mimeType ?? "text/plain",
filename: resource.name,
url: resource.uri,
source: {
type: "resource" as const,
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
clientName: resource.server,
uri: resource.uri,
},
},
resource,
})),
)
const context = createMemo<PromptInputV2Suggestion[]>(() => [
...references(),
...props.controls.agents.available
.filter((agent) => !agent.hidden && agent.mode !== "primary")
.map((agent) => ({
id: `agent:${agent.name}`,
kind: "agent" as const,
label: `@${agent.name}`,
mention: { type: "agent" as const, name: agent.name, content: `@${agent.name}`, start: 0, end: 0 },
})),
...resources(),
...recent().map((path) => ({
id: `file:${path}`,
kind: "file" as const,
label: path,
path,
recent: true,
mention: { type: "file" as const, path, content: `@${path}`, start: 0, end: 0 },
})),
])
const slashCommands = createMemo(() => [
...sync().data.command.map((item) => ({
id: `custom.${item.name}`,
trigger: item.name,
title: item.name,
description: item.description,
type: "custom" as const,
})),
...command.options
.filter((item) => !item.disabled && !item.id.startsWith("suggested.") && item.slash)
.map((item) => ({
id: item.id,
trigger: item.slash!,
title: item.title,
description: item.description,
type: "builtin" as const,
})),
])
const commands = createMemo<PromptInputV2Suggestion[]>(() =>
slashCommands().map((item) => ({
id: item.id,
kind: "command",
label: `/${item.trigger}`,
trigger: item.trigger,
title: item.title,
description: item.description,
keybind: command.keybindParts(item.id),
})),
)
const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
const controller = createPromptInputV2Controller({
store: () => prompt.capture().store,
state: interaction,
identity: () => prompt.capture(),
history: {
entries: (mode) =>
history.entries(mode).map((value) => {
const entry = normalizePromptHistoryEntry(value)
return { prompt: entry.prompt, metadata: entry.comments }
}),
add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()),
capture: historyComments,
restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]),
},
commands,
context,
searchContextFiles: async (query) =>
(await files.searchFilesAndDirectories(query)).map((path) => ({
id: `file:${path}`,
kind: "file",
label: path,
path,
mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 },
})),
onContextRemove(item) {
if (item?.commentID) comments.remove(item.path, item.commentID)
},
openAttachment: (attachment) =>
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />),
openContext(key) {
const item = controller.contextItem(key)
if (item) openComment(item, props, sync, layout, files, comments)
},
onEditor(element) {
editor = element as HTMLDivElement
props.ref?.(editor)
},
onSuggestionSelect(item) {
if (item.kind !== "command") return
const selected = slashCommands().find((entry) => entry.id === item.id)
if (!selected || selected.type === "custom") return
return () => command.trigger(selected.id, "slash")
},
attachments: {
picker: platform.openAttachmentPickerDialog,
directory: () => sdk().directory,
isDialogActive: () => !!dialog.active,
warn: () =>
showToast({
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
}),
duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
onError: (error) =>
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
}),
readClipboardImage: platform.readClipboardImage,
getPathForFile: platform.getPathForFile,
store: platform.draftStore?.putBlob,
},
view: {
placeholder: designPlaceholder,
get agent() {
return props.controls.agents.visible && props.controls.agents.options.length > 0
? {
options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
current: () => props.controls.agents.current,
onSelect: (value: string) => props.controls.agents.select(value),
keybind: () => command.keybindParts("agent.cycle"),
}
: undefined
},
variant: {
options: () => variants().map((value) => ({ id: value, label: value })),
current: () => props.controls.model.selection.variant.current() ?? "default",
onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value),
keybind: () => command.keybindParts("model.variant.cycle"),
},
submit: {
stopping,
working,
onSubmit: () => void submission.handleSubmit(new Event("submit")),
onStop: () => void submission.abort(),
},
},
})
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
}
function PromptInputV2ModelControl(props: {
loading: boolean
paid: boolean
title: string
keybind: string[]
model: PromptInputV2ComposerController["model"]["selection"]
providerID?: string
modelName: string
onClose: () => void
onUnpaidClick: () => void
}) {
const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
const content = () => (
<>
<Show when={props.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
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 leading-4">{props.modelName}</span>
<span class="-ml-0.5 -mr-1 flex shrink-0">
<Icon name="chevron-down" />
</span>
</>
)
return (
<Show when={!props.loading}>
<TooltipV2
placement="top"
gutter={4}
value={
<>
{props.title}
<KeybindV2 keys={props.keybind} variant="neutral" />
</>
}
>
<Show
when={props.paid}
fallback={
<ButtonV2
data-action="prompt-model"
data-control-type="dialog"
variant="ghost-muted"
size="normal"
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
classList={{ "animate-in fade-in": shouldAnimate() }}
style={{ height: "28px" }}
onClick={props.onUnpaidClick}
>
{content()}
</ButtonV2>
}
>
<ModelSelectorPopoverV2
model={props.model}
trigger={(triggerProps) => (
<ButtonV2
{...triggerProps}
variant="ghost-muted"
size="normal"
style={{ height: "28px" }}
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
classList={{ "animate-in fade-in": shouldAnimate() }}
data-action="prompt-model"
data-control-type="popover"
>
{content()}
</ButtonV2>
)}
onClose={props.onClose}
/>
</Show>
</TooltipV2>
</Show>
)
}
function openComment(
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
props: PromptInputV2ControllerProps,
sync: ReturnType<typeof useSync>,
layout: ReturnType<typeof useLayout>,
files: ReturnType<typeof useFile>,
comments: ReturnType<typeof useComments>,
) {
if (!item.commentID) return
const focus = { file: item.path, id: item.commentID }
comments.setActive(focus)
const queueFocus = (attempts = 6) => {
requestAnimationFrame(() => {
comments.setFocus({ ...focus })
if (attempts <= 0) return
requestAnimationFrame(() => {
const current = comments.focus()
if (current?.file === focus.file && current.id === focus.id) queueFocus(attempts - 1)
})
})
}
const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined
const review =
item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path))
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
if (review) {
layout.fileTree.setTab("changes")
props.controls.session.tabs.setActive("review")
queueFocus()
return
}
layout.fileTree.setTab("all")
const tab = files.tab(item.path)
void props.controls.session.tabs.open(tab)
props.controls.session.tabs.setActive(tab)
void Promise.resolve(files.load(item.path)).finally(() => queueFocus())
}

View file

@ -30,16 +30,8 @@ function PromptInputExample() {
activeTab: undefined as string | undefined, activeTab: undefined as string | undefined,
reviewOpen: false, reviewOpen: false,
}) })
const storyModel = {
id: "claude-3-7-sonnet",
name: "Claude 3.7 Sonnet",
provider: { id: "anthropic", name: "Anthropic" },
}
const model = { const model = {
current: () => storyModel, current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
list: () => [storyModel],
visible: () => true,
set: () => {},
variant: { variant: {
list: () => ["fast", "thinking"], list: () => ["fast", "thinking"],
current: () => controls.variant, current: () => controls.variant,
@ -75,6 +67,7 @@ function PromptInputExample() {
open: () => setControls("reviewOpen", true), open: () => setControls("reviewOpen", true),
}, },
}, },
newLayoutDesigns: true,
} }
const addReviewComment = () => { const addReviewComment = () => {
const comment = controls.comments + 1 const comment = controls.comments + 1
@ -153,6 +146,7 @@ function PromptInputWithOpenDock() {
}, },
reviewPanel: { opened: () => false, open: () => {} }, reviewPanel: { opened: () => false, open: () => {} },
}, },
newLayoutDesigns: true,
} }
const state = { const state = {
blocked: () => false, blocked: () => false,

File diff suppressed because it is too large Load diff

View file

@ -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,10 +36,9 @@ 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 = { type PromptAttachmentsInput = {
prompt: ReturnType<typeof usePrompt> prompt: ReturnType<typeof usePrompt>
editor: () => HTMLDivElement | undefined editor: () => HTMLDivElement | undefined
isDialogActive: () => boolean isDialogActive: () => boolean
@ -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({

View file

@ -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)

View file

@ -1,57 +0,0 @@
import type { useLocal } from "@/context/local"
import type { Prompt, usePrompt } from "@/context/prompt"
import type { PromptInputHistory } from "./history-store"
import type { FollowupDraft } from "./submit"
export type PromptInputState = ReturnType<typeof usePrompt>
export type PromptInputSubmission = {
abort: () => Promise<void> | void
handleSubmit: (event: Event) => Promise<void> | void
}
export type PromptInputControls = {
agents: {
available: { name: string; hidden?: boolean; mode: string }[]
options: string[]
current: string
loading: boolean
visible: boolean
select: (name: string | undefined) => void
}
model: {
selection: ReturnType<typeof useLocal>["model"]
paid: boolean
loading: boolean
}
session: {
id?: string
tabs: {
active: () => string | undefined
all: () => string[]
open: (tab: string) => void | Promise<void>
setActive: (tab: string) => void
}
reviewPanel: {
opened: () => boolean
open: () => void
}
}
}
export interface PromptInputProps {
class?: string
state?: PromptInputState
history?: PromptInputHistory
submission?: PromptInputSubmission
controls: PromptInputControls
ref?: (el: HTMLDivElement) => void
newSessionWorktree?: string
onNewSessionWorktreeReset?: () => void
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
onEditLoaded?: () => void
shouldQueue?: () => boolean
onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void
onSubmit?: () => void
}

View file

@ -1,63 +0,0 @@
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import type { Prompt } from "@/context/prompt"
import { Persist, persisted } from "@/utils/persist"
import {
clonePromptHistoryComments,
clonePromptParts,
prependHistoryEntry,
type PromptHistoryComment,
type PromptHistoryStoredEntry,
} from "./history"
export type PromptInputHistory = {
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
}
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
function createPromptInputHistoryStore(
normal: Store<PromptHistoryState>,
setNormal: SetStoreFunction<PromptHistoryState>,
shell: Store<PromptHistoryState>,
setShell: SetStoreFunction<PromptHistoryState>,
): PromptInputHistory {
return {
entries: (mode) => (mode === "shell" ? shell.entries : normal.entries),
add(prompt, mode, comments) {
const current = mode === "shell" ? shell : normal
const setCurrent = mode === "shell" ? setShell : setNormal
const next = prependHistoryEntry(current.entries, prompt, comments)
if (next === current.entries) return
setCurrent("entries", next)
},
}
}
export function createPromptInputHistory(): PromptInputHistory {
const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
}
export function createPersistedPromptInputHistory() {
const [normal, setNormal, normalInit] = persisted(
Persist.prompt(Persist.global("prompt-history", ["prompt-history.v1"])),
createStore<PromptHistoryState>({ entries: [] }),
)
const [shell, setShell, shellInit] = persisted(
Persist.prompt(Persist.global("prompt-history-shell", ["prompt-history-shell.v1"])),
createStore<PromptHistoryState>({ entries: [] }),
)
const history = createPromptInputHistoryStore(normal, setNormal, shell, setShell)
return {
...history,
add(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
const ready = mode === "shell" ? shellInit : normalInit
if (!(ready instanceof Promise)) return history.add(prompt, mode, comments)
const saved = clonePromptParts(prompt)
const metadata = clonePromptHistoryComments(comments)
void ready.then(() => history.add(saved, mode, metadata))
},
}
}

View file

@ -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)

View file

@ -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)}

View file

@ -13,8 +13,3 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
if (!input.suggest) return input.t("prompt.placeholder.simple") if (!input.suggest) return input.t("prompt.placeholder.simple")
return input.t("prompt.placeholder.normal", { example: input.example }) return input.t("prompt.placeholder.normal", { example: input.example })
} }
export function promptDesignPlaceholder(mode: PromptPlaceholderInput["mode"], placeholder: string) {
if (mode === "shell") return placeholder
return "Ask anything, / for commands, @ for context..."
}

View file

@ -1,17 +1,11 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import { createStore } from "solid-js/store" import type { Prompt } from "@/context/prompt"
import type { Prompt, PromptStore } from "@/context/prompt"
import type { ModelSelection } from "@/context/local" import type { ModelSelection } from "@/context/local"
let createPromptSubmit: typeof import("./submit").createPromptSubmit 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 +19,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,14 +30,8 @@ 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>({
prompt: promptValue,
cursor: 0,
context: { items: [] },
})
const prompt = { const prompt = {
store: [() => promptStore, setPromptStore] as [() => PromptStore, typeof setPromptStore],
ready: Object.assign(() => true, { promise: Promise.resolve(true) }), ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
current: () => promptValue, current: () => promptValue,
cursor: () => 0, cursor: () => 0,
@ -73,39 +56,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 +165,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 +176,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 +203,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 +244,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 +257,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 +290,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 +412,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 +420,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 +480,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])
}) })
}) })

View file

@ -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,

View file

@ -12,6 +12,7 @@ export type PromptInputTransientState = {
draggingType: "image" | "@mention" | null draggingType: "image" | "@mention" | null
mode: "normal" | "shell" mode: "normal" | "shell"
applyingHistory: boolean applyingHistory: boolean
variantOpen: boolean
} }
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) { function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
@ -24,6 +25,7 @@ function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTr
draggingType: null, draggingType: null,
mode: "normal", mode: "normal",
applyingHistory: false, applyingHistory: false,
variantOpen: false,
}) })
} }
@ -38,6 +40,7 @@ export function createPromptInputTransientState(identity: Accessor<unknown>, pla
draggingType: null, draggingType: null,
mode: "normal", mode: "normal",
applyingHistory: false, applyingHistory: false,
variantOpen: false,
}) })
createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true })) createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))

View file

@ -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
@ -56,7 +55,7 @@ export function createPromptProjectController(input: {
const [store, setStore] = createStore({ open: false, search: "", active: "" }) const [store, setStore] = createStore({ open: false, search: "", active: "" })
let searchRef: HTMLInputElement | undefined let searchRef: HTMLInputElement | undefined
const current = () => { const selected = () => {
const key = pathKey(input.controls().directory) const key = pathKey(input.controls().directory)
return input return input
.controls() .controls()
@ -66,7 +65,6 @@ export function createPromptProjectController(input: {
(pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)), (pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
) )
} }
const selected = () => current() ?? input.controls().available[0]
const projects = () => { const projects = () => {
const search = store.search.trim().toLowerCase() const search = store.search.trim().toLowerCase()
if (!search) return input.controls().available if (!search) return input.controls().available
@ -102,8 +100,8 @@ export function createPromptProjectController(input: {
} }
const select = (project: PromptProject) => { const select = (project: PromptProject) => {
if ( if (
pathKey(project.worktree) !== pathKey(current()?.worktree ?? "") || pathKey(project.worktree) !== pathKey(selected()?.worktree ?? "") ||
project.server?.key !== current()?.server?.key project.server?.key !== selected()?.server?.key
) { ) {
input.controls().select(project.worktree, project.server?.key) input.controls().select(project.worktree, project.server?.key)
} }
@ -126,7 +124,6 @@ export function createPromptProjectController(input: {
return { return {
selected, selected,
empty: () => input.controls().available.length === 0,
projects, projects,
servers, servers,
projectKey, projectKey,
@ -198,8 +195,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 +219,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 +265,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 +289,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 +298,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 +362,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">

View file

@ -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>
)
}

View file

@ -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>

View file

@ -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"

View file

@ -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={

View file

@ -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>
)
}

View file

@ -2,8 +2,7 @@ import { createMemo, Show } from "solid-js"
import type { JSX } from "solid-js" import type { JSX } from "solid-js"
import { useSortable } from "@dnd-kit/solid/sortable" import { useSortable } from "@dnd-kit/solid/sortable"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@ -20,7 +19,6 @@ export function SortableTabV2(props: {
const file = useFile() const file = useFile()
const language = useLanguage() const language = useLanguage()
const command = useCommand() const command = useCommand()
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
const sortable = useSortable({ const sortable = useSortable({
get id() { get id() {
return props.tab return props.tab
@ -41,15 +39,9 @@ export function SortableTabV2(props: {
<Tabs.Trigger <Tabs.Trigger
value={props.tab} value={props.tab}
closeButton={ closeButton={
<TooltipV2 <TooltipKeybind
value={ title={language.t("common.closeTab")}
<> keybind={command.keybind("tab.close")}
{language.t("common.closeTab")}
<Show when={closeTabKeybind().length > 0}>
<KeybindV2 keys={closeTabKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom" placement="bottom"
gutter={10} gutter={10}
> >
@ -60,7 +52,7 @@ export function SortableTabV2(props: {
onClick={() => props.onTabClose(props.tab)} onClick={() => props.onTabClose(props.tab)}
aria-label={language.t("common.closeTab")} aria-label={language.t("common.closeTab")}
/> />
</TooltipV2> </TooltipKeybind>
} }
hideCloseButton hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)} onMiddleClick={() => props.onTabClose(props.tab)}

View file

@ -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>
</> </>
} }
> >

View file

@ -10,7 +10,7 @@ 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 { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command" import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { SettingsList } from "./settings-list" import { SettingsList } from "./settings-list"
@ -20,6 +20,7 @@ const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette" const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt" type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt"
@ -30,8 +31,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 +123,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 +263,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 +477,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>
) )
} }

View file

@ -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

View file

@ -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 />

View file

@ -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 }
}

View file

@ -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()
}
})
})

View file

@ -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>

View file

@ -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 />

View file

@ -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>

View file

@ -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()}>

View file

@ -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);
} }
@ -372,65 +371,15 @@
transform: translateY(-50%); transform: translateY(-50%);
} }
.settings-v2-models { .settings-v2-tab-body.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;
} }

View file

@ -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>
) )

View file

@ -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)
})
})

View file

@ -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"

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

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) => {

View file

@ -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
} }

View file

@ -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"])

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"

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