Compare commits

..
Author SHA1 Message Date
iamdavidhill b8f34df601 chore(console): refresh GitHub star fallback 2026-09-12 13:23:43 +00:00
iamdavidhill bec2e6c6ce chore(stats): automate GitHub star fallback 2026-09-12 13:19:26 +00:00
iamdavidhill 1ad0442c39 chore(stats): update GitHub star fallback 2026-09-12 13:11:18 +00:00
opencode 7c5a4d01aa sync release versions for v2.0.2 2026-09-12 07:57:20 +00:00
David Hill 4143084250 feat(app): polish summary panels and config actions (#48445) 2026-09-12 17:42:04 +10:00
opencode-agent[bot] c7868ce0e6 chore: update nix node_modules hashes 2026-09-12 07:28:31 +00:00
Dax Raad cf4f1fb45e fix(tui): remove preview session tabs 2026-09-12 03:22:32 -04:00
Aiden Cline 0a21042523 fix(tools): clarify unavailable tool errors (#48620) 2026-09-12 02:20:04 -05:00
Aiden Cline 6add96698e refactor(codemode): move built-ins onto real prototypes with native function objects (#48608) 2026-09-12 02:19:30 -05:00
opencode-agent[bot]andrekram1-node 3f579f53f4 feat(core): add TinyFish web search (#48561)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-12 02:10:54 -05:00
David Hill 9f3ba44c4a fix(app): keep summaries clear of the session timeline (#48449) 2026-09-12 17:07:55 +10:00
Luke Parker c4fe0f676a fix(app): polyfill startup APIs for older Safari (#48621) 2026-09-12 17:07:33 +10:00
Brendan AllanandBrendonovich 625c469854 fix(server): return 404 for missing files (#44089)
Co-authored-by: Brendonovich <Brendonovich@users.noreply.github.com>
2026-09-12 14:55:10 +08:00
usrnk1andLukeParkerDev d6c22b3bf5 feat(desktop): consolidate status into session summary (#48103)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-12 06:09:16 +00:00
bbf4cd4975 fix(desktop): polish settings search (#48210)
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-12 05:27:23 +00:00
Dax Raad 27027777d2 docs: expand V1 plugin migration guide 2026-09-12 01:12:27 -04:00
usrnk1andLukeParkerDev 5d1841e0aa feat(desktop): redesign settings with search (#48174)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-12 05:05:48 +00:00
opencode 2253d4c31d sync release versions for v2.0.1 2026-09-12 05:01:52 +00:00
usrnk1andLukeParkerDev 877b04f0b9 feat(desktop): redesign settings navigation and project editing (#47983)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-12 04:36:17 +00:00
Luke Parker 41e5d1b6b6 fix(session-ui): keep divider notices out of used groups (#48594) 2026-09-12 03:58:18 +00:00
Luke Parker 60fb97d59e fix(desktop): preserve keyboard focus during browser navigation (#48593) 2026-09-12 03:54:22 +00:00
Aiden Cline b2e6e764a8 refactor(codemode): give errors a real prototype chain and JS error types (#48559) 2026-09-11 21:45:29 -05:00
Aiden Cline 0d8e64112f fix(tui): defer named-theme palette detection (#48570) 2026-09-11 21:42:47 -05:00
Aiden Cline b0b85cbb77 fix(ai): omit empty Bedrock tool descriptions (#48582) 2026-09-11 21:18:57 -05:00
Dax Raad 0f26ad8787 fix(cli): keep opencode2 as a working alias 2026-09-11 20:55:03 -04:00
Dax Raad b08ad82518 fix(cli): clarify opencode2 rename guidance 2026-09-11 20:50:20 -04:00
Dax 5319f063d6 docs: use stable V2 packages (#48576) 2026-09-11 20:33:18 -04:00
Dax 63f7ceecbe fix(release): use V2 Docker artifact paths (#48571) 2026-09-11 19:46:37 -04:00
Dax 770420bdad fix(release): omit node CLI from latest (#48568) 2026-09-11 19:10:23 -04:00
Dax 4994a7d694 fix(release): sign primary Windows CLI (#48567) 2026-09-11 19:08:34 -04:00
Dax c55ee2a815 fix(release): sign v2 Windows CLI (#48566) 2026-09-11 18:55:07 -04:00
Dax 312651f68e feat(tui): configure session permission handling (#48545) 2026-09-11 19:50:39 +00:00
Dax 654f411a5b feat(release): isolate v2 stable publishing (#48546) 2026-09-11 15:50:35 -04:00
Aiden Cline 68b862da49 refactor(codemode): make functions program objects with name and length (#48541) 2026-09-11 14:50:07 -05:00
Dax 8f2b13e864 fix(tui): display slash command aliases (#48544) 2026-09-11 15:34:33 -04:00
Dax bb564f96a3 feat(cli): rename command to opencode 2026-09-11 15:03:45 -04:00
Aiden Cline a555f24ded refactor: flatten provider settings across core and the AI package (#48429) 2026-09-11 13:37:24 -05:00
Aiden Cline 10ae321bf3 refactor(codemode): give programs their own objects and arrays (#48527) 2026-09-11 13:36:35 -05:00
Aiden Cline 6e8ff0c2b7 refactor(core): remove dead AISDK hooks (#48523) 2026-09-11 12:41:20 -05:00
Aiden Cline 923bda07a2 feat(ai): always send interleaved thinking beta for Anthropic (#48521) 2026-09-11 12:29:18 -05:00
Aiden Cline 4c34ee5eab test(codemode): run test262 files verbatim from a local checkout (#48446) 2026-09-11 10:40:02 -05:00
Shoubhit Dash 2df00955cb fix(cli): preserve ACP reasoning boundaries and effort selections (#48493) 2026-09-11 18:46:54 +05:30
opencode-agent[bot]andnexxeln e0deffa083 docs(plugin): fix Effect system part example (#48492)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-11 18:12:11 +05:30
Wu Shuwen 4e97b78d98 docs: fix v2 plugin system example (#48452) 2026-09-11 18:12:01 +05:30
482 changed files with 16960 additions and 9287 deletions
+21 -52
View File
@@ -25,7 +25,7 @@ on:
required: false
type: string
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version || inputs.bump }}
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ (github.ref_name == 'v2' && (inputs.version || inputs.bump) && 'release') || inputs.version || inputs.bump }}
permissions:
id-token: write
@@ -33,7 +33,7 @@ permissions:
packages: write
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'dev') || '' }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && !inputs.bump && !inputs.version && 'dev') || '' }}
jobs:
version:
@@ -168,7 +168,7 @@ jobs:
fi
found=0
for file in packages/cli/dist/cli-darwin-*/bin/opencode2; do
for file in packages/cli/dist/cli-darwin-*/bin/opencode; do
if [ ! -f "$file" ]; then
continue
fi
@@ -191,7 +191,7 @@ jobs:
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli
name: opencode-preview-cli-macos
path: packages/cli/dist/cli-*
if-no-files-found: error
@@ -199,7 +199,7 @@ jobs:
needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 30
if: github.repository == 'anomalyco/opencode'
if: github.repository == 'anomalyco/opencode' && !(github.ref_name == 'v2' && (inputs.bump || inputs.version))
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
@@ -221,7 +221,7 @@ jobs:
needs:
- version
- build-node-app-archive
if: github.repository == 'anomalyco/opencode'
if: github.repository == 'anomalyco/opencode' && !(github.ref_name == 'v2' && (inputs.bump || inputs.version))
strategy:
fail-fast: false
matrix:
@@ -276,10 +276,9 @@ jobs:
sign-cli-windows:
needs:
- build-cli
- version
- sign-cli-macos
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' && github.ref_name != 'beta'
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'v2' || github.ref_name == 'beta')
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -292,15 +291,8 @@ jobs:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: opencode-cli-windows
path: packages/opencode/dist
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
name: opencode-preview-cli-macos
path: packages/cli/dist
- name: Azure login
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
@@ -315,9 +307,9 @@ jobs:
signing-account-name: ${{ env.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
certificate-profile-name: ${{ env.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
files: |
${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe
${{ github.workspace }}\packages\cli\dist\cli-windows-arm64\bin\opencode.exe
${{ github.workspace }}\packages\cli\dist\cli-windows-x64\bin\opencode.exe
${{ github.workspace }}\packages\cli\dist\cli-windows-x64-baseline\bin\opencode.exe
exclude-environment-credential: true
exclude-workload-identity-credential: true
exclude-managed-identity-credential: true
@@ -333,9 +325,9 @@ jobs:
shell: pwsh
run: |
$files = @(
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe",
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe",
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe"
"${{ github.workspace }}\packages\cli\dist\cli-windows-arm64\bin\opencode.exe",
"${{ github.workspace }}\packages\cli\dist\cli-windows-x64\bin\opencode.exe",
"${{ github.workspace }}\packages\cli\dist\cli-windows-x64-baseline\bin\opencode.exe"
)
foreach ($file in $files) {
@@ -345,40 +337,17 @@ jobs:
}
}
- name: Repack Windows CLI archives
working-directory: packages/opencode/dist
shell: pwsh
run: |
Compress-Archive -Path "opencode-windows-arm64\bin\*" -DestinationPath "opencode-windows-arm64.zip" -Force
Compress-Archive -Path "opencode-windows-x64\bin\*" -DestinationPath "opencode-windows-x64.zip" -Force
Compress-Archive -Path "opencode-windows-x64-baseline\bin\*" -DestinationPath "opencode-windows-x64-baseline.zip" -Force
- name: Upload signed Windows CLI release assets
if: needs.version.outputs.release != ''
shell: pwsh
env:
GH_TOKEN: ${{ steps.committer.outputs.token }}
run: |
gh release upload "v${{ needs.version.outputs.version }}" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64.zip" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64.zip" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline.zip" `
--clobber `
--repo "${{ needs.version.outputs.repo }}"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-cli-signed-windows
path: |
packages/opencode/dist/opencode-windows-arm64
packages/opencode/dist/opencode-windows-x64
packages/opencode/dist/opencode-windows-x64-baseline
name: opencode-preview-cli
path: packages/cli/dist/cli-*
if-no-files-found: error
build-electron:
needs:
- version
- sign-cli-macos
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
- sign-cli-windows
if: github.repository == 'anomalyco/opencode' && (github.ref_name != 'v2' || needs.version.outputs.release != '')
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
+6 -1
View File
@@ -24,11 +24,16 @@ jobs:
- name: Run stats script
run: bun script/stats.ts
- name: Update GitHub stars
run: bun script/update-github-stars.ts
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Commit stats
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add STATS.md
git add STATS.md packages/console/app/src/config.ts packages/stats/app/src/routes/stats-shell.tsx
git diff --staged --quiet || git commit -m "ignore: update download stats $(date -I)"
git push
env:
+2 -2
View File
@@ -8,9 +8,9 @@
## Live V2 TUI Testing
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode2` background server and live sessions.
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode` background server and live sessions.
- Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`.
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `dev` TUI storage channel so tabs and other client-local state match the installed client.
- The script discovers the server with `opencode service status`, injects its private local credential from `opencode service get password`, and uses the `dev` TUI storage channel so tabs and other client-local state match the installed client.
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
## V2 TUI Stories
+38 -35
View File
@@ -31,7 +31,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "1.17.20",
"version": "2.0.2",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -53,7 +53,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "1.18.15",
"version": "2.0.2",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -82,6 +82,7 @@
"@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:",
"core-js": "3.50.0",
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
@@ -111,7 +112,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "1.18.4",
"version": "2.0.2",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -175,7 +176,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "1.17.13",
"version": "2.0.2",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -201,7 +202,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "1.18.4",
"version": "2.0.2",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -215,7 +216,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "1.18.15",
"version": "2.0.2",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -251,7 +252,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "1.18.15",
"version": "2.0.2",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -278,7 +279,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "1.18.15",
"version": "2.0.2",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -295,7 +296,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "1.18.15",
"version": "2.0.2",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -319,7 +320,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "1.18.15",
"version": "2.0.2",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -339,7 +340,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "1.18.4",
"version": "2.0.2",
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/anthropic": "3.0.82",
@@ -411,7 +412,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "1.18.15",
"version": "2.0.2",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
@@ -463,7 +464,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "1.18.15",
"version": "2.0.2",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -500,7 +501,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "1.18.15",
"version": "2.0.2",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -516,7 +517,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "1.18.15",
"version": "2.0.2",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -535,7 +536,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "0.0.0",
"version": "2.0.2",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -548,7 +549,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "0.0.0",
"version": "2.0.2",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -562,7 +563,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "0.0.0",
"version": "2.0.2",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -577,7 +578,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "1.18.15",
"version": "2.0.2",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -616,7 +617,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "0.0.0",
"version": "2.0.2",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -646,7 +647,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "1.17.11",
"version": "2.0.2",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -661,7 +662,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "1.17.11",
"version": "2.0.2",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -685,7 +686,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "1.18.4",
"version": "2.0.2",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -706,7 +707,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "1.18.4",
"version": "2.0.2",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -728,7 +729,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "1.18.15",
"version": "2.0.2",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -763,7 +764,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "1.17.13",
"version": "2.0.2",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -783,7 +784,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "1.18.15",
"version": "2.0.2",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -817,7 +818,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "1.18.15",
"version": "2.0.2",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -836,7 +837,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "1.18.15",
"version": "2.0.2",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -882,7 +883,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "0.0.0",
"version": "2.0.2",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -896,7 +897,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "1.18.4",
"version": "2.0.2",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -931,7 +932,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "1.18.15",
"version": "2.0.2",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -966,7 +967,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "1.18.3",
"version": "2.0.2",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -999,7 +1000,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "1.18.15",
"version": "2.0.2",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1040,7 +1041,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "1.18.4",
"version": "2.0.2",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
@@ -3702,6 +3703,8 @@
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"core-js": ["core-js@3.50.0", "", {}, "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw=="],
"core-js-compat": ["core-js-compat@3.50.0", "", { "dependencies": { "browserslist": "^4.28.7" } }, "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
+43 -15
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
APP=opencode2
APP=opencode
SOURCE_APP=opencode
MUTED='\033[0;2m'
RED='\033[0;31m'
@@ -22,7 +23,7 @@ Options:
Examples:
curl -fsSL https://opencode.ai/v2/install | bash
curl -fsSL https://opencode.ai/v2/install | bash -s -- --version 0.0.0-beta-17236
./install --binary /path/to/opencode2
./install --binary /path/to/opencode
EOF
}
@@ -201,9 +202,9 @@ else
filename="cli-$target-$specific_version.tgz"
url="https://registry.npmjs.org/$package_name/-/$filename"
binary_name="$APP"
binary_name="$SOURCE_APP"
if [ "$os" = "windows" ]; then
binary_name="$APP.exe"
binary_name="$SOURCE_APP.exe"
fi
fi
@@ -230,12 +231,7 @@ check_version() {
installed_version="${installed_version##* }"
installed_version="${installed_version#v}"
if [[ "$installed_version" != "$specific_version" ]]; then
print_message info "${MUTED}Installed version: ${NC}$installed_version."
else
print_message info "${MUTED}Version ${NC}$specific_version${MUTED} already installed"
exit 0
fi
print_message info "${MUTED}Installed version: ${NC}$installed_version."
fi
}
@@ -340,15 +336,46 @@ download_and_install() {
fi
tar -xzf "$tmp_dir/$filename" -C "$tmp_dir"
mv "$tmp_dir/package/bin/$binary_name" "$INSTALL_DIR"
chmod 755 "${INSTALL_DIR}/$binary_name"
local installed_binary="$APP"
if [ "$os" = "windows" ]; then
installed_binary="$APP.exe"
fi
mv "$tmp_dir/package/bin/$binary_name" "$INSTALL_DIR/$installed_binary"
chmod 755 "$INSTALL_DIR/$installed_binary"
rm -rf "$tmp_dir"
}
install_from_binary() {
print_message info "\n${MUTED}Installing ${NC}$APP ${MUTED}from: ${NC}$binary_path"
cp "$binary_path" "${INSTALL_DIR}/$APP"
chmod 755 "${INSTALL_DIR}/$APP"
local installed_binary="$APP"
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*) installed_binary="$APP.exe" ;;
esac
cp "$binary_path" "$INSTALL_DIR/$installed_binary"
chmod 755 "$INSTALL_DIR/$installed_binary"
}
install_legacy_shim() {
local shim_os="${os:-}"
if [[ -z "$shim_os" ]]; then
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*) shim_os="windows" ;;
esac
fi
rm -f "$INSTALL_DIR/opencode2" "$INSTALL_DIR/opencode2.exe" "$INSTALL_DIR/opencode2.cmd"
if [[ "$shim_os" == "windows" ]]; then
cat > "$INSTALL_DIR/opencode2.cmd" <<'EOF'
@echo off
"%~dp0opencode.exe" %*
exit /b %errorlevel%
EOF
return
fi
cat > "$INSTALL_DIR/opencode2" <<'EOF'
#!/bin/sh
exec "$(dirname "$0")/opencode" "$@"
EOF
chmod 755 "$INSTALL_DIR/opencode2"
}
if [ -n "$binary_path" ]; then
@@ -357,6 +384,7 @@ else
check_version
download_and_install
fi
install_legacy_shim
add_to_path() {
@@ -453,7 +481,7 @@ echo -e ""
echo -e "${MUTED}OpenCode includes free models, to start:${NC}"
echo -e ""
echo -e "cd <project> ${MUTED}# Open directory${NC}"
echo -e "opencode2 ${MUTED}# Run command${NC}"
echo -e "opencode ${MUTED}# Run command${NC}"
echo -e ""
echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/v2/docs"
echo -e ""
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-yzCk746pospz8EVakHRcDhYJkhGYGSt9dHOPbzO4OYo=",
"aarch64-linux": "sha256-MFJVLos4v2r9jazmmr3ldVCJKLrO+Qp/BpGpyM/1lf8=",
"aarch64-darwin": "sha256-k8r/HVSgdRSTlJ1lI7EebqbxeA3AElnaw1sDYOPEdQw=",
"x86_64-darwin": "sha256-ACJdJfz12xLBQvWIkbuve86znSoGJ2PLDQbgh0cT6/g="
"x86_64-linux": "sha256-QJn59dTbxspHl35vGxs6akynlfaWaPqvbMNsi0EqnbM=",
"aarch64-linux": "sha256-wa54uUIAth/ZOyejZ/TfU0zZdkhNirwq7yK1218jhBQ=",
"aarch64-darwin": "sha256-VvsfVdtxcVijCvlrWpBUkgmkYFoXX7QiXM0Fhz1t2VE=",
"x86_64-darwin": "sha256-IxYFBG5I+znXcTVqrAWKhG2qvVtjM+Amp68xIGKZogE="
}
}
+3 -3
View File
@@ -2,15 +2,15 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "0.0.0",
"version": "2.0.2",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
"scripts": {
"dev": "bun run --cwd packages/cli src/index.ts",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode service get password)\" exec bun run dev \"$@\" --server \"$(opencode service status)\"' --",
"dev:vite": "bun run --cwd packages/cli --conditions=browser dev/vite.ts",
"dev:vite:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev:vite \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:vite:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode service get password)\" exec bun run dev:vite \"$@\" --server \"$(opencode service status)\"' --",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
+2 -1
View File
@@ -122,13 +122,14 @@ Keep provider facades small and explicit:
### Provider Package Entrypoints
Catalog-selected native providers use package-like export paths from `@opencode/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
Catalog-selected native providers use package-like export paths from `@opencode/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are one flat serializable object: the connection keys the entrypoint declares (`apiKey`, `baseURL`, `region`, …), the common `headers` and `body` overlays, and the protocol's request options (`reasoningEffort`, `thinking`, …) side by side. Each entrypoint destructures its own connection keys and passes the rest to the route as `providerOptions`; there is no nested `providerOptions` at the entrypoint.
```ts
import { model } from "@opencode/ai/providers/openai/responses"
const selected = model("gpt-5", {
apiKey,
reasoningEffort: "high",
})
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.20",
"version": "2.0.2",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
@@ -1708,7 +1708,10 @@ export const transport = <
}
function requiredBetaHeaders(body: Pick<AnthropicMessagesBody, "messages" | "context_management" | "thinking">) {
const betas: string[] = []
// Always request interleaved thinking. The API accepts the header on any
// model and ignores it where unsupported, while manual-thinking models need
// it for thinking between tool calls.
const betas: string[] = ["interleaved-thinking-2025-05-14"]
const requestsCompaction = (body.context_management?.edits.length ?? 0) > 0
const replaysCompaction = body.messages.some((message) =>
message.content.some((block) => block.type === "compaction"),
@@ -106,7 +106,7 @@ type BedrockSystemBlock = Schema.Schema.Type<typeof BedrockSystemBlock>
const BedrockToolSpec = Schema.Struct({
toolSpec: Schema.Struct({
name: Schema.String,
description: Schema.String,
description: Schema.optional(Schema.String),
inputSchema: Schema.Struct({
json: JsonObject,
}),
@@ -222,7 +222,7 @@ type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockToolSpec => ({
toolSpec: {
name: tool.name,
description: tool.description,
...(tool.description.trim().length > 0 ? { description: tool.description } : {}),
inputSchema: { json: inputSchema },
},
})
+11 -46
View File
@@ -86,7 +86,6 @@ export const OpenResponsesReasoningItem = Schema.Struct({
id: Schema.optionalKey(Schema.String),
summary: Schema.Array(OpenResponsesReasoningSummaryText),
encrypted_content: optionalNull(Schema.String),
provider_metadata: Schema.optional(JsonObject),
})
const OpenResponsesWebSearchCall = Schema.StructWithRest(
@@ -183,7 +182,6 @@ export const InputItem = Schema.Union([
content: Schema.Array(OpenResponsesOutputText),
phase: Schema.optionalKey(MessagePhase),
status: Schema.optional(Schema.String),
provider_metadata: Schema.optional(JsonObject),
}),
OpenResponsesReasoningItem,
Schema.Struct({
@@ -193,7 +191,6 @@ export const InputItem = Schema.Union([
name: Schema.String,
namespace: Schema.optional(Schema.String),
arguments: Schema.String,
provider_metadata: Schema.optional(JsonObject),
}),
Schema.Struct({
type: Schema.tag("function_call_output"),
@@ -226,7 +223,6 @@ type OpenResponsesReasoningInput = {
id?: string
summary: Array<{ type: "summary_text"; text: string }>
encrypted_content?: string | null
provider_metadata?: Record<string, unknown>
}
export const Tool = Schema.Struct({
type: Schema.tag("function"),
@@ -440,8 +436,6 @@ export const decodeChannelEvent = (frame: string) =>
export interface ProviderAdapter {
readonly id: string
readonly name: string
/** Replay opaque gateway continuation state only for adapters that own this extension. */
readonly preserveProviderMetadata?: boolean
readonly nativeTool?: (
native: NonNullable<ToolDefinition["native"]>,
) => Effect.Effect<{ readonly type: string }, AIError>
@@ -461,7 +455,6 @@ export interface ParserState {
readonly id: string
readonly name: string
readonly providerMetadataKey: string
readonly preserveProviderMetadata: boolean
readonly tools: ToolStream.State<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
@@ -521,16 +514,7 @@ const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadata
return separator > 0 && separator < metadata.itemId.length - 1 ? metadata.itemId : undefined
}
const replayProviderMetadata = (metadata: ProviderMetadata | undefined, key: string, adapter: ProviderAdapter) => {
const value = metadata?.[key]?.providerMetadata
return adapter.preserveProviderMetadata && ProviderShared.isRecord(value) ? { provider_metadata: value } : {}
}
const lowerToolCall = (
part: ToolCallPart,
providerMetadataKey: string,
adapter: ProviderAdapter,
): OpenResponsesInputItem => {
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
const id = itemID(part.providerMetadata, providerMetadataKey)
return {
type: "function_call",
@@ -539,15 +523,10 @@ const lowerToolCall = (
name: part.name,
namespace: part.namespace,
arguments: ProviderShared.encodeJson(part.input),
...replayProviderMetadata(part.providerMetadata, providerMetadataKey, adapter),
}
}
const lowerReasoning = (
part: ReasoningPart,
providerMetadataKey: string,
adapter: ProviderAdapter,
): OpenResponsesReasoningInput | undefined => {
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata)) return undefined
const id = itemID(part.providerMetadata, providerMetadataKey)
@@ -560,7 +539,6 @@ const lowerReasoning = (
...(id === undefined ? {} : { id }),
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
...replayProviderMetadata(part.providerMetadata, providerMetadataKey, adapter),
}
}
@@ -709,7 +687,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
status: "completed",
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }),
...replayProviderMetadata(group.parts.at(-1)?.providerMetadata, providerMetadataKey, adapter),
})),
)
content.splice(0, content.length)
@@ -730,14 +707,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
}
if (part.type === "reasoning") {
flushText()
const reasoning = lowerReasoning(part, providerMetadataKey, adapter)
const reasoning = lowerReasoning(part, providerMetadataKey)
if (!reasoning) continue
const existing = reasoning.id === undefined ? undefined : reasoningItems[reasoning.id]
if (existing) {
existing.summary.push(...reasoning.summary)
if (typeof reasoning.encrypted_content === "string")
existing.encrypted_content = reasoning.encrypted_content
if (reasoning.provider_metadata !== undefined) existing.provider_metadata = reasoning.provider_metadata
continue
}
if (reasoning.id !== undefined) reasoningItems[reasoning.id] = reasoning
@@ -747,7 +723,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
if (part.type === "tool-call") {
flushText()
if (part.providerExecuted === true) continue
input.push(lowerToolCall(part, providerMetadataKey, adapter))
input.push(lowerToolCall(part, providerMetadataKey))
continue
}
if (part.type === "tool-result" && part.providerExecuted === true) {
@@ -1086,17 +1062,8 @@ export const onReasoningDone = (state: ParserState, event: Event, itemID: string
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
}
const outputMetadata = (state: ParserState, item: OutputItem, extra?: Record<string, unknown>) =>
providerMetadata(state, {
itemId: item.id,
...extra,
...(state.preserveProviderMetadata && ProviderShared.isRecord(item.provider_metadata)
? { providerMetadata: item.provider_metadata }
: {}),
})
const reasoningMetadata = (state: ParserState, item: OutputItem) =>
outputMetadata(state, item, { reasoningEncryptedContent: item.encrypted_content ?? null })
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
// Responses APIs normally stream reasoning items in this order:
// `output_item.added` (reasoning) →
@@ -1159,7 +1126,7 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
}
if (item.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
if (state.tools[item.id] !== undefined) return [state, NO_EVENTS]
const metadata = outputMetadata(state, item)
const metadata = providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
@@ -1282,7 +1249,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
content.push(decoded.type === "output_text" ? decoded.text : decoded.refusal)
}
const text = content.length > 0 ? content.join("") : undefined
const metadata = outputMetadata(state, item, phase === undefined ? undefined : { phase })
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
const events: LLMEvent[] = []
const lifecycle = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
return [
@@ -1297,11 +1264,10 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") {
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const metadata = outputMetadata(state, item)
const pending = state.tools[item.id]
const registered = pending !== undefined
const tools = pending
? ToolStream.start(state.tools, item.id, { ...pending, providerMetadata: metadata })
const metadata = providerMetadata(state, { itemId: item.id })
const registered = state.tools[item.id] !== undefined
const tools = registered
? state.tools
: ToolStream.start(state.tools, item.id, {
id: item.call_id,
name: item.name,
@@ -1555,7 +1521,6 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
id: adapter.id,
name: adapter.name,
providerMetadataKey: metadataKey(request.model),
preserveProviderMetadata: adapter.preserveProviderMetadata ?? false,
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
@@ -1,72 +0,0 @@
import { Effect, Schema } from "effect"
import { Route } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import type { LLMRequest } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { ProviderShared } from "./shared.js"
const ADAPTER = "organization-routes"
const adapter = {
id: ADAPTER,
name: "Organization routes",
preserveProviderMetadata: true,
} satisfies OpenResponses.ProviderAdapter
const Body = Schema.Struct({
...OpenResponses.coreFields,
store: Schema.Literal(false),
provider_options: Schema.Struct({
"openai-responses": Schema.Struct({ include: Schema.Array(Schema.String) }),
}),
stream: Schema.Literal(true),
})
const fromRequest = Effect.fn("OrganizationRoutes.fromRequest")(function* (request: LLMRequest) {
const body = yield* OpenResponses.fromRequestWithAdapter(request, adapter)
// A route can select another provider on each call. Keep full history and only
// portable options; native caching and stored IDs would exclude translated
// targets. Scope encrypted reasoning to native Responses targets so their
// stateless continuations remain replayable without blocking other protocols.
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))({
model: body.model,
input: body.input,
instructions: body.instructions,
tools: body.tools,
tool_choice: body.tool_choice,
stream: true as const,
store: false as const,
provider_options: { "openai-responses": { include: ["reasoning.encrypted_content"] } },
max_output_tokens:
body.max_output_tokens !== undefined && body.max_output_tokens > 0 ? body.max_output_tokens : undefined,
temperature: body.temperature,
top_p: body.top_p,
parallel_tool_calls: body.parallel_tool_calls,
metadata: body.metadata,
reasoning: body.reasoning?.effort === undefined ? undefined : { effort: body.reasoning.effort },
text: body.text,
})
})
export const protocol = Protocol.make({
...OpenResponses.protocol,
id: ADAPTER,
body: { schema: Body, from: fromRequest },
stream: {
...OpenResponses.protocol.stream,
initial: (request: LLMRequest) => OpenResponses.initial(request, adapter),
},
})
export const route = Route.make({
id: ADAPTER,
providerMetadataKey: ADAPTER,
protocol,
endpoint: Endpoint.path(OpenResponses.PATH),
transport: HttpTransport.sseJson.with<typeof Body.Type>(),
defaults: { providerOptions: { store: false, include: [] } },
})
export * as OrganizationRoutes from "./organization-routes.js"
+4
View File
@@ -1,6 +1,10 @@
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
import type { CompactionOperations } from "./route/client.js"
/**
* Flat, serializable settings for `model(modelID, settings)`. Each entrypoint declares the connection keys it
* reads; every other key is a request option for the route's protocol.
*/
export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
readonly headers?: Readonly<Record<string, string>>
+8 -3
View File
@@ -1,3 +1,4 @@
import { Struct } from "effect"
import type { ProviderPackage } from "../provider-package.js"
import { AlibabaChat } from "../protocols/alibaba-chat.js"
import { AlibabaMessages } from "../protocols/alibaba-messages.js"
@@ -34,9 +35,9 @@ export type Config = Location &
readonly providerOptions?: ChatOptionsInput | MessagesOptionsInput | ResponsesOptionsInput
}
export type Settings<Options = ChatOptionsInput> = Location &
ProviderPackage.Settings & {
ProviderPackage.Settings &
Options & {
readonly apiKey?: string
readonly providerOptions?: Options
}
const hosts = new Map<string, string>([
@@ -120,7 +121,11 @@ export const responsesModel: ProviderPackage.Definition<
function fromSettings(input: Settings<Config["providerOptions"]>) {
const { body, ...rest } = input
return configure({ ...rest, http: body === undefined ? undefined : { body } })
return configure({
...rest,
http: body === undefined ? undefined : { body },
providerOptions: Struct.omit(rest, ["apiKey", "baseURL", "headers", "region", "workspaceID"]),
})
}
export const webSearch = () => hostedTool("web_search", "Search the web with Alibaba's hosted search tool.")
@@ -23,16 +23,16 @@ export type Config = RouteDefaultsInput & {
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
readonly credentials?: Credentials
readonly profile?: string
readonly region?: string
readonly topP?: number
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
readonly credentials?: Credentials
readonly profile?: string
readonly region?: string
readonly topP?: number
}
const responsesRoute = Route.make({
id: "bedrock-mantle-responses",
@@ -108,18 +108,29 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
const fromSettings = (settings: Settings) =>
const fromSettings = ({
apiKey,
auth,
baseURL,
body,
credentials,
headers,
profile,
region,
topP,
...providerOptions
}: Settings) =>
configure({
apiKey: settings.apiKey,
auth: settings.auth,
baseURL: settings.baseURL,
credentials: settings.credentials,
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
profile: settings.profile,
providerOptions: settings.providerOptions,
region: settings.region,
apiKey,
auth,
baseURL,
credentials,
generation: topP === undefined ? undefined : { topP },
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
profile,
providerOptions,
region,
})
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
@@ -19,13 +19,13 @@ export type Config = RouteDefaultsInput &
}
export type Settings = ProviderPackage.Settings &
AnthropicMessages.ProviderOptionsInput &
(
| { readonly apiKey?: string; readonly authToken?: never }
| { readonly apiKey?: never; readonly authToken?: string }
) & {
readonly baseURL: string
readonly provider?: string
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
}
export const routes = [AnthropicMessages.route]
@@ -63,22 +63,20 @@ export const provider = {
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
{ apiKey, authToken, baseURL, body, headers, provider, ...providerOptions },
) => {
// Read before the exclusivity check narrows a conflicting settings object to `never`.
const provider = ProviderID.make(settings.provider ?? id)
if (settings.apiKey !== undefined && settings.authToken !== undefined)
if (apiKey !== undefined && authToken !== undefined)
throw new ProviderConfigurationError({
provider,
provider: ProviderID.make(provider ?? id),
message: "Anthropic-compatible apiKey cannot be combined with authToken",
})
return configure({
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
provider: settings.provider,
providerOptions: settings.providerOptions,
...(authToken === undefined ? { apiKey: apiKey } : { auth: Auth.bearer(authToken) }),
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
provider,
providerOptions,
}).model(modelID)
}
+8 -8
View File
@@ -21,12 +21,12 @@ export type Config = RouteDefaultsInput &
}
export type Settings = ProviderPackage.Settings &
AnthropicMessages.ProviderOptionsInput &
(
| { readonly apiKey?: string; readonly authToken?: never }
| { readonly apiKey?: never; readonly authToken?: string }
) & {
readonly baseURL?: string
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
}
const auth = (options: ProviderAuthOption<"optional">) => {
@@ -54,18 +54,18 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
{ apiKey, authToken, baseURL, body, headers, ...providerOptions },
) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined)
if (apiKey !== undefined && authToken !== undefined)
throw new ProviderConfigurationError({
provider: id,
message: "Anthropic apiKey cannot be combined with authToken",
})
return configure({
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
...(authToken === undefined ? { apiKey: apiKey } : { auth: Auth.bearer(authToken) }),
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
}
+21 -11
View File
@@ -28,12 +28,12 @@ export type LanguageModelOptions = AzureURL &
export type Config = LanguageModelOptions
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput &
AzureURL & {
readonly apiKey?: string
readonly apiVersion?: string
readonly queryParams?: Readonly<Record<string, string>>
readonly useDeploymentBasedUrls?: boolean
readonly providerOptions?: OpenAIProviderOptionsInput
}
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai`
@@ -151,18 +151,28 @@ export const provider = {
configure,
}
const config = (settings: Settings): Config => {
const config = ({
apiKey,
apiVersion,
baseURL,
body,
headers,
queryParams,
resourceName,
useDeploymentBasedUrls,
...providerOptions
}: Settings): Config => {
const common = {
apiKey: settings.apiKey,
apiVersion: settings.apiVersion,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
apiKey,
apiVersion,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
queryParams: queryParams === undefined ? undefined : { ...queryParams },
useDeploymentBasedUrls,
}
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
if (baseURL !== undefined) return { ...common, baseURL }
if (resourceName !== undefined) return { ...common, resourceName }
throw new ProviderConfigurationError({ provider: id, message: "Azure requires resourceName or baseURL" })
}
+14 -11
View File
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export const route = Route.make({
id: "baseten-chat",
@@ -48,13 +48,16 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export * as Baseten from "./baseten.js"
+14 -11
View File
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export const route = Route.make({
id: "cerebras-chat",
@@ -52,11 +52,14 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
@@ -27,10 +27,10 @@ export type LanguageModelOptions = GatewayURL &
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput &
GatewayURL & {
readonly apiKey?: string
readonly gatewayApiKey?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const baseURL = (input: GatewayURL) => {
@@ -89,14 +89,25 @@ export const configure = (input: LanguageModelOptions) => {
export const provider = { id, configure }
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
gatewayApiKey: settings.gatewayApiKey,
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
const {
accountId: _,
apiKey,
baseURL: _url,
body,
gatewayApiKey,
gatewayId: _id,
headers,
...providerOptions
} = settings
return configure({
apiKey,
gatewayApiKey,
baseURL: baseURL(settings),
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
}
export * as CloudflareAIGateway from "./cloudflare-ai-gateway.js"
@@ -21,9 +21,9 @@ export type LanguageModelOptions = WorkersAIURL &
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput &
WorkersAIURL & {
readonly apiKey?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const baseURL = (input: WorkersAIURL) => {
@@ -63,13 +63,15 @@ export const configure = (input: LanguageModelOptions) => {
export const provider = { id, configure }
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
const { accountId: _, apiKey, baseURL: _url, body, headers, ...providerOptions } = settings
return configure({
apiKey,
baseURL: baseURL(settings),
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
}
export * as CloudflareWorkersAI from "./cloudflare-workers-ai.js"
+14 -11
View File
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export const route = Route.make({
id: "deepinfra-chat",
@@ -55,11 +55,14 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
+14 -11
View File
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export const route = Route.make({
id: "deepseek-chat",
@@ -52,13 +52,16 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export * as DeepSeek from "./deepseek.js"
+14 -11
View File
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export const route = Route.make({
id: "fireworks-chat",
@@ -48,13 +48,16 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export * as Fireworks from "./fireworks.js"
+20 -17
View File
@@ -16,14 +16,14 @@ export type Config = RouteDefaultsInput &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly accessToken?: string
readonly apiKey?: never
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly accessToken?: string
readonly apiKey?: never
readonly baseURL?: string
readonly location?: string
readonly project?: string
}
const route = Route.make({
id: "google-vertex-chat",
@@ -74,16 +74,19 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined)
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ accessToken, apiKey, baseURL, body, headers, location, project, ...providerOptions },
) => {
if (apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
accessToken,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
location,
project,
providerOptions,
}).model(modelID)
}
@@ -25,14 +25,14 @@ export type Config = RouteDefaultsInput &
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly accessToken?: string
readonly apiKey?: never
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
AnthropicMessages.ProviderOptionsInput & {
readonly accessToken?: string
readonly apiKey?: never
readonly baseURL?: string
readonly location?: string
readonly project?: string
}
const route = Route.make({
id: "google-vertex-messages",
@@ -105,17 +105,17 @@ export const provider = {
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
{ accessToken, apiKey, baseURL, body, headers, location, project, ...providerOptions },
) => {
if (settings.apiKey !== undefined)
if (apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
accessToken,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
location,
project,
providerOptions,
}).model(modelID)
}
@@ -16,14 +16,14 @@ export type Config = RouteDefaultsInput &
readonly providerOptions?: OpenResponsesProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly accessToken?: string
readonly apiKey?: never
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: OpenResponsesProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenResponsesProviderOptionsInput & {
readonly accessToken?: string
readonly apiKey?: never
readonly baseURL?: string
readonly location?: string
readonly project?: string
}
const route = Route.make({
id: "google-vertex-responses",
@@ -77,17 +77,17 @@ export const provider = {
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
modelID,
settings,
{ accessToken, apiKey, baseURL, body, headers, location, project, ...providerOptions },
) => {
if (settings.apiKey !== undefined)
if (apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
accessToken,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
location,
project,
providerOptions,
}).model(modelID)
}
+13 -10
View File
@@ -26,6 +26,7 @@ export type Config = RouteDefaultsInput &
}
export type Settings = ProviderPackage.Settings &
GeminiProviderOptionsInput &
(
| { readonly accessToken?: string; readonly apiKey?: never }
| { readonly accessToken?: never; readonly apiKey?: string }
@@ -33,7 +34,6 @@ export type Settings = ProviderPackage.Settings &
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: GeminiProviderOptionsInput
}
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
@@ -124,19 +124,22 @@ export const provider = {
id,
configure,
}
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (
modelID,
{ accessToken, apiKey, baseURL, body, headers, location, project, ...providerOptions },
) => {
if (apiKey !== undefined && accessToken !== undefined)
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
})
return configure({
...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }),
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
...(apiKey === undefined ? { accessToken: accessToken } : { apiKey: apiKey }),
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
location,
project,
providerOptions,
}).model(modelID)
}
+14 -11
View File
@@ -20,11 +20,11 @@ export type Config = RouteDefaultsInput &
readonly providerOptions?: Gemini.ProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: Gemini.ProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
Gemini.ProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
const auth = (options: ProviderAuthOption<"optional">) => {
if ("auth" in options && options.auth) return options.auth
@@ -57,13 +57,16 @@ export const configure = (input: Config = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export const image = provider.image
+14 -11
View File
@@ -26,11 +26,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: ProviderOptions
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: ProviderOptions
}
export type Settings = ProviderPackage.Settings &
ProviderOptions & {
readonly apiKey?: string
readonly baseURL?: string
}
const Options = Schema.Struct({
includeReasoning: Schema.optional(Schema.Boolean),
@@ -103,13 +103,16 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export * as Groq from "./groq.js"
+11 -11
View File
@@ -79,11 +79,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: ProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: ProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
ProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
const responsesRoute = Route.make({
id: "meta-responses",
@@ -169,13 +169,13 @@ export const chatModel: ProviderPackage.Definition<Settings, OpenResponsesProvid
export const messagesModel: ProviderPackage.Definition<Settings, MessagesOptionsInput>["model"] = (modelID, settings) =>
fromSettings(settings).messages(modelID)
function fromSettings(settings: Settings) {
function fromSettings({ apiKey, baseURL, body, headers, ...providerOptions }: Settings) {
return configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
})
}
+11 -11
View File
@@ -40,11 +40,11 @@ export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: ProviderOptionsInput
}
export interface Settings<Options = MessagesOptionsInput> extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: Options
}
export type Settings<Options = MessagesOptionsInput> = ProviderPackage.Settings &
Options & {
readonly apiKey?: string
readonly baseURL?: string
}
const ChatOptions = Schema.Struct({
thinking: Schema.optional(Schema.Struct({ type: Schema.String })),
@@ -127,14 +127,14 @@ export const provider = configure()
export const model: ProviderPackage.Definition<Settings<MessagesOptionsInput>, MessagesOptionsInput>["model"] = (
modelID,
settings,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export const messages = provider.messages
+9 -6
View File
@@ -3,11 +3,14 @@ import { MiniMax } from "../minimax.js"
export type Settings = MiniMax.Settings<MiniMax.ChatOptionsInput>
export const model: ProviderPackage.Definition<Settings, MiniMax.ChatOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, MiniMax.ChatOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
MiniMax.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).chat(modelID)
@@ -5,12 +5,12 @@ export type Settings = MiniMax.Settings<MiniMax.ResponsesOptionsInput>
export const model: ProviderPackage.Definition<Settings, MiniMax.ResponsesOptionsInput>["model"] = (
modelID,
settings,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
MiniMax.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).responses(modelID)
+14 -11
View File
@@ -14,11 +14,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: ProviderOptions
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: ProviderOptions
}
export type Settings = ProviderPackage.Settings &
ProviderOptions & {
readonly apiKey?: string
readonly baseURL?: string
}
export const route = MistralChat.route
export const routes = [route]
@@ -39,13 +39,16 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export * as Mistral from "./mistral.js"
+14 -11
View File
@@ -42,11 +42,11 @@ export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: ChatOptionsInput | MessagesOptionsInput | ResponsesOptionsInput
}
export interface Settings<Options = ChatOptionsInput> extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: Options
}
export type Settings<Options = ChatOptionsInput> = ProviderPackage.Settings &
Options & {
readonly apiKey?: string
readonly baseURL?: string
}
const ChatOptions = Schema.Struct({
reasoningEffort: Schema.optional(Schema.String),
@@ -133,13 +133,16 @@ export const chat = provider.chat
export const messages = provider.messages
export const responses = provider.responses
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export * as Moonshot from "./moonshot.js"
@@ -5,12 +5,12 @@ export type Settings = Moonshot.Settings<Moonshot.MessagesOptionsInput>
export const model: ProviderPackage.Definition<Settings, Moonshot.MessagesOptionsInput>["model"] = (
modelID,
settings,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
Moonshot.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).messages(modelID)
@@ -5,12 +5,12 @@ export type Settings = Moonshot.Settings<Moonshot.ResponsesOptionsInput>
export const model: ProviderPackage.Definition<Settings, Moonshot.ResponsesOptionsInput>["model"] = (
modelID,
settings,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
Moonshot.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).responses(modelID)
@@ -16,12 +16,12 @@ export type Config = RouteDefaultsInput &
readonly providerOptions?: OpenResponsesProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
readonly providerOptions?: OpenResponsesProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenResponsesProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
}
export const routes = [OpenAICompatibleResponses.route]
@@ -48,13 +48,13 @@ export const provider = {
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
modelID,
settings,
{ apiKey, baseURL, body, headers, provider, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
provider: settings.provider,
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
provider,
providerOptions,
}).model(modelID)
+16 -13
View File
@@ -14,12 +14,12 @@ type GenericModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
}
export const routes = [OpenAICompatibleChat.route]
@@ -45,14 +45,17 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, provider, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
provider: settings.provider,
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
provider,
providerOptions,
}).model(modelID)
export * as OpenAICompatible from "./openai-compatible.js"
+26 -17
View File
@@ -57,14 +57,14 @@ export const imageGeneration = (options: ImageGenerationOptions = {}) =>
},
})
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly organization?: string
readonly project?: string
readonly queryParams?: Readonly<Record<string, string>>
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
readonly organization?: string
readonly project?: string
readonly queryParams?: Readonly<Record<string, string>>
}
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
@@ -116,19 +116,28 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
const config = (settings: Settings): Config => {
const config = ({
apiKey,
baseURL,
body,
headers: given,
organization,
project,
queryParams,
...providerOptions
}: Settings): Config => {
const headers = {
...(settings.organization === undefined ? {} : { "OpenAI-Organization": settings.organization }),
...(settings.project === undefined ? {} : { "OpenAI-Project": settings.project }),
...settings.headers,
...(organization === undefined ? {} : { "OpenAI-Organization": organization }),
...(project === undefined ? {} : { "OpenAI-Project": project }),
...given,
}
return {
apiKey: settings.apiKey,
baseURL: settings.baseURL,
apiKey,
baseURL,
headers: Object.keys(headers).length === 0 ? undefined : headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
queryParams: queryParams === undefined ? undefined : { ...queryParams },
}
}
+11 -11
View File
@@ -77,11 +77,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenRouterProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenRouterProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenRouterProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [
Schema.Record(Schema.String, Schema.Any),
@@ -191,12 +191,12 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (
modelID,
settings,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
@@ -1,57 +0,0 @@
import type { ProviderPackage } from "../provider-package.js"
import { OrganizationRoutes } from "../protocols/organization-routes.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
export type Options = Pick<
OpenResponsesProviderOptionsInput,
"reasoningEffort" | "textVerbosity" | "parallelToolCalls" | "metadata" | "allowedTools"
>
export const id = ProviderID.make("organization-routes")
export type Config = RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
readonly provider?: string
readonly baseURL: string
readonly providerOptions?: Options
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
readonly providerOptions?: Options
}
export const routes = [OrganizationRoutes.route]
export const configure = (input: Config) => {
const provider = input.provider ?? "organization-routes"
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
const route = OrganizationRoutes.route.with({
...rest,
provider,
endpoint: { baseURL },
auth: AuthOptions.bearer(input, []),
})
return {
id: ProviderID.make(provider),
model: (modelID: string | ModelID) => route.model<Options>({ id: modelID }),
configure,
}
}
export const provider = { id, configure }
export const model: ProviderPackage.Definition<Settings, Options>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
+14 -11
View File
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export const route = Route.make({
id: "togetherai-chat",
@@ -52,11 +52,14 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
+11 -11
View File
@@ -20,11 +20,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: XAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: XAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
XAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export type { XAIImageOptions } from "../protocols/xai-images.js"
@@ -110,13 +110,13 @@ export const model: ProviderPackage.Definition<
Settings,
XAIProviderOptionsInput,
typeof responsesRoute.compact
>["model"] = (modelID, settings) =>
>["model"] = (modelID, { apiKey, baseURL, body, headers, ...providerOptions }) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export const responses = provider.responses
export const chat = provider.chat
+14 -11
View File
@@ -23,11 +23,11 @@ export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: ChatOptionsInput | MessagesOptionsInput | ResponsesOptionsInput
}
export interface Settings<Options = ChatOptionsInput> extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: Options
}
export type Settings<Options = ChatOptionsInput> = ProviderPackage.Settings &
Options & {
readonly apiKey?: string
readonly baseURL?: string
}
const chatRoute = Route.make({
id: "zai-coding-chat",
@@ -80,13 +80,16 @@ export const chat = provider.chat
export const messages = provider.messages
export const responses = provider.responses
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export * as ZAICodingPlan from "./zai-coding-plan.js"
@@ -5,12 +5,12 @@ export type Settings = ZAICodingPlan.Settings<ZAICodingPlan.MessagesOptionsInput
export const model: ProviderPackage.Definition<Settings, ZAICodingPlan.MessagesOptionsInput>["model"] = (
modelID,
settings,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
ZAICodingPlan.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).messages(modelID)
@@ -5,12 +5,12 @@ export type Settings = ZAICodingPlan.Settings<ZAICodingPlan.ResponsesOptionsInpu
export const model: ProviderPackage.Definition<Settings, ZAICodingPlan.ResponsesOptionsInput>["model"] = (
modelID,
settings,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
ZAICodingPlan.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).responses(modelID)
+14 -11
View File
@@ -17,11 +17,11 @@ export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: ChatOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: ChatOptionsInput
}
export type Settings = ProviderPackage.Settings &
ChatOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export type { ZAIImageOptions } from "../protocols/zai-images.js"
@@ -70,13 +70,16 @@ export const provider = configure()
export const image = provider.image
export const chat = provider.chat
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export * as ZAI from "./zai.js"
+7 -1
View File
@@ -23,7 +23,13 @@ export interface DispatchResult extends ToolSettlement {
export const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<DispatchResult> => {
const name = call.namespace === undefined ? call.name : `${call.namespace}.${call.name}`
const tool = tools[name]
if (!tool) return Effect.succeed(result(call, { type: "error", value: `Unknown tool: ${name}` }))
if (!tool)
return Effect.succeed(
result(call, {
type: "error",
value: `No tool named "${name}" is currently available. Please use a tool from the available tool list.`,
}),
)
if (!tool.execute)
return Effect.succeed(result(call, { type: "error", value: `Tool has no execute handler: ${name}` }))
+10 -9
View File
@@ -188,13 +188,13 @@ describe("provider package entrypoints", () => {
baseURL: "https://provider.example.test/v1/",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
providerOptions: { reasoningEffort: "high" as const },
reasoningEffort: "high" as const,
}
const deepinfra = DeepInfra.model("google/gemma-3-27b-it", settings)
expect(deepinfra.route.id).toBe("deepinfra-chat")
expect(deepinfra.route.endpoint.baseURL).toBe("https://provider.example.test/v1/openai")
expect(deepinfra.route.defaults.providerOptions).toEqual(settings.providerOptions)
expect(deepinfra.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
expect(deepinfra.route.defaults.headers).toEqual(settings.headers)
expect(deepinfra.route.defaults.http?.body).toEqual(settings.body)
})
@@ -210,7 +210,7 @@ describe("provider package entrypoints", () => {
apiKey: "fixture",
headers: { "x-application": "opencode" },
body: { custom: true },
providerOptions: { reasoningEffort: "high" },
reasoningEffort: "high",
})
expect(selected.provider).toBe(provider.id)
expect(selected.route.endpoint.baseURL).toBe(provider.baseURL({ accountId: "account" }))
@@ -231,11 +231,11 @@ describe("provider package entrypoints", () => {
}
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
...settings,
providerOptions: { usage: true },
usage: true,
})
const xai = XAI.model("grok-4", {
...settings,
providerOptions: { reasoningEffort: "high" },
reasoningEffort: "high",
})
for (const selected of [openrouter, xai]) {
@@ -269,7 +269,8 @@ describe("provider package entrypoints", () => {
provider: "example",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
providerOptions: { reasoningEffort: "low", store: true },
reasoningEffort: "low",
store: true,
})
expect(String(selected.provider)).toBe("example")
@@ -295,7 +296,7 @@ describe("provider package entrypoints", () => {
provider: "example",
headers: { "x-application": "opencode" },
body: { metadata: { user_id: "user_1" } },
providerOptions: { effort: "low" },
effort: "low",
})
expect(String(selected.provider)).toBe("example")
@@ -315,7 +316,7 @@ describe("provider package entrypoints", () => {
const Anthropic = await import("@opencode/ai/providers/anthropic")
const selected = Anthropic.model("claude-sonnet-4-6", {
apiKey: "fixture",
providerOptions: { thinking: { type: "adaptive" } },
thinking: { type: "adaptive" },
})
expect(selected.route.defaults.providerOptions).toEqual({ thinking: { type: "adaptive" } })
@@ -409,7 +410,7 @@ describe("provider package entrypoints", () => {
baseURL: "https://generativelanguage.test/v1beta",
headers: { "x-application": "opencode" },
body: { safetySettings: [] },
providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } },
thinkingConfig: { thinkingBudget: 1_024 },
})
expect(selected.route.id).toBe("gemini")
@@ -69,7 +69,9 @@ for (const model of [
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
const body = JSON.parse(text)
expect(request.headers["anthropic-beta"]).toBe("existing-beta,compact-2026-01-12")
expect(request.headers["anthropic-beta"]).toBe(
"existing-beta,interleaved-thinking-2025-05-14,compact-2026-01-12",
)
if (body.messages.length === 1) {
expect(body.context_management.edits).toEqual([
{
@@ -32,7 +32,9 @@ for (const [id, enabled] of [
enabled ? { type: "adaptive", block_binding: { prefix_mismatch_behavior: "drop_block" } } : undefined,
)
expect(prepared.request.headers["anthropic-beta"]).toBe(
enabled ? "existing-beta,thinking-binding-controls-2026-08-01" : "existing-beta",
enabled
? "existing-beta,interleaved-thinking-2025-05-14,thinking-binding-controls-2026-08-01"
: "existing-beta,interleaved-thinking-2025-05-14",
)
}),
)
@@ -53,7 +55,9 @@ it.effect("preserves explicit thinking settings and combines required beta heade
const prepared = yield* AnthropicMessages.route.prepareTransport(compiled.body, request)
expect(compiled.body.thinking).toEqual(thinking)
expect(prepared.request.headers["anthropic-beta"]).toBe(
thinking.type === "disabled" ? "compact-2026-01-12" : "compact-2026-01-12,thinking-binding-controls-2026-08-01",
thinking.type === "disabled"
? "interleaved-thinking-2025-05-14,compact-2026-01-12"
: "interleaved-thinking-2025-05-14,compact-2026-01-12,thinking-binding-controls-2026-08-01",
)
}
}),
@@ -279,6 +279,52 @@ describe("Bedrock Converse route", () => {
})
}),
)
;["", " \t\r\n"].forEach((description) => {
it.effect(`omits blank tool description ${JSON.stringify(description)}`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(baseRequest, {
tools: [
ToolDefinition.make({
name: "lookup",
description,
inputSchema: { type: "object", properties: { query: { type: "string" } } },
}),
],
}),
)
expect(prepared.body.toolConfig.tools).toEqual([
{
toolSpec: {
name: "lookup",
inputSchema: { json: { type: "object", properties: { query: { type: "string" } } } },
},
},
])
expect(prepared.body.toolConfig.tools[0].toolSpec).not.toHaveProperty("description")
}),
)
})
it.effect("preserves meaningful tool descriptions including surrounding whitespace", () =>
Effect.gen(function* () {
const description = " \tLookup data.\n"
const prepared = yield* compileRequest(
LLMRequest.update(baseRequest, {
tools: [
ToolDefinition.make({
name: "lookup",
description,
inputSchema: { type: "object" },
}),
],
}),
)
expect(prepared.body.toolConfig.tools[0].toolSpec.description).toBe(description)
}),
)
it.effect("keeps tools and omits the unsupported choice when tool choice is none", () =>
Effect.gen(function* () {
+4 -6
View File
@@ -34,12 +34,10 @@ it.effect("Groq lowers its own options for custom catalog identities and endpoin
baseURL: "https://gateway.example/v1",
headers: { "x-client": "test" },
body: { custom: "value" },
providerOptions: {
reasoningEffort: "default",
parallelToolCalls: true,
serviceTier: "flex",
user: "test-user",
},
reasoningEffort: "default",
parallelToolCalls: true,
serviceTier: "flex",
user: "test-user",
}),
{ provider: "custom-groq" },
)
+1 -1
View File
@@ -87,7 +87,7 @@ it.effect("Meta package selectors preserve overrides and Chat token policy on cu
baseURL: "https://gateway.example/v1",
headers: { "x-client": "test" },
body: { custom: "value" },
providerOptions: { reasoningEffort: "future-effort" },
reasoningEffort: "future-effort",
})
expect(model.route.endpoint.baseURL).toBe("https://gateway.example/v1")
expect(model.route.defaults.headers).toEqual({ "x-client": "test" })
@@ -99,7 +99,7 @@ describe("native OpenAI-compatible providers", () => {
const settings = {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
providerOptions: { reasoningEffort: "high" },
reasoningEffort: "high",
}
const selected = provider.configure(settings).model("test-model")
expect(selected.provider).toBe(provider.id)
@@ -143,7 +143,7 @@ describe("native OpenAI-compatible providers", () => {
tools: [
ToolDefinition.make({ name: "lookup", description: "Look up data", inputSchema: { type: "object" } }),
],
providerOptions: { store: true },
store: true,
}),
)
@@ -168,7 +168,7 @@ describe("native OpenAI-compatible providers", () => {
]),
Message.user("Continue."),
],
providerOptions: { store: true },
store: true,
}),
)
@@ -206,7 +206,7 @@ describe("native OpenAI-compatible providers", () => {
baseURL: "https://gateway.example/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
providerOptions: { reasoningEffort: "high" },
reasoningEffort: "high",
})
expect(selected.route.endpoint.baseURL).toBe("https://gateway.example/v1")
@@ -1,361 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, LLMEvent, Message } from "../../src/index.js"
import { configure } from "../../src/providers/organization-routes.js"
import { provider } from "../../src/providers/openai-compatible-responses.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const model = configure({
apiKey: "session-test",
baseURL: "https://console.example.test/inference/route/openai/v1",
provider: "opencode-routes-org_test",
headers: { "x-opencode-org-id": "org_test" },
}).model("route/coding")
const fixtures = [
{
protocol: "openai-responses",
content: [
{
type: "reasoning",
id: "rs_native",
summary: [{ type: "summary_text", text: "Need a lookup" }],
encrypted_content: "opaque-openai-reasoning",
},
{ type: "message", id: "msg_native", role: "assistant", content: [{ type: "output_text", text: "Checking." }] },
{
type: "function_call",
id: "fc_native_lookup",
call_id: "call_lookup",
name: "lookup",
arguments: '{"query":"weather"}',
},
{ type: "function_call", id: "fc_native_clock", call_id: "call_clock", name: "clock", arguments: "{}" },
],
},
{
protocol: "google",
content: [
{ text: "Need a lookup", thought: true },
{ text: "Checking." },
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "opaque-google-signature" },
{ functionCall: { name: "clock", args: {} } },
],
},
{
protocol: "anthropic-messages",
content: [
{ type: "thinking", thinking: "Need a lookup", signature: "opaque-anthropic-signature" },
{ type: "text", text: "Checking." },
{ type: "tool_use", id: "call_lookup", name: "lookup", input: { query: "weather" } },
{ type: "tool_use", id: "call_clock", name: "clock", input: {} },
],
},
{
protocol: "openai-chat",
content: [
{
role: "assistant",
reasoning_content: "Need a lookup",
content: "Checking.",
tool_calls: [
{ id: "call_lookup", type: "function", function: { name: "lookup", arguments: '{"query":"weather"}' } },
{ id: "call_clock", type: "function", function: { name: "clock", arguments: "{}" } },
],
},
],
},
]
const finalText = sseEvents(
{
type: "response.output_item.done",
item: { type: "message", id: "msg_final", content: [{ type: "output_text", text: "Sunny." }] },
},
{ type: "response.completed", response: { id: "resp_final" } },
)
describe("organization routes", () => {
it.effect("uses the Responses route endpoint with stateless portable options", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model,
system: "Help with code.",
prompt: "Hello",
promptCacheKey: "session-cache",
providerOptions: {
store: true,
include: ["reasoning.encrypted_content"],
previousResponseId: "resp_old",
reasoningEffort: "low",
reasoningSummary: "auto",
serviceTier: "priority",
},
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(input.request.url).toBe("https://console.example.test/inference/route/openai/v1/responses")
expect(input.request.headers.authorization).toBe("Bearer session-test")
expect(input.request.headers["x-opencode-org-id"]).toBe("org_test")
expect(yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))(input.text)).toEqual({
model: "route/coding",
input: [{ role: "user", content: [{ type: "input_text", text: "Hello" }] }],
instructions: "Help with code.",
stream: true,
store: false,
provider_options: { "openai-responses": { include: ["reasoning.encrypted_content"] } },
reasoning: { effort: "low" },
})
return input.respond(finalText, { headers: { "content-type": "text/event-stream" } })
}),
),
),
)
expect(response.text).toBe("Sunny.")
}),
)
it.effect("omits an unknown output limit", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello", generation: { maxTokens: 0 } }))
expect(prepared.body.max_output_tokens).toBeUndefined()
}),
)
for (const fixture of fixtures) {
it.effect(`preserves ${fixture.protocol} continuation metadata through a streamed parallel tool loop`, () =>
Effect.gen(function* () {
const metadata = {
protocol: fixture.protocol,
model: "native-model",
connection_id: "conn_native",
endpoint_id: "endpoint_native",
content: fixture.content,
group_id: "resp_tools",
}
const marker = { group_id: "resp_tools" }
const items = [
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Need a lookup" }],
provider_metadata: metadata,
},
{
type: "message",
id: "msg_1",
role: "assistant",
content: [{ type: "output_text", text: "Checking." }],
provider_metadata: marker,
},
{
type: "function_call",
id: "fc_lookup",
call_id: "call_lookup",
name: "lookup",
arguments: '{"query":"weather"}',
provider_metadata: marker,
},
{
type: "function_call",
id: "fc_clock",
call_id: "call_clock",
name: "clock",
arguments: "{}",
provider_metadata: marker,
},
]
const first = yield* LLMClient.generate(LLM.request({ model, prompt: "Check weather and time." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "reasoning", id: "rs_1", summary: [] },
},
{
type: "response.reasoning_summary_text.delta",
item_id: "rs_1",
summary_index: 0,
delta: "Need a lookup",
},
{
type: "response.output_item.added",
output_index: 1,
item: { type: "message", id: "msg_1", role: "assistant", content: [] },
},
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Checking." },
{
type: "response.output_item.added",
output_index: 2,
item: {
type: "function_call",
id: "fc_lookup",
call_id: "call_lookup",
name: "lookup",
arguments: "",
},
},
{ type: "response.function_call_arguments.delta", item_id: "fc_lookup", delta: '{"query":"weather"}' },
{
type: "response.output_item.added",
output_index: 3,
item: { type: "function_call", id: "fc_clock", call_id: "call_clock", name: "clock", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_clock", delta: "{}" },
...items.map((item, output_index) => ({ type: "response.output_item.done", output_index, item })),
{ type: "response.completed", response: { id: "resp_tools", output: items } },
),
),
),
)
expect(first.toolCalls).toHaveLength(2)
expect(first.events.filter(LLMEvent.is.toolCall)).toHaveLength(2)
expect(
first.message.content.map((part) => part.providerMetadata?.["opencode-routes-org_test"]?.providerMetadata),
).toEqual([metadata, marker, marker, marker])
const second = yield* LLMClient.generate(
LLM.request({
model,
providerOptions: { previousResponseId: "resp_tools" },
messages: [
Message.user("Check weather and time."),
first.message,
Message.tool({ id: "call_lookup", name: "lookup", result: { weather: "sunny" } }),
Message.tool({ id: "call_clock", name: "clock", result: { time: "noon" } }),
],
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const body = yield* Schema.decodeUnknownEffect(
Schema.fromJsonString(
Schema.Struct({
input: Schema.Array(Schema.Record(Schema.String, Schema.Unknown)),
store: Schema.Boolean,
include: Schema.optional(Schema.Array(Schema.String)),
previous_response_id: Schema.optional(Schema.String),
}),
),
)(input.text)
expect(body.store).toBe(false)
expect(body.include).toBeUndefined()
expect(body.previous_response_id).toBeUndefined()
expect(body.input.map((item) => item.type ?? item.role)).toEqual([
"user",
"reasoning",
"message",
"function_call",
"function_call",
"function_call_output",
"function_call_output",
])
expect(body.input.slice(1, 5).map((item) => item.provider_metadata)).toEqual([
metadata,
marker,
marker,
marker,
])
expect(body.input.slice(3, 5).map((item) => item.call_id)).toEqual(["call_lookup", "call_clock"])
return input.respond(finalText, { headers: { "content-type": "text/event-stream" } })
}),
),
),
)
expect(second.text).toBe("Sunny.")
}),
)
}
it.effect("preserves opaque state on an empty reasoning item", () =>
Effect.gen(function* () {
const metadata = {
protocol: "anthropic-messages",
content: [{ type: "redacted_thinking", data: "opaque" }],
group_id: "resp_empty",
}
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_empty", summary: [], provider_metadata: metadata },
},
{ type: "response.completed", response: { id: "resp_empty" } },
),
),
),
)
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.input).toEqual([
{ type: "reasoning", id: "rs_empty", summary: [], encrypted_content: null, provider_metadata: metadata },
])
}),
)
it.effect("keeps opaque route metadata out of other Responses providers", () =>
Effect.gen(function* () {
const other = provider
.configure({
apiKey: "test-key",
baseURL: "https://other.example.test/v1",
provider: "opencode-routes-org_test",
})
.model("other")
const response = yield* LLMClient.generate(LLM.request({ model: other, prompt: "Hello" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
content: [{ type: "output_text", text: "Hi" }],
provider_metadata: { group_id: "resp_1", secret: "opaque" },
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content[0]?.providerMetadata).toEqual({ "opencode-routes-org_test": { itemId: "msg_1" } })
const replay = yield* compileRequest(
LLM.request({
model: other,
messages: [
Message.assistant({
type: "text",
text: "Hi",
providerMetadata: {
"opencode-routes-org_test": { itemId: "msg_1", providerMetadata: { secret: "opaque" } },
},
}),
],
}),
)
expect(replay.body.input).toEqual([
{
type: "message",
id: "msg_1",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Hi" }],
},
])
expect(replay.body.include).toEqual(["reasoning.encrypted_content"])
}),
)
})
+12 -4
View File
@@ -237,13 +237,16 @@ describe("LLMClient tools", () => {
LLMEvent.toolError({
id: "call_2",
name: "missing",
message: "Unknown tool: missing",
message: 'No tool named "missing" is currently available. Please use a tool from the available tool list.',
providerMetadata,
}),
LLMEvent.toolResult({
id: "call_2",
name: "missing",
result: { type: "error", value: "Unknown tool: missing" },
result: {
type: "error",
value: 'No tool named "missing" is currently available. Please use a tool from the available tool list.',
},
providerMetadata,
}),
])
@@ -712,12 +715,17 @@ describe("LLMClient tools", () => {
const toolError = events.find(LLMEvent.is.toolError)
expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "missing_tool" })
expect(toolError?.message).toContain("Unknown tool")
expect(toolError?.message).toBe(
'No tool named "missing_tool" is currently available. Please use a tool from the available tool list.',
)
expect(events.find(LLMEvent.is.toolResult)).toMatchObject({
type: "tool-result",
id: "call_1",
name: "missing_tool",
result: { type: "error", value: "Unknown tool: missing_tool" },
result: {
type: "error",
value: 'No tool named "missing_tool" is currently available. Please use a tool from the available tool list.',
},
})
}),
)
@@ -0,0 +1,37 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test("loads home and the directory picker without newer browser APIs", async ({ page }) => {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
fileList: () => [],
})
await page.addInitScript((directory) => {
// Safari 16.6 has neither API. Remove them before the web entry runs.
Reflect.deleteProperty(Map, "groupBy")
Reflect.deleteProperty(Promise, "withResolvers")
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
}, fixture.directory)
await page.goto("/")
const row = page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle })
await expect(row).toBeVisible()
await page.getByRole("button", { name: "Add project", exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toBeHidden()
await expect(row).toBeVisible()
})
@@ -59,8 +59,9 @@ for (const shared of [true, false]) {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await page.keyboard.press("ControlOrMeta+;")
const dialog = page.getByRole("dialog", { name: "MCPs", exact: true })
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const dialog = page.getByRole("dialog", { name: "MCP", exact: true })
await expect(dialog.getByText("figma-desktop", { exact: true })).toBeVisible()
const toggle = dialog.getByRole("switch")
await expect(toggle).not.toBeChecked()
@@ -92,7 +93,7 @@ for (const surface of ["popover", "dialog"] as const) {
const state = { fail: true, status: surface === "popover" ? "failed" : "disabled" }
const requests: { path: string; directory: string }[] = []
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { showStatus: true } }))
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "mcp.toggle": "ctrl+;" } }))
})
await mockOpenCodeServer(page, {
directory,
@@ -113,6 +114,10 @@ for (const surface of ["popover", "dialog"] as const) {
const url = new URL(route.request().url())
const target = url.searchParams.get("location[directory]") ?? directory
requests.push({ path: url.pathname, directory: target })
if (url.pathname === "/api/mcp/figma-desktop/disconnect") {
state.status = "disabled"
return route.fulfill({ status: 204 })
}
if (url.pathname === "/api/mcp/figma-desktop/connect") {
state.status = state.fail ? "failed" : "connected"
// Connection failures are reported by the refreshed status, not the HTTP response.
@@ -137,12 +142,19 @@ for (const surface of ["popover", "dialog"] as const) {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
const panel =
surface === "popover" ? page.getByRole("tabpanel") : page.getByRole("dialog", { name: "MCPs", exact: true })
if (surface === "popover") {
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
}
if (surface === "dialog") await page.keyboard.press("Control+;")
const panel = page.getByRole("dialog", { name: surface === "popover" ? "MCP" : "MCPs", exact: true })
const toggle = panel.getByRole("switch")
await expect(panel.getByText("figma-desktop", { exact: true })).toBeVisible()
await expect(toggle).toBeEnabled()
if (surface === "popover") {
await expect(toggle).toBeChecked()
await panel.getByText("figma-desktop", { exact: true }).click()
}
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
requests.length = 0
@@ -152,7 +164,7 @@ for (const surface of ["popover", "dialog"] as const) {
.getByRole("listitem", { includeHidden: true })
.filter({ has: page.getByText("Request failed", { exact: true }) })
await expect(toast.getByText(`figma-desktop: ${error}`, { exact: true })).toBeVisible()
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeChecked({ checked: surface === "popover" })
await expect(toggle).toBeEnabled()
expect(requests.filter((request) => request.path.endsWith("/connect"))).toEqual([
{ path: "/api/mcp/figma-desktop/connect", directory: workspace },
@@ -167,9 +179,18 @@ for (const surface of ["popover", "dialog"] as const) {
await toast.getByRole("button", { name: "Dismiss", exact: true }).click()
await expect(toast).toBeHidden()
state.fail = false
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
if (surface === "popover") {
await expect(page.getByRole("dialog", { name: "Session details", exact: true })).toBeHidden()
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
}
if (surface === "dialog") await page.keyboard.press("Control+;")
await expect(toggle).toBeEnabled()
if (surface === "popover") {
await panel.getByText("figma-desktop", { exact: true }).click()
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
}
await panel.locator('[data-slot="switch-control"]').click()
await expect(toggle).toBeChecked()
await expect(toggle).toBeEnabled()
@@ -104,18 +104,12 @@ for (const position of ["top", "bottom"] as const) {
await expect(composer).toBeHidden()
await more.click()
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
const status = page.getByRole("dialog", { name: "Status", exact: true })
await expect(status.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
await status.getByRole("button", { name: "Close", exact: true }).click()
await expect(status).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
const details = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible()
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await details.getByRole("button", { name: "Close", exact: true }).click()
await expect(details).toBeHidden()
await expect(more).toBeFocused()
@@ -2,20 +2,21 @@ import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
test("status drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
test("summary drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
await page.goto(stressSessionHref(fixture.targetID))
const more = page
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
const drawer = page.getByRole("dialog", { name: "Session details", exact: true })
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
await more.click()
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await expect(drawer.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
// Corvu starts opening after paint; the transition flag is also absent
// before that callback. Wait for the open position before dismissing.
await expect
@@ -0,0 +1,476 @@
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer, currentSession } from "../utils/mock-server"
import { openWithDirection } from "../utils/direction"
const directory = "/workspace/summary-project"
const workspace = "/workspace/existing-worktree"
const createdWorkspace = "/workspace/created-worktree"
const draftID = "draft_summary"
const secondDraftID = "draft_summary_other"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const draftPath = `/new-session?draftId=${draftID}`
for (const rtl of [false, true]) {
test(`new session summary shows project extensions and follows workspace selection in ${rtl ? "rtl" : "ltr"}`, async ({
page,
}, testInfo) => {
const mock = await openDraft(page, "main", { direction: rtl ? "rtl" : "ltr" })
await expect(page.locator("html")).toHaveAttribute("dir", rtl ? "rtl" : "ltr")
await expect(page.locator("html")).toHaveAttribute("lang", "en")
const trigger = page.getByRole("button", { name: "Session details", exact: true })
await expect
.poll(async () => {
const button = await trigger.boundingBox()
const view = await page.locator('[data-component="new-session"]').boundingBox()
if (!button || !view) return false
const gap = rtl ? button.x - view.x : view.x + view.width - button.x - button.width
return Math.abs(gap - 12) <= 1 && Math.abs(button.y + button.height / 2 - view.y - 24) <= 1
})
.toBe(true)
await trigger.click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(summary.getByRole("button", { name: "summary-project", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect
.poll(async () => {
const button = await trigger.boundingBox()
const project = await summary.locator('[data-section="project"]').boundingBox()
const server = await summary.locator('[data-section="server"]').boundingBox()
if (!button || !project || !server) return
return {
top: project.y - button.y - button.height,
cards: server.y - project.y - project.height,
}
})
.toEqual({ top: 12, cards: 8 })
await testInfo.attach(`new-session-summary-${rtl ? "rtl" : "ltr"}`, {
body: await page.screenshot(),
contentType: "image/png",
})
for (const [name, item] of [
["MCP", "summary-mcp"],
["Plugins", "project-plugin"],
["Skills", "summary-skill"],
["LSP", "typescript"],
]) {
await summary.getByRole("button", { name, exact: true }).click()
await expect(page.getByRole("dialog", { name, exact: true }).getByText(item, { exact: true })).toBeVisible()
}
await page.keyboard.press("Escape")
await summary.getByRole("button", { name: "Local repository", exact: true }).click()
const worktreeMenu = page.getByRole("menu", { name: "Local repository", exact: true })
await expect
.poll(async () => {
const menu = await worktreeMenu.boundingBox()
const panel = await summary.locator('[data-component="session-summary-panel"]').boundingBox()
if (!menu || !panel) return false
return rtl ? menu.x >= panel.x + panel.width : menu.x + menu.width <= panel.x
})
.toBe(true)
await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeHidden()
await worktreeMenu.getByRole("menuitem", { name: "Worktree", exact: true }).press(rtl ? "ArrowLeft" : "ArrowRight")
await expect(page.getByRole("menu", { name: "Worktree", exact: true })).toBeVisible()
await page.keyboard.press("Enter")
await expect(summary.getByRole("button", { name: "existing-worktree", exact: true })).toBeVisible()
await summary.getByRole("button", { name: "MCP", exact: true }).click()
const mcp = page.getByRole("dialog", { name: "MCP", exact: true })
const toggle = mcp.getByRole("switch", { name: "summary-mcp", exact: true })
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
await mcp.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).toBeChecked()
await expect(toggle).toBeEnabled()
expect(mock.calls).toEqual([{ type: "mcp", directory: workspace, enabled: true }])
expect(mock.status.get(directory)).toBe("connected")
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
await expect(
page.getByRole("dialog", { name: "Plugins", exact: true }).getByText("workspace-plugin", { exact: true }),
).toBeVisible()
})
}
test("non-Git folders show their status without offering worktree actions", async ({ page }) => {
await openDraft(page, "main", { git: false })
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(summary.getByText("No Git", { exact: true })).toBeVisible()
await expect(summary.getByRole("button", { name: "Local repository", exact: true })).toHaveCount(0)
await expect(summary.getByRole("button", { name: "New worktree", exact: true })).toHaveCount(0)
await summary.getByRole("button", { name: "MCP", exact: true }).click()
await expect(
page.getByRole("dialog", { name: "MCP", exact: true }).getByRole("switch", { name: "summary-mcp", exact: true }),
).toBeEnabled()
})
test("new worktree MCP choices persist per draft and apply before the first prompt", async ({ page }, testInfo) => {
const mock = await openDraft(page, "create")
await page.locator('[data-component="composer-editor"]').fill("Use my selected MCPs")
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
await expect(menu.locator('[data-slot="mcp-preview-hint"]')).toHaveText("Applies when the worktree is created")
await expect(menu).toHaveCSS("opacity", "1")
await testInfo.attach("new-worktree-mcp-preview", { body: await page.screenshot(), contentType: "image/png" })
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
await expect(toggle).toBeChecked()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).not.toBeChecked()
expect(mock.calls).toEqual([])
await expect
.poll(() =>
page.evaluate(
() =>
JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs") ?? "[]").find(
(tab: { draftID?: string }) => tab.draftID === "draft_summary",
)?.mcp?.states,
),
)
.toEqual({ "summary-mcp": false })
await page.goto(`/new-session?draftId=${secondDraftID}`)
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
await expect(toggle).toBeChecked()
await page.goto(draftPath)
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
await expect(toggle).not.toBeChecked()
expect(mock.calls).toEqual([])
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Use my selected MCPs")
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls).toEqual([
{ type: "worktree", directory },
{ type: "mcp", directory: createdWorkspace, enabled: false },
{ type: "session", directory: createdWorkspace },
{ type: "prompt", directory: createdWorkspace },
])
expect(mock.prompts[0].body.text).toBe("Use my selected MCPs")
expect(mock.status.get(directory)).toBe("connected")
})
test("the first prompt waits for a live MCP toggle", async ({ page }) => {
const mock = await openDraft(page)
const release = Promise.withResolvers<void>()
mock.state.hold = release.promise
await page.locator('[data-component="composer-editor"]').fill("Wait for the MCP update")
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
await expect(menu.getByRole("switch", { name: "summary-mcp", exact: true })).toBeEnabled()
try {
await menu.getByText("summary-mcp", { exact: true }).click()
await expect.poll(() => mock.calls.length).toBe(1)
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
await page.locator('[data-action="composer-submit"]').click()
await expect(page).toHaveURL(draftPath)
expect(mock.calls).toEqual([{ type: "mcp", directory, enabled: false }])
expect(mock.prompts).toEqual([])
} finally {
release.resolve()
}
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls).toEqual([
{ type: "mcp", directory, enabled: false },
{ type: "session", directory },
{ type: "prompt", directory },
])
})
test("changing worktrees does not wait for another directory's pending MCP update", async ({ page }) => {
const mock = await openDraft(page)
const release = Promise.withResolvers<void>()
mock.state.hold = release.promise
mock.state.holdDirectory = directory
await page.locator('[data-component="composer-editor"]').fill("Run in the selected worktree")
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await summary.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
try {
await expect(menu.getByRole("switch", { name: "summary-mcp", exact: true })).toBeEnabled()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect.poll(() => mock.calls.length).toBe(1)
await page.keyboard.press("Escape")
await summary.getByRole("button", { name: "Local repository", exact: true }).click()
await page.getByRole("menuitem", { name: "Worktree", exact: true }).click()
await page.getByRole("menuitem", { name: "existing-worktree", exact: true }).click()
await summary.getByRole("button", { name: "MCP", exact: true }).click()
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
await expect(toggle).toBeEnabled()
await expect(toggle).not.toBeChecked()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).toBeChecked()
await expect(toggle).toBeEnabled()
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls.find((call) => call.type === "session")?.directory).toBe(workspace)
expect(mock.status.get(directory)).toBe("connected")
} finally {
release.resolve()
}
})
test("failed MCP preparation restores the draft and reuses the created worktree", async ({ page }) => {
const mock = await openDraft(page, "create")
mock.state.fail = true
await page.locator('[data-component="composer-editor"]').fill("Keep this prompt on failure")
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
await page.getByRole("dialog", { name: "MCP", exact: true }).getByText("summary-mcp", { exact: true }).click()
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
await page.locator('[data-action="composer-submit"]').click()
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
await expect(page).toHaveURL(draftPath)
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Keep this prompt on failure")
await expect(page.getByRole("button", { name: "created-worktree", exact: true })).toBeVisible()
expect(mock.prompts).toEqual([])
expect(mock.calls.map((call) => call.type)).toEqual(["worktree", "mcp"])
mock.state.fail = false
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls.filter((call) => call.type === "worktree")).toHaveLength(1)
expect(mock.status.get(createdWorkspace)).toBe("disabled")
expect(mock.prompts[0].body.text).toBe("Keep this prompt on failure")
})
test("new worktree sign-in completes before the draft can send", async ({ page, context }) => {
const mock = await openDraft(page, "create")
mock.status.set(createdWorkspace, "needs_auth")
const attempts: string[] = []
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
await page.route("**/api/integration/**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
if (route.request().method() === "POST") {
attempts.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
return route.fulfill({
json: { location: { directory: createdWorkspace }, data: { url: "https://auth.example.test/authorize" } },
})
}
return route.fulfill({
json: {
location: { directory: createdWorkspace },
data: {
id: "summary-oauth",
methods: [{ id: "oauth", type: "oauth" }],
},
},
})
})
await page.locator('[data-component="composer-editor"]').fill("Wait for my sign-in")
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
await expect(toggle).toBeChecked()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).not.toBeChecked()
await menu.getByText("summary-mcp", { exact: true }).click()
await expect(toggle).toBeChecked()
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
const popup = page.waitForEvent("popup")
await page.locator('[data-action="composer-submit"]').click()
await expect(await popup).toHaveURL("https://auth.example.test/authorize")
await expect(page).toHaveURL(draftPath)
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Wait for my sign-in")
expect(attempts).toEqual([createdWorkspace])
expect(mock.prompts).toEqual([])
mock.status.set(createdWorkspace, "connected")
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.calls.filter((call) => call.type === "worktree")).toHaveLength(1)
expect(attempts).toHaveLength(1)
})
async function openDraft(page: Page, worktree = "main", options: { git?: boolean; direction?: "ltr" | "rtl" } = {}) {
const project = {
id: "proj_new_summary",
worktree: directory,
name: "summary-project",
vcs: options.git === false ? undefined : "git",
time: { created: 1, updated: 1 },
sandboxes: [workspace],
}
const sessions: ReturnType<typeof currentSession>[] = []
const status = new Map<string, string>([
[directory, "connected"],
[workspace, "disabled"],
[createdWorkspace, "connected"],
])
const calls: { type: string; directory: string; enabled?: boolean }[] = []
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
const state: { fail: boolean; hold?: Promise<void>; holdDirectory?: string } = { fail: false }
await mockOpenCodeServer(page, {
directory,
project,
sessions,
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "summary-model": { id: "summary-model", name: "Summary Model", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "summary-model" },
},
pageMessages: () => ({ items: [] }),
onPrompt(input) {
const session = sessions.find((session) => session.id === input.sessionID)
if (!session?.location.directory) throw new Error("Prompt arrived before session creation")
calls.push({ type: "prompt", directory: session.location.directory })
prompts.push(input)
},
})
if (options.git === false) {
await page.route(
(url) => url.pathname === "/api/vcs",
(route) => route.fulfill({ json: { location: { directory }, data: { branch: {} } } }),
)
}
await page.route("**/api/mcp**", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
const target = url.searchParams.get("location[directory]") ?? directory
if (route.request().method() === "POST") {
const enabled = url.pathname.endsWith("/connect")
calls.push({ type: "mcp", directory: target, enabled })
if (!state.holdDirectory || state.holdDirectory === target) await state.hold
if (state.fail) return route.fulfill({ status: 500, json: { message: "MCP fixture failed" } })
status.set(target, enabled ? "connected" : "disabled")
return route.fulfill({ status: 204 })
}
return route.fulfill({
json: {
location: { directory: target },
data:
url.pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: [
{
name: "summary-mcp",
integrationID: "summary-oauth",
status: { status: status.get(target) ?? "connected" },
},
],
},
})
})
await page.route(
(url) => url.pathname === "/api/location",
(route) =>
route.fulfill({
json: {
directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory,
project: { id: project.id, directory, canonical: directory },
},
}),
)
await page.route(
(url) => url.pathname === "/api/plugin",
(route) => {
const target = new URL(route.request().url()).searchParams.get("location[directory]") ?? directory
const id = target === directory ? "project-plugin" : "workspace-plugin"
return route.fulfill({
json: {
location: { directory: target },
data: [{ id, source: { type: "package", target: id }, features: {}, state: { status: "active" } }],
},
})
},
)
await page.route(
(url) => url.pathname === "/api/skill",
(route) =>
route.fulfill({
json: {
location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory },
data: [
{
id: "summary-skill",
name: "summary-skill",
location: "/skills/summary/SKILL.md",
content: "Summary skill",
},
],
},
}),
)
await page.route(
(url) => url.pathname === "/api/config",
(route) =>
route.fulfill({
json: [{ type: "document", info: { lsp: { typescript: { command: ["typescript-language-server"] } } } }],
}),
)
await page.route(
(url) => url.pathname === "/api/worktree",
(route) => {
if (route.request().method() !== "POST") return route.fallback()
calls.push({ type: "worktree", directory })
project.sandboxes.push(createdWorkspace)
return route.fulfill({ json: { directory: createdWorkspace } })
},
)
await page.route(
(url) => url.pathname === "/api/session",
(route) => {
if (route.request().method() !== "POST") return route.fallback()
const body: { id: string; location: { directory: string } } = route.request().postDataJSON()
calls.push({ type: "session", directory: body.location.directory })
const session = currentSession(
{ ...body, projectID: project.id, title: "Created summary session" },
body.location.directory,
)
sessions.push(session)
return route.fulfill({ json: { data: session } })
},
)
await page.addInitScript(
({ directory, server, draftID, secondDraftID, worktree }) => {
if (!localStorage.getItem("opencode.global.dat:server"))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
if (!localStorage.getItem("opencode.window.browser.dat:tabs"))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "draft", draftID, server, directory, worktree },
{ type: "draft", draftID: secondDraftID, server, directory, worktree },
]),
)
},
{ directory, server, draftID, secondDraftID, worktree },
)
if (options.direction) await openWithDirection(page, draftPath, options.direction)
if (!options.direction) await page.goto(draftPath)
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(page.locator('[data-action="composer-model"]')).toContainText("Summary Model")
if (options.git === false) await expect(page.getByText("No Git", { exact: true })).toBeVisible()
if (options.git !== false) {
await expect(
page.getByRole("button", { name: worktree === "create" ? "New worktree" : "Local", exact: true }),
).toBeVisible()
}
return { calls, prompts, status, state }
}
@@ -0,0 +1,109 @@
import { expect, test } from "@playwright/test"
import type { ProjectUpdateInput } from "@opencode/client/promise"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "/repo/project-autosave"
const project = {
id: "proj_autosave",
canonical: directory,
name: "Autosave project",
icon: { color: "orange" },
commands: { start: "echo setup" },
sandboxes: [],
time: { created: 1, updated: 1 },
}
test.use({ viewport: { width: 1280, height: 900 } })
for (const field of [
{
name: "color",
label: "",
initial: "orange",
next: "blue",
patches: [{ icon: { color: "blue", override: "" } }, { icon: { color: "orange", override: "" } }],
},
{
name: "name",
label: "Project name",
initial: project.name,
next: "Renamed project",
patches: [{ name: "Renamed project" }, { name: project.name }],
},
{
name: "startup script",
label: "Worktree startup script",
initial: project.commands.start,
next: "bun install",
patches: [{ commands: { start: "bun install" } }, { commands: { start: project.commands.start } }],
},
]) {
test(`keeps the final project ${field.name} when reverting during an autosave`, async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project,
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript(
({ directory, server }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
list: [{ type: "http", http: { url: server } }],
projects: {
local: [{ worktree: directory, expanded: true }],
[server]: [{ worktree: directory, expanded: true }],
},
}),
)
},
{ directory, server },
)
const pending = Promise.withResolvers<void>()
const patches: unknown[] = []
await page.route(`**/api/project/${project.id}`, async (route) => {
const patch: Pick<ProjectUpdateInput, "name" | "icon" | "commands"> = route.request().postDataJSON()
patches.push(patch)
if (patches.length === 1) await pending.promise
await route.fulfill({ json: { ...project, ...patch } })
})
await page.goto("/settings")
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: project.name, exact: true }).click()
const edit = async (value: string) => {
if (field.name === "color") {
await settings.getByRole("button", { name: `Select ${value} color`, exact: true }).click()
return
}
const input = settings.getByRole("textbox", { name: field.label, exact: true })
await input.fill(value)
await input.blur()
}
const first = page.waitForRequest(
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === `/api/project/${project.id}`,
)
await edit(field.next)
await first
await edit(field.initial)
expect(patches).toEqual([field.patches[0]])
pending.resolve()
await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0)
expect(patches).toEqual(field.patches)
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await settings.getByRole("tab", { name: project.name, exact: true }).click()
if (field.name === "color") {
await expect(settings.getByRole("button", { name: "Select orange color", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
)
return
}
await expect(settings.getByRole("textbox", { name: field.label, exact: true })).toHaveValue(field.initial)
})
}
@@ -0,0 +1,129 @@
import { expect, test } from "@playwright/test"
import type { ConfigEntry } from "@opencode/client/promise"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "/repo/configured-lsp"
const entries: ConfigEntry[] = [
{
type: "document",
path: "/config/opencode.json",
info: {
lsp: {
typescript: { command: ["typescript-language-server", "--stdio"], extensions: [".ts", ".tsx"] },
},
},
},
{
type: "document",
path: `${directory}/opencode.jsonc`,
info: {
lsp: {
typescript: { disabled: true },
rust: { command: ["rust-analyzer"], extensions: [".rs"] },
},
},
},
]
test.use({ viewport: { width: 1280, height: 900 } })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_configured_lsp",
canonical: directory,
name: "Configured LSP project",
sandboxes: [],
time: { created: 1, updated: 1 },
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
}, directory)
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: "Configured LSP project", exact: true }).click()
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
})
test("shows inherited and project-configured LSP entries with config-only status", async ({ page }) => {
const ready = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === "/api/config",
async (route) => {
await ready.promise
await route.fulfill({ json: entries })
},
)
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/config")
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
expect(new URL((await requested).url()).searchParams.get("location[directory]")).toBe(directory)
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
await expect(panel.getByText("Loading", { exact: true })).toBeVisible()
ready.resolve()
await expect(panel.getByText("typescript", { exact: true })).toBeVisible()
await expect(panel.getByText("rust", { exact: true })).toBeVisible()
const typescript = panel.locator(".project-settings-extension-row").filter({ hasText: "typescript" })
await expect(typescript).toContainText("Disabled in config")
await expect(typescript).toContainText(".ts, .tsx")
await expect(panel.locator(".project-settings-extension-row").filter({ hasText: "rust" })).toContainText(
"Enabled in config",
)
await expect(panel.getByRole("switch")).toHaveCount(0)
await expect(panel.getByText("Setup required", { exact: true })).toHaveCount(0)
await page.setViewportSize({ width: 390, height: 844 })
await expect(panel.getByText("rust", { exact: true })).toBeInViewport()
await expect
.poll(() => settings.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeLessThanOrEqual(1)
})
for (const lsp of [true, false]) {
test(`handles boolean lsp=${lsp} without inventing detected servers`, async ({ page }) => {
await page.route(
(url) => url.pathname === "/api/config",
(route) =>
route.fulfill({
json: [{ type: "document", info: { lsp } }],
}),
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
await expect(
panel.getByText(lsp ? "No language servers configured" : "Language servers disabled", { exact: true }),
).toBeVisible()
await expect(panel.locator(".project-settings-extension-row")).toHaveCount(0)
})
}
test("keeps configuration load failures inside the tab and allows retry", async ({ page }) => {
const state = { fail: true }
await page.route(
(url) => url.pathname === "/api/config",
(route) =>
route.fulfill({
status: state.fail ? 404 : 200,
json: state.fail ? {} : entries,
}),
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
await expect(panel.getByText("Could not load language server configuration", { exact: true })).toBeVisible()
state.fail = false
await panel.getByRole("button", { name: "Retry", exact: true }).click()
await expect(panel.getByText("typescript", { exact: true })).toBeVisible()
await settings.getByRole("tab", { name: "Skills", exact: true }).click()
await expect(settings.getByRole("tabpanel", { name: "Skills", exact: true })).toBeVisible()
})
@@ -20,6 +20,7 @@ test("session settings use the remote server context", async ({ page }) => {
// one toggle sweeps every connected server, not just the focused one.
await mockServers(page, permissionRequests, permissionResponses, {
pending: { [serverA]: [pendingPermission("permission-pending-a", sessionA.id)] },
preferencesUnavailable: true,
})
await configureServers(page)
@@ -34,8 +35,10 @@ test("session settings use the remote server context", async ({ page }) => {
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
await expect(page.getByRole("button", { name: "Home", exact: true })).toHaveAttribute("aria-pressed", "false")
await expect(page.getByRole("dialog")).toHaveCount(0)
await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px")
await expect(settings.getByRole("complementary")).toHaveCSS("width", "328px")
await expect(sessionHeading).toBeHidden()
await expect(settings.getByText("Servers", { exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveCount(0)
const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]')
const input = autoAccept.getByRole("switch")
await expect(autoAccept).toBeVisible()
@@ -63,9 +66,21 @@ test("session settings use the remote server context", async ({ page }) => {
},
])
await settings.getByRole("tab", { name: "127.0.0.1:4097", exact: true }).click()
await expect(settings.getByRole("button", { name: "Back to settings", exact: true })).toBeVisible()
await expect(settings.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
await expect(settings.getByRole("tab")).toHaveText([
"127.0.0.1:4097",
"Projects",
"Worktrees",
"Providers",
"Models",
"Extensions",
])
await settings.getByRole("tab", { name: "Models" }).click()
await expect(settings.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
await settings.getByRole("button", { name: "Back to settings" }).click()
await settings.getByRole("button", { name: "Back to app" }).click()
await expect(settings).toBeHidden()
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
@@ -73,7 +88,7 @@ test("session settings use the remote server context", async ({ page }) => {
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toContainText(sessionB.title)
await page.keyboard.press("Control+]")
await expect(page).toHaveURL("/settings")
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
@@ -311,6 +326,7 @@ type MockServerOptions = {
listFailures?: Record<string, number>
// Records /api/session/:id GETs so tests can assert session resyncs.
sessionGets?: string[]
preferencesUnavailable?: boolean
}
async function mockServers(
@@ -402,6 +418,11 @@ async function mockServers(
directory,
project: { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory },
})
if (url.pathname === "/api/config/preferences") return json(route, {}, options.preferencesUnavailable ? 404 : 200)
if (url.pathname === "/api/config/shell")
return json(route, options.preferencesUnavailable ? {} : [], options.preferencesUnavailable ? 404 : 200)
if (url.pathname === "/api/websearch/provider")
return json(route, { location: { directory }, data: [] }, options.preferencesUnavailable ? 404 : 200)
if (url.pathname === "/api/worktree") return json(route, [{ directory }])
if (url.pathname === "/api/vcs")
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
@@ -1,5 +1,6 @@
import { base64Encode } from "@opencode/util/encode"
import { expect, test } from "@playwright/test"
import type { OpenCodeEvent } from "@opencode/client/promise"
import { expect, test, type Route } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
@@ -13,6 +14,7 @@ test.use({ viewport: { width: 1440, height: 900 } })
test("opens and searches project files inline", async ({ page }) => {
const searches: { query: string; dirs?: string; limit?: number }[] = []
const events: OpenCodeEvent[] = []
await mockOpenCodeServer(page, {
directory,
project: {
@@ -58,6 +60,7 @@ test("opens and searches project files inline", async ({ page }) => {
searches.push(input)
return input.query === "nested" ? ["src/nested.ts"] : []
},
events: () => events.splice(0),
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
@@ -126,6 +129,35 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(sidebar).toHaveCount(0)
const missingReadPattern = "**/api/fs/read/README.md*"
const missingRead = (route: Route) =>
route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({
_tag: "FileNotFoundError",
path: "README.md",
message: "File not found: README.md",
}),
})
await page.route(missingReadPattern, missingRead)
const missingResponse = page.waitForResponse((response) => response.url().includes("/api/fs/read/README.md"))
events.push(filesystemEvent("README.md", "unlink"))
await missingResponse
await expect(page.getByText("Failed to load file", { exact: true })).toBeVisible()
const missingTab = panel.getByRole("tab", { name: "File not found: README.md", selected: true })
await expect(missingTab).toBeVisible()
await expect(missingTab.locator("[data-file-not-found]")).toHaveCSS("text-decoration-line", "line-through")
await expect(panel.getByText("File not found: README.md", { exact: true })).toBeVisible()
await page.unroute(missingReadPattern, missingRead)
events.push(filesystemEvent("README.md", "add"))
const restoredTab = panel.getByRole("tab", { name: "README.md", selected: true })
await expect(restoredTab).toBeVisible()
await expect(restoredTab.locator("[data-file-not-found]")).toHaveCount(0)
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
await expect(sidebar).toBeVisible()
@@ -155,6 +187,16 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible()
})
function filesystemEvent(file: string, event: "add" | "change" | "unlink"): OpenCodeEvent {
return {
id: `evt_${file}_${event}`,
created: 1,
type: "filesystem.changed",
location: { directory },
data: { file, event },
}
}
function fileNode(path: string) {
return {
name: path,
@@ -27,8 +27,12 @@ test("server dialog keeps focus above fullscreen settings", async ({ page }) =>
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
await expect(page.getByRole("dialog")).toHaveCount(0)
await settings.getByRole("tab", { name: "Servers" }).click()
await settings.getByRole("button", { name: "Add server" }).click()
const add = settings.getByRole("button", { name: "Add server" })
const group = settings.locator('[data-component="settings-nav-group-header"]').filter({ hasText: "Servers" })
await expect(add).toHaveCSS("opacity", "0")
await group.hover()
await expect(add).toHaveCSS("opacity", "1")
await add.click()
const editor = page.getByRole("dialog", { name: "Add server" })
await expect(editor.getByPlaceholder("http://localhost:4096")).toBeFocused()
@@ -4,7 +4,7 @@ import { installStressSessionTabs, stressSessionHref } from "../performance/time
import { mockOpenCodeServer } from "../utils/mock-server"
for (const direction of ["ltr", "rtl"] as const) {
test(`session header groups controls and exposes server status in ${direction}`, async ({ page }) => {
test(`session header groups controls and exposes session details in ${direction}`, async ({ page }) => {
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
@@ -31,7 +31,7 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(review).toBeVisible()
await expect(details).toBeVisible()
const status = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" })
await expect(status).toBeVisible()
await expect(status).toHaveCount(0)
const titleBounds = await header.getByRole("heading").boundingBox()
expect(titleBounds).not.toBeNull()
for (const editing of [false, true]) {
@@ -122,12 +122,11 @@ for (const direction of ["ltr", "rtl"] as const) {
.toBe(true)
await expect(page.getByRole("menuitem", { name: "Server status", exact: true })).toHaveCount(0)
await page.keyboard.press("Escape")
await status.click()
const mcp = page.getByRole("tab", { name: "MCP", exact: true })
const plugins = page.getByRole("tab", { name: "Plugins", exact: true })
await expect(mcp).toHaveAttribute("aria-selected", "true")
await plugins.click()
await expect(plugins).toHaveAttribute("aria-selected", "true")
await details.click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
const mcp = summary.getByRole("button", { name: "MCP", exact: true })
await expect(mcp).toBeVisible()
await expect(summary.getByRole("button", { name: "Plugins", exact: true })).toBeVisible()
await page.keyboard.press("Escape")
await expect(mcp).toBeHidden()
})
@@ -198,14 +198,15 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(settings).toBeFocused()
await expect(page.getByRole("tooltip")).toBeHidden()
await page.keyboard.press("Enter")
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("heading", { name: copy["dialog.project.edit.title"], exact: true })).toBeVisible()
await expect(dialog.getByRole("textbox", { name: copy["dialog.project.edit.name"], exact: true })).toHaveValue(
project.name,
)
const settingsScreen = page.getByTestId("settings-screen")
await expect(settingsScreen.getByRole("heading", { name: project.name, exact: true })).toBeVisible()
await expect(
settingsScreen.getByRole("textbox", { name: en["project.settings.name.title"], exact: true }),
).toHaveValue(project.name)
await expect(menu).toBeHidden()
await dialog.getByRole("button", { name: copy["common.cancel"], exact: true }).click()
await expect(dialog).toBeHidden()
await settingsScreen.getByRole("button", { name: en["settings.backToProjects"], exact: true }).click()
await settingsScreen.getByRole("button", { name: en["settings.backToApp"], exact: true }).click()
await expect(settingsScreen).toBeHidden()
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await page.setViewportSize({ width: 1440, height: 900 })
@@ -272,12 +273,13 @@ for (const state of ["closed", "unopened"] as const) {
await expect(menu.getByRole("menuitem", { name: fixture.project.name, exact: true })).toBeEnabled()
await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled()
await menu.getByRole("menuitem", { name: "Edit project", exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox", { name: en["dialog.project.edit.name"], exact: true })).toHaveValue(
fixture.project.name,
)
await dialog.getByRole("button", { name: en["common.cancel"], exact: true }).click()
await expect(dialog).toBeHidden()
const settingsScreen = page.getByTestId("settings-screen")
await expect(
settingsScreen.getByRole("textbox", { name: en["project.settings.name.title"], exact: true }),
).toHaveValue(fixture.project.name)
await settingsScreen.getByRole("button", { name: en["settings.backToProjects"], exact: true }).click()
await settingsScreen.getByRole("button", { name: en["settings.backToApp"], exact: true }).click()
await expect(settingsScreen).toBeHidden()
}
await trigger.click()
await menu.getByRole("menuitem", { name: fixture.project.name, exact: true }).click()
@@ -0,0 +1,99 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode/util/encode"
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
const server = "http://summary-remote.test:4096"
const path = "/home/remote/.config/opencode/opencode.jsonc"
test.use({ serviceWorkers: "block", permissions: ["clipboard-read", "clipboard-write"] })
for (const service of [
{ name: "MCP", config: { mcp: { servers: {} } } },
{ name: "Plugins", config: { plugins: [] } },
{ name: "Skills", config: { skills: [] } },
{ name: "LSP", config: { lsp: false } },
]) {
test(`remote ${service.name} copies its configuration path with timeline copy feedback`, async ({ page }) => {
await setup(page)
await page.route("**/api/config**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
json: [
{ type: "document", path, info: service.config },
{ type: "document", path: `${fixture.directory}/opencode.json`, info: {} },
{ type: "document", path: `${fixture.directory}/.opencode/agents/review.md`, info: {} },
],
})
})
await page.goto(`/server/${base64Encode(server)}/session/${fixture.targetID}`)
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page
.getByRole("dialog", { name: "Session details", exact: true })
.getByRole("button", { name: service.name, exact: true })
.click()
const menu = page.getByRole("dialog", { name: service.name, exact: true })
const copy = menu.getByRole("button", { name: "Copy configuration file path", exact: true })
const tooltipOffset = async () => {
const icon = await copy.locator("svg").boundingBox()
const tooltip = await page.getByRole("tooltip").boundingBox()
if (!icon || !tooltip) return Infinity
return Math.abs(tooltip.x + tooltip.width / 2 - icon.x - icon.width / 2)
}
await expect(copy).toBeEnabled()
await expect(copy.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-outline-copy")
await expect(copy.locator(".session-service-config-arrow")).toHaveCount(0)
await copy.hover()
await expect(page.getByRole("tooltip")).toHaveText("Copy")
await expect.poll(tooltipOffset).toBeLessThanOrEqual(1)
await copy.click()
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(path)
await expect(page.getByRole("tooltip")).toHaveText("Copied")
await expect.poll(tooltipOffset).toBeLessThanOrEqual(1)
await expect(copy.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-check")
await expect(menu).toBeVisible()
await expect(copy.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-outline-copy")
})
}
test("remote configuration without a file path reports the problem instead of copying a directory", async ({
page,
}) => {
await setup(page)
await page.goto(`/server/${base64Encode(server)}/session/${fixture.targetID}`)
await page.evaluate(() => navigator.clipboard.writeText("original clipboard"))
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page
.getByRole("dialog", { name: "Session details", exact: true })
.getByRole("button", { name: "Skills", exact: true })
.click()
const copy = page
.getByRole("dialog", { name: "Skills", exact: true })
.getByRole("button", { name: "Copy configuration file path", exact: true })
await copy.click()
await expect(page.getByText("No configuration file found", { exact: true })).toBeVisible()
await expect(copy).toBeEnabled()
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("original clipboard")
await expect(copy.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-outline-copy")
})
async function setup(page: Page) {
await mockOpenCodeServer(page, {
server,
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await page.addInitScript((server) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
list: [{ type: "http", http: { url: server }, displayName: "Remote server" }],
projects: {},
lastProject: {},
}),
)
}, server)
}
@@ -0,0 +1,225 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
import { openWithDirection } from "../utils/direction"
for (const direction of ["ltr", "rtl"] as const) {
test(`summary slides the conversation into spare space and back in ${direction}`, async ({ page }, testInfo) => {
await page.clock.install({ time: new Date("2026-09-10T12:00:00Z") })
await page.setViewportSize({ width: 1440, height: 900 })
await mockStressTimeline(page)
await openWithDirection(page, stressSessionHref(fixture.targetID), direction)
const trigger = page.getByRole("button", { name: "Session details", exact: true })
await expect(trigger).toBeEnabled()
await expect(page.locator("html")).toHaveAttribute("dir", direction)
await expect(page.locator("html")).toHaveAttribute("lang", "en")
const row = page.locator(
`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.targetMessageIDs.at(-1)}"]`,
)
const content = page.locator("[data-timeline-virtual-content]")
const composer = page.locator('[data-component="session-composer-dock"] > div')
const panel = page.locator('[data-slot="session-chat-panel"]')
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(row).toBeInViewport()
await expect(row).toHaveCSS("width", "1000px")
const before = await row.boundingBox()
const dock = await composer.boundingBox()
expect(before).not.toBeNull()
expect(dock).not.toBeNull()
await content.evaluate((element) => {
element.setAttribute("data-summary-motion", "")
for (const type of ["transitionrun", "transitionend"]) {
element.addEventListener(type, (event) => {
if (event.target !== element || (event as TransitionEvent).propertyName !== "translate") return
element.setAttribute("data-summary-motion", `${element.getAttribute("data-summary-motion")}${type},`)
})
}
})
await testInfo.attach(`summary-${direction}-centered`, { body: await page.screenshot(), contentType: "image/png" })
await trigger.click()
await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,")
await expect
.poll(async () => {
const message = await row.boundingBox()
const details = await summary.boundingBox()
const chat = await panel.boundingBox()
if (!message || !details || !chat) return false
return (
message.x >= chat.x &&
message.x + message.width <= chat.x + chat.width &&
(direction === "ltr" ? message.x + message.width < details.x : message.x > details.x + details.width)
)
})
.toBe(true)
await expect(row).toHaveCSS("width", "1000px")
await expect
.poll(async () => {
const message = await row.boundingBox()
const input = await composer.boundingBox()
if (!message || !input || !before || !dock) return Infinity
return Math.abs(message.x - before.x - (input.x - dock.x))
})
.toBeLessThan(1)
await expect
.poll(() =>
content.evaluate((element) => element.parentElement!.scrollWidth - element.parentElement!.clientWidth),
)
.toBe(0)
await testInfo.attach(`summary-${direction}-shifted`, { body: await page.screenshot(), contentType: "image/png" })
await page.clock.pauseAt(new Date("2026-09-10T12:01:00Z"))
const shifted = await content.evaluate((element) => getComputedStyle(element).translate)
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
// Keep issuing resize events before the idle timer expires, including crossing the width cutoff.
for (const width of [1520, 1280, 1600]) {
await page.setViewportSize({ width, height: 900 })
await expect(panel).toHaveAttribute("data-summary-resizing", "true")
await page.clock.runFor(100)
await expect(content).toHaveCSS("translate", shifted)
await expect(composer).toHaveCSS("translate", shifted)
await expect(content).toHaveAttribute("data-summary-motion", "")
}
await page.clock.resume()
await expect(panel).toHaveAttribute("data-summary-resizing", "false")
await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,")
await expect(content).not.toHaveCSS("translate", shifted)
await page.setViewportSize({ width: 1440, height: 900 })
await expect(content).toHaveCSS("translate", shifted)
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
await page.keyboard.press("Escape")
await expect(summary).toBeHidden()
await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,")
await expect.poll(async () => Math.abs((await row.boundingBox())!.x - before!.x)).toBeLessThan(1)
await expect(trigger).toBeFocused()
await trigger.click()
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toBeVisible()
// Cross the actual chat-panel breakpoint, including any surrounding shell width.
const shell = 1440 - (await panel.boundingBox())!.width
await page.setViewportSize({ width: 1320 + shell, height: 900 })
await expect.poll(async () => (await panel.boundingBox())!.width).toBe(1320)
await expect
.poll(async () => {
const message = (await row.boundingBox())!
const details = (await summary.boundingBox())!
return direction === "ltr" ? details.x - message.x - message.width : message.x - details.x - details.width
})
.toBeGreaterThan(0)
await page.setViewportSize({ width: 1319 + shell, height: 900 })
await expect(content).toHaveCSS("translate", "none")
await expect(row).toHaveCSS("width", "1000px")
await expect(summary).toBeVisible()
await page.setViewportSize({ width: 1800, height: 900 })
await expect(content).toHaveCSS("translate", "0px")
await expect(summary).toBeVisible()
await page.emulateMedia({ reducedMotion: "reduce" })
await page.setViewportSize({ width: 1440, height: 900 })
await expect(content).toHaveCSS("transition-duration", "0s")
await page.keyboard.press("Escape")
await expect(content).toHaveCSS("translate", "none")
await expect.poll(async () => Math.abs((await row.boundingBox())!.x - before!.x)).toBeLessThan(1)
})
}
for (const direction of ["ltr", "rtl"] as const) {
test(`summary truncates long copy before the indicator column in ${direction}`, async ({ page }) => {
const branch = `feature/${"long-branch-name-".repeat(12)}`
await mockStressTimeline(page)
await page.route(
(url) => url.pathname === "/api/vcs",
(route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
json: { location: { directory: fixture.directory }, data: { branch: { current: branch, default: "main" } } },
})
},
)
await openWithDirection(page, stressSessionHref(fixture.targetID), direction)
await expect(page.locator("html")).toHaveAttribute("dir", direction)
await expect(page.locator("html")).toHaveAttribute("lang", "en")
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
const text = summary.getByText(branch, { exact: true })
await expect(text).toBeVisible()
await expect(text).toHaveCSS("text-overflow", "ellipsis")
await expect.poll(() => text.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
const arrow = summary
.getByRole("button", { name: "Local repository", exact: true })
.locator(".session-summary-menu-indicator")
await expect
.poll(async () => {
const label = await text.boundingBox()
const icon = await arrow.boundingBox()
if (!label || !icon) return 0
return direction === "ltr" ? icon.x - label.x - label.width : label.x - icon.x - icon.width
})
.toBeGreaterThanOrEqual(12)
for (const name of ["Local repository", "MCP", "Plugins", "Skills", "LSP"]) {
const row = summary.getByRole("button", { name, exact: true })
await expect
.poll(async () => {
const label = await row.locator(".session-summary-label").boundingBox()
const icon = await row.locator(".session-summary-menu-indicator").boundingBox()
if (!label || !icon) return 0
return direction === "ltr" ? icon.x - label.x - label.width : label.x - icon.x - icon.width
})
.toBeGreaterThanOrEqual(12)
}
})
}
for (const theme of ["light", "dark"] as const) {
test(`summary bounds long service lists in ${theme}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width: 800, height: 600 })
await mockStressTimeline(page)
await page.addInitScript((theme) => {
localStorage.setItem("opencode-theme-id", "oc-2")
localStorage.setItem("opencode-color-scheme", theme)
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: theme === "dark" ? "he" : "en" }))
}, theme)
await page.route("**/api/mcp**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
json: {
location: { directory: fixture.directory },
data:
new URL(route.request().url()).pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: Array.from({ length: 30 }, (_, index) => ({
name: `server-${String(index).padStart(2, "0")}-בדיקה-${"long-name-".repeat(6)}`,
status: { status: "connected" },
})),
},
})
})
await page.goto(stressSessionHref(fixture.targetID))
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", theme)
await page.getByRole("button", { name: theme === "dark" ? "פרטי ההפעלה" : "Session details", exact: true }).click()
const summary = page.locator('[data-component="session-summary-panel"]')
await summary.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
await expect(menu.getByRole("switch")).toHaveCount(30)
await expect.poll(() => menu.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
await expect
.poll(async () => {
const bounds = await menu.boundingBox()
return (
!!bounds &&
bounds.x >= 15 &&
bounds.y >= 15 &&
bounds.x + bounds.width <= 785 &&
bounds.y + bounds.height <= 585
)
})
.toBe(true)
await testInfo.attach(`summary-${theme}-long-list`, { body: await page.screenshot(), contentType: "image/png" })
await menu.getByRole("switch", { name: /^server-29-/ }).focus()
await expect(menu.getByRole("switch", { name: /^server-29-/ })).toBeFocused()
await page.keyboard.press("Escape")
await expect(menu).toBeHidden()
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeFocused()
})
}
@@ -0,0 +1,168 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
test("every MCP row hit area toggles exactly once and keeps the submenu open", async ({ page }, testInfo) => {
await mockStressTimeline(page)
const state = { enabled: true }
const writes: string[] = []
await page.route("**/api/mcp**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
const directory = url.searchParams.get("location[directory]")
if (route.request().method() === "POST") {
expect(directory).toBe(fixture.directory)
writes.push(url.pathname)
state.enabled = url.pathname.endsWith("/connect")
return route.fulfill({ status: 204 })
}
return route.fulfill({
json: {
location: { directory: fixture.directory },
data:
url.pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: [
{ name: "figma", status: { status: state.enabled ? "connected" : "disabled" } },
{ name: "linear", status: { status: "needs_auth" }, integrationID: "linear-oauth" },
{ name: "playwright", status: { status: "failed", error: "Connection refused" } },
{ name: "waiting", status: { status: "pending" } },
],
},
})
})
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
const toggle = submenu.getByRole("switch", { name: "figma", exact: true })
const row = submenu
.locator('[data-component="switch"]')
.filter({ has: page.getByRole("switch", { name: "figma", exact: true }) })
await expect(toggle).toBeChecked()
await expect(submenu.getByRole("switch", { name: "playwright", exact: true })).toBeChecked()
await expect(submenu.getByRole("switch", { name: "playwright", exact: true })).toHaveAccessibleDescription("Failed")
await expect(submenu.getByRole("switch", { name: "waiting", exact: true })).toBeDisabled()
await expect(submenu.getByRole("switch", { name: "waiting", exact: true })).toHaveAccessibleDescription("Connecting…")
await expect(submenu.getByRole("switch", { name: "linear", exact: true })).toHaveAccessibleDescription(
"Sign in required",
)
await testInfo.attach("summary-mcp-states", { body: await page.screenshot(), contentType: "image/png" })
for (const [index, target] of ["label", "dot", "padding", "control", "keyboard"].entries()) {
const enabled = index % 2 !== 0
await expect(toggle).toBeEnabled()
if (target === "label") await row.getByText("figma", { exact: true }).click()
if (target === "dot") await row.locator(".session-service-dot").click()
if (target === "padding") await row.click({ position: { x: 3, y: 3 } })
if (target === "control") await row.locator('[data-slot="switch-control"]').click()
if (target === "keyboard") await toggle.press("Space")
await expect(toggle).toBeChecked({ checked: enabled })
await expect(toggle).toBeEnabled()
await expect(submenu).toBeVisible()
if (target === "keyboard") await expect(toggle).toBeFocused()
expect(writes).toHaveLength(index + 1)
expect(writes[index]).toBe(`/api/mcp/figma/${enabled ? "connect" : "disconnect"}`)
}
})
test("MCP authentication starts before a slow resource catalog finishes", async ({ page, context }) => {
await mockStressTimeline(page)
const state = { status: "disabled" }
const attempts: string[] = []
const resources = Promise.withResolvers<void>()
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
await page.route("**/api/mcp**", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
if (url.pathname.endsWith("/connect")) {
state.status = "needs_auth"
return route.fulfill({ status: 204 })
}
if (url.pathname === "/api/mcp/resource" && state.status === "needs_auth") await resources.promise
return route.fulfill({
json: {
location: { directory: fixture.directory },
data:
url.pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: [{ name: "linear", integrationID: "linear-oauth", status: { status: state.status } }],
},
})
})
await page.route("**/api/integration/**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
if (route.request().method() === "POST") {
attempts.push(route.request().url())
return route.fulfill({
json: { location: { directory: fixture.directory }, data: { url: "https://auth.example.test/authorize" } },
})
}
return route.fulfill({
json: {
location: { directory: fixture.directory },
data: {
id: "linear-oauth",
methods: [{ id: "oauth", type: "oauth" }],
},
},
})
})
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
const toggle = submenu.getByRole("switch", { name: "linear", exact: true })
await expect(toggle).toBeEnabled()
const refresh = page.waitForRequest(
(request) =>
state.status === "needs_auth" &&
new URL(request.url()).pathname === "/api/mcp/resource" &&
request.method() === "GET",
)
try {
const popup = page.waitForEvent("popup")
await submenu.getByText("linear", { exact: true }).click()
await expect(await popup).toHaveURL("https://auth.example.test/authorize")
await refresh
await expect(toggle).toBeChecked()
await expect(toggle).toHaveAccessibleDescription("Sign in required")
} finally {
resources.resolve()
}
await expect(toggle).toBeEnabled()
expect(attempts).toHaveLength(1)
expect(new URL(attempts[0]).searchParams.get("location[directory]")).toBe(fixture.directory)
})
test("multiple desktop connections show the session's server name", async ({ page }) => {
await mockStressTimeline(page)
await page.route("http://secondary.test/**", (route) => route.fulfill({ json: { healthy: true, version: "2.0.0" } }))
await page.addInitScript(
({ directory, server }) => {
const current = { type: "http", http: { url: server }, displayName: "Design server" }
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
list: [current, { type: "http", http: { url: "http://secondary.test" }, displayName: "Other server" }],
projects: { local: [{ worktree: directory, expanded: true }] },
hidden: {},
lastProject: {},
recentlyClosed: {},
}),
)
},
{
directory: fixture.directory,
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
},
)
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(summary.getByRole("button", { name: "Design server", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toHaveCount(0)
})
@@ -0,0 +1,126 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
const services = [
{ name: "MCP", path: "/api/mcp", empty: "No MCP servers configured", item: "summary-mcp" },
{ name: "Plugins", path: "/api/plugin", empty: "No plugins configured", item: "summary-plugin" },
{ name: "Skills", path: "/api/skill", empty: "No skills configured", item: "summary-skill" },
{ name: "LSP", path: "/api/config", empty: "No LSP servers configured", item: "summary-lsp" },
] as const
for (const service of services) {
for (const empty of [false, true]) {
test(`${service.name} keeps ${empty ? "its empty state" : "cached items"} visible while reopening and refreshing`, async ({
page,
}, testInfo) => {
await mockStressTimeline(page)
const warnings: string[] = []
page.on("console", (event) => {
if (event.text().includes("computations created outside")) warnings.push(event.text())
})
const state = { hold: false }
const response = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === service.path,
async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
if (state.hold) await response.promise
if (service.name === "LSP")
return route.fulfill({
json: empty ? [] : [{ type: "document", info: { lsp: { "summary-lsp": { command: ["summary-lsp"] } } } }],
})
const items =
service.name === "MCP"
? [{ name: service.item, status: { status: "connected" } }]
: service.name === "Plugins"
? [
{
id: service.item,
source: { type: "package", target: service.item },
features: {},
state: { status: "active" },
},
]
: [{ id: service.item, name: service.item, location: "/skills/summary/SKILL.md", content: "Summary" }]
return route.fulfill({ json: { location: { directory: fixture.directory }, data: empty ? [] : items } })
},
)
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
const trigger = summary.getByRole("button", { name: service.name, exact: true })
await trigger.click()
const menu = page.getByRole("dialog", { name: service.name, exact: true })
const content = menu.getByText(empty ? service.empty : service.item, { exact: true })
await expect(content).toBeVisible()
await expect(menu).toHaveAttribute("aria-busy", "false")
await expect(menu).toHaveCSS("width", empty ? "232px" : "280px")
if (empty) {
const message = menu.locator(".session-service-empty")
await expect(message).toHaveCSS("padding", "0px")
await expect(message).toHaveCSS("gap", "0px")
await expect(message.locator("strong")).toHaveCSS("padding", "8px 12px")
await expect(message).toHaveCSS("font-size", "13px")
await expect(message).toHaveCSS("line-height", "16px")
await expect(message.locator("strong")).toHaveCSS("font-weight", "530")
await expect(message.locator(".session-service-footer")).toHaveCSS("font-weight", "440")
await testInfo.attach(`${service.name}-empty`, { body: await menu.screenshot(), contentType: "image/png" })
}
await page.keyboard.press("Escape")
await expect(menu).toBeHidden()
state.hold = true
const refresh = page.waitForRequest(
(request) => request.method() === "GET" && new URL(request.url()).pathname === service.path,
)
try {
await trigger.click()
await refresh
await expect(menu).toHaveAttribute("aria-busy", "true")
await expect(content).toBeVisible()
await expect(menu.getByRole("status")).toHaveCount(0)
await expect(menu).toHaveCSS("width", empty ? "232px" : "280px")
await expect(summary).toBeVisible()
} finally {
response.resolve()
}
await expect(menu).toHaveAttribute("aria-busy", "false")
await expect(content).toBeVisible()
expect(warnings).toEqual([])
})
}
}
test("prefetching plugins does not suspend the summary or report an empty catalog", async ({ page }) => {
await mockStressTimeline(page)
const response = Promise.withResolvers<void>()
const state = { requested: false }
await page.route(
(url) => url.pathname === "/api/plugin",
async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
state.requested = true
await response.promise
return route.fulfill({ json: { location: { directory: fixture.directory }, data: [] } })
},
)
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
try {
await expect.poll(() => state.requested).toBe(true)
await expect(summary.getByRole("button", { name: fixture.project.name, exact: true })).toBeVisible()
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toBeVisible()
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
const menu = page.getByRole("dialog", { name: "Plugins", exact: true })
await expect(menu.getByRole("status")).toContainText("Loading")
await expect(menu.getByText("No plugins configured", { exact: true })).toHaveCount(0)
await expect(summary).toBeVisible()
} finally {
response.resolve()
}
await expect(
page.getByRole("dialog", { name: "Plugins", exact: true }).getByText("No plugins configured", { exact: true }),
).toBeVisible()
})
@@ -0,0 +1,296 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import {
installStressSessionTabs,
mockStressTimeline,
stressSessionHref,
} from "../performance/timeline/timeline-test-helpers"
import { openWithDirection } from "../utils/direction"
for (const custom of [false, true]) {
test(`summary tooltip and ${custom ? "custom" : "default"} shortcut follow the active session`, async ({ page }) => {
await mockStressTimeline(page)
await installStressSessionTabs(page)
if (custom) {
await page.addInitScript(() => {
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
localStorage.setItem(
"settings.v3",
JSON.stringify({ ...settings, keybinds: { ...settings.keybinds, "session.summary.toggle": "f8" } }),
)
})
}
await page.goto(stressSessionHref(fixture.sourceID))
const trigger = page.getByRole("button", { name: "Session details", exact: true })
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(trigger).toBeEnabled()
await trigger.hover()
const tooltip = page.getByRole("tooltip")
await expect(tooltip).toBeVisible()
await expect(tooltip).toContainText("Summary")
const mac = await page.evaluate(() => /(Mac|iPod|iPhone|iPad)/.test(navigator.platform))
const shortcut = custom ? "F8" : mac ? "Meta+Shift+Y" : "Control+Shift+Y"
await expect(tooltip.locator('[data-slot="keybind-v2-label"]')).toHaveText(
custom ? ["F8"] : mac ? ["⇧", "⌘", "Y"] : ["Ctrl", "Shift", "Y"],
)
for (const id of [fixture.sourceID, fixture.targetID, fixture.sourceID]) {
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(id)}"]`).click()
await expect(
page.locator(
`[data-timeline-row="UserMessage"][data-message-id="${id === fixture.sourceID ? fixture.expected.sourceMessageIDs.at(-1) : fixture.expected.targetMessageIDs.at(-1)}"]`,
),
).toBeInViewport()
await page.keyboard.press(shortcut)
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toBeVisible()
await expect.poll(() => summary.evaluate((element) => element.contains(document.activeElement))).toBe(true)
await expect(tooltip).toBeHidden()
await page.keyboard.press(shortcut)
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(summary).toBeHidden()
await expect(trigger).toBeFocused()
}
})
}
for (const layout of ["horizontal", "vertical"] as const) {
test(`summary persists both disclosures across sessions with ${layout} tabs`, async ({ page }, testInfo) => {
await mockStressTimeline(page)
await page.addInitScript((layout) => {
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
localStorage.setItem(
"settings.v3",
JSON.stringify({
...settings,
appearance: { ...settings.appearance, tabLayout: layout },
general: { ...settings.general, showStatus: true },
}),
)
}, layout)
await page.goto(stressSessionHref(fixture.targetID))
const trigger = page.getByRole("button", { name: "Session details", exact: true })
await expect(trigger).toBeEnabled()
await expect(page.getByRole("button", { name: "Status", exact: true })).toHaveCount(0)
await trigger.click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
const project = summary.getByRole("button", { name: fixture.project.name, exact: true })
const server = summary.getByRole("button", { name: "Extensions", exact: true })
await expect(project).toHaveAttribute("aria-expanded", "true")
await expect(server).toHaveAttribute("aria-expanded", "true")
for (const heading of [project, server]) {
await expect(heading).toHaveCSS("column-gap", "8px")
await expect(heading.locator(".session-summary-label")).toHaveCSS("flex-grow", "0")
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("width", "14")
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("height", "14")
}
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await testInfo.attach(`summary-${layout}`, { body: await page.screenshot(), contentType: "image/png" })
await project.click()
await expect(project).toHaveAttribute("aria-expanded", "false")
await expect(summary.getByRole("button", { name: "No changes", exact: true })).toHaveCount(0)
await expect(server).toHaveAttribute("aria-expanded", "true")
await server.click()
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(summary).toBeHidden()
await expect(trigger).toBeFocused()
await trigger.click()
await expect(project).toHaveAttribute("aria-expanded", "false")
await expect(server).toHaveAttribute("aria-expanded", "false")
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").sessionSummary))
.toEqual({ projectExpanded: false, serverExpanded: false })
await page.goto(stressSessionHref(fixture.sourceID))
await trigger.click()
await expect(project).toHaveAttribute("aria-expanded", "false")
await expect(server).toHaveAttribute("aria-expanded", "false")
await server.click()
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await expect(project).toHaveAttribute("aria-expanded", "false")
await page.keyboard.press("Escape")
await page.keyboard.press("Control+,")
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
await expect(settings.getByText("Server status", { exact: true })).toHaveCount(0)
})
}
for (const direction of ["ltr", "rtl"] as const) {
test(`service submenus open on click and stay aligned with the view in ${direction}`, async ({ page }, testInfo) => {
await mockStressTimeline(page)
await openWithDirection(page, stressSessionHref(fixture.targetID), direction)
await expect(page.getByRole("button", { name: "Session details", exact: true })).toBeEnabled()
await expect(page.locator("html")).toHaveAttribute("dir", direction)
await expect(page.locator("html")).toHaveAttribute("lang", "en")
const warnings: string[] = []
page.on("console", (event) => {
if (event.text().includes("computations created outside")) warnings.push(event.text())
})
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
const mcp = summary.getByRole("button", { name: "MCP", exact: true })
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
await mcp.hover()
await expect(submenu).toHaveCount(0)
await mcp.click()
await expect(submenu.getByText("No MCP servers configured", { exact: true })).toBeVisible()
await expect(summary).toBeVisible()
await expect
.poll(async () => {
const row = await mcp.boundingBox()
const menu = await submenu.boundingBox()
if (!row || !menu) return false
return direction === "ltr" ? menu.x + menu.width <= row.x : menu.x >= row.x + row.width
})
.toBe(true)
await testInfo.attach(`summary-submenu-${direction}`, { body: await page.screenshot(), contentType: "image/png" })
await page.keyboard.press("Escape")
await expect(submenu).toBeHidden()
await expect(summary).toBeVisible()
await expect(mcp).toBeFocused()
await mcp.press("Enter")
await expect(submenu.getByText("Configuration file")).toBeVisible()
await mcp.click()
await expect(submenu).toBeHidden()
for (const [name, text] of [
["Plugins", "No plugins configured"],
["Skills", "No skills configured"],
["LSP", "No LSP servers configured"],
]) {
await summary.getByRole("button", { name, exact: true }).click()
await expect(page.getByRole("dialog", { name, exact: true }).getByText(text, { exact: true })).toBeVisible()
await expect(submenu).toBeHidden()
}
await summary.getByRole("button", { name: "Extensions", exact: true }).click()
await expect(page.getByRole("dialog", { name: "LSP", exact: true })).toBeHidden()
await page.keyboard.press("Escape")
await expect(summary).toBeHidden()
for (const reviewOpen of [false, true]) {
if (reviewOpen) await page.getByRole("button", { name: "Toggle review", exact: true }).click()
await page.getByRole("button", { name: "Session details", exact: true }).click()
await expect(summary).toBeVisible()
await expect
.poll(async () => {
const header = await page.locator("[data-session-title]").boundingBox()
const panel = await summary.boundingBox()
if (!header || !panel) return Infinity
return direction === "ltr"
? Math.abs(header.x + header.width - panel.x - panel.width - 12)
: Math.abs(panel.x - header.x - 12)
})
.toBeLessThanOrEqual(1)
await page.keyboard.press("Escape")
}
expect(warnings).toEqual([])
})
}
test("catalog submenus show project plugins and skills, refresh on reopen, and distinguish errors from empty", async ({
page,
}, testInfo) => {
await mockStressTimeline(page)
const state = { fail: true, extra: false }
const requests: string[] = []
await page.route("**/api/plugin**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
requests.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
if (state.fail) return route.fulfill({ status: 500, json: { message: "Unavailable" } })
return route.fulfill({
json: {
location: { directory: fixture.directory },
data: [
{ id: "builtin", source: { type: "builtin" }, features: {}, state: { status: "active" } },
{
id: "supermemory",
source: { type: "package", target: "opencode-supermemory" },
features: { server: true },
state: { status: "active" },
},
{
id: "broken-plugin",
source: { type: "local", path: "/broken.ts" },
features: { server: true },
state: { status: "failed", error: "Plugin failed to activate" },
},
...(state.extra
? [
{
id: "daytona",
source: { type: "package", target: "opencode-daytona" },
features: {},
state: { status: "active" },
},
]
: []),
],
},
})
})
await page.route("**/api/skill**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
json: {
location: { directory: fixture.directory },
data: [
{ id: "find-skills", name: "find-skills", location: "/skills/find/SKILL.md", content: "Find skills" },
{
id: "review-animations",
name: "review-animations",
location: "/skills/review/SKILL.md",
content: "Review animations",
},
],
},
})
})
await page.route("**/api/config**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
json: [
{
type: "document",
info: {
lsp: {
typescript: { command: ["typescript-language-server", "--stdio"] },
rust: { command: ["rust-analyzer"] },
},
},
},
{ type: "document", info: { lsp: { rust: { disabled: true } } } },
],
})
})
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
const plugins = page.getByRole("dialog", { name: "Plugins", exact: true })
await expect(plugins.getByRole("alert")).toContainText("Request failed")
await expect(plugins.getByText("No plugins configured", { exact: true })).toHaveCount(0)
state.fail = false
await plugins.getByRole("button", { name: "Retry", exact: true }).click()
await expect(plugins.getByText("supermemory", { exact: true })).toBeVisible()
await expect(plugins.getByText("builtin", { exact: true })).toHaveCount(0)
await expect(plugins.getByTitle("Plugin failed to activate")).toContainText("Failed")
expect(requests.every((directory) => directory === fixture.directory)).toBe(true)
await testInfo.attach("summary-plugins", { body: await page.screenshot(), contentType: "image/png" })
await page.keyboard.press("Escape")
state.extra = true
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
await expect(plugins.getByText("daytona", { exact: true })).toBeVisible()
await summary.getByRole("button", { name: "Skills", exact: true }).click()
const skills = page.getByRole("dialog", { name: "Skills", exact: true })
await expect(skills.getByText("find-skills", { exact: true })).toBeVisible()
await expect(skills.getByText("review-animations", { exact: true })).toBeVisible()
await expect(plugins).toBeHidden()
await summary.getByRole("button", { name: "LSP", exact: true }).click()
const lsp = page.getByRole("dialog", { name: "LSP", exact: true })
await expect(lsp.getByText("Configured LSPs", { exact: true })).toBeVisible()
await expect(lsp.getByText("typescript", { exact: true })).toBeVisible()
await expect(lsp.getByText("rust", { exact: true })).toHaveCount(0)
await expect(lsp.locator(".session-service-dot")).toHaveCount(0)
await testInfo.attach("summary-configured-lsp", { body: await page.screenshot(), contentType: "image/png" })
})
@@ -13,11 +13,20 @@ test.beforeEach(async ({ page }) => {
id: "proj_settings_demo",
canonical: directory,
name: "Settings demo",
icon: {
color: "orange",
override:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3E%3Crect width='16' height='16' fill='red'/%3E%3C/svg%3E",
},
commands: { start: "echo setup" },
vcs: "git",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes,
},
provider: { all: [], connected: [], default: {} },
preferences: { shell: "zsh", websearch: { provider: "exa" } },
shells: [{ path: "/bin/zsh", name: "zsh", acceptable: true }],
websearchProviders: [{ id: "exa", name: "Exa" }],
sessions: sandboxes.map((directory, index) => ({
id: `ses_settings_${index + 1}`,
title: `Workspace ${index + 1} session`,
@@ -56,6 +65,131 @@ test("settings has its own route and returns through app history", async ({ page
await expect(home).toHaveAttribute("aria-pressed", "true")
})
test("single-server settings expose scoped pages without a server picker", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await expect(settings.getByRole("tab", { name: "Server", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Servers", exact: true })).toHaveCount(0)
for (const name of ["Projects", "Worktrees", "Providers", "Models", "Extensions"]) {
await settings.getByRole("tab", { name, exact: true }).click()
await expect(settings.locator('[data-action="settings-server-select"]')).toHaveCount(0)
}
await settings.getByRole("tab", { name: "Server", exact: true }).click()
await expect(settings.getByRole("button", { name: "Add server", exact: true })).toBeVisible()
await expect(settings.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
await expect(settings.getByText("Terminal shell", { exact: true })).toBeVisible()
await expect(settings.getByText("Third-party search", { exact: true })).toBeVisible()
await expect(settings.getByText("zsh", { exact: true })).toBeVisible()
await expect(settings.getByText("Exa", { exact: true })).toBeVisible()
await settings.getByText("Exa", { exact: true }).click()
const updated = page.waitForRequest(
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/config/preferences",
)
await page.getByRole("option", { name: "Any", exact: true }).click()
expect((await updated).postDataJSON()).toEqual({ websearch: { provider: "random" } })
})
test("server details tolerate unavailable preference endpoints", async ({ page }) => {
await page.route(
(url) =>
url.pathname === "/api/config/preferences" ||
url.pathname === "/api/config/shell" ||
url.pathname === "/api/websearch/provider",
(route) => route.fulfill({ status: 404, json: {} }),
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Server", exact: true }).click()
const connection = settings.locator('[data-component="settings-server-connection"]')
await expect(connection.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
await expect(connection.locator('[data-component="settings-list"]')).toHaveCSS("padding-left", "16px")
await expect(connection.locator(".settings-servers-row")).toHaveCSS("padding-top", "20px")
await expect(connection.locator(".settings-servers-lead")).toHaveCSS("column-gap", "4px")
await expect(connection.locator(".settings-servers-copy")).toHaveCSS("row-gap", "6px")
await expect(page.getByText("Server request failed", { exact: true })).toHaveCount(0)
})
test("project settings open as a nested autosaving view", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: "Settings demo", exact: true }).click()
await expect(settings.getByRole("button", { name: "Back to projects", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Settings demo", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Worktrees", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Extensions", exact: true })).toBeVisible()
await expect(settings.getByRole("tab", { name: "Scripts", exact: true })).toHaveCount(0)
const name = settings.getByRole("textbox", { name: "Project name", exact: true })
const saved = page.waitForRequest(
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/project/proj_settings_demo",
)
await name.fill("Renamed project")
await name.blur()
expect((await saved).postDataJSON()).toEqual({ name: "Renamed project" })
await expect(settings.getByRole("tab", { name: "Renamed project", exact: true })).toBeVisible()
const startup = settings.getByRole("textbox", { name: "Worktree startup script", exact: true })
const scriptSaved = page.waitForRequest(
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/project/proj_settings_demo",
)
await startup.fill("bun install")
await startup.blur()
expect((await scriptSaved).postDataJSON()).toEqual({ commands: { start: "bun install" } })
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await expect(settings.getByRole("heading", { name: "Worktrees", exact: true })).toBeVisible()
await page.keyboard.press("Escape")
await expect(settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
})
test("clearing project fields sends explicit removal values", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: "Settings demo", exact: true }).click()
const startup = settings.getByRole("textbox", { name: "Worktree startup script", exact: true })
await expect(startup).toHaveValue("echo setup")
const scriptSaved = page.waitForResponse(
(response) =>
response.request().method() === "PATCH" && new URL(response.url()).pathname === "/api/project/proj_settings_demo",
)
await startup.clear()
await startup.blur()
const scriptResponse = await scriptSaved
expect(scriptResponse.ok()).toBe(true)
expect(scriptResponse.request().postDataJSON()).toEqual({ commands: { start: "" } })
await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0)
const icon = settings.getByRole("button", { name: "Project icon", exact: true })
await expect(icon.locator("img")).toHaveCount(1)
const iconSaved = page.waitForResponse(
(response) =>
response.request().method() === "PATCH" && new URL(response.url()).pathname === "/api/project/proj_settings_demo",
)
await icon.hover()
await icon.click()
const iconResponse = await iconSaved
expect(iconResponse.ok()).toBe(true)
expect(iconResponse.request().postDataJSON()).toEqual({ icon: { color: "orange", override: "" } })
await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0)
const color = settings.getByRole("button", { name: "Select orange color", exact: true })
await expect(color).toHaveAttribute("aria-pressed", "true")
const colorSaved = page.waitForResponse(
(response) =>
response.request().method() === "PATCH" && new URL(response.url()).pathname === "/api/project/proj_settings_demo",
)
await color.click()
const colorResponse = await colorSaved
expect(colorResponse.ok()).toBe(true)
expect(colorResponse.request().postDataJSON()).toEqual({ icon: { color: "", override: "" } })
await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0)
})
test("new session shortcut leaves settings and opens a new session screen", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeFocused()
@@ -38,7 +38,7 @@ for (const viewport of [
test("every settings page leaves room below its final content", async ({ page }) => {
await page.setViewportSize(viewport)
const settings = page.getByTestId("settings-screen")
const panel = settings.locator(":scope > .settings > .settings-panel:visible")
const panel = settings.locator(".settings-content > .settings-panel:visible")
if (viewport.bottom) {
const toggle = settings.locator('[data-action="settings-mobile-titlebar-bottom"]')
await toggle.locator('[data-slot="switch-control"]').click()
@@ -50,12 +50,12 @@ for (const viewport of [
"Appearance",
"Notifications",
"Shortcuts",
"Servers",
"Projects",
"Worktrees",
"Providers",
"Models",
"Extensions",
"Server",
"Experimental",
"About",
]) {
@@ -69,11 +69,8 @@ for (const colorScheme of ["light", "dark"] as const) {
await page.setViewportSize({ width: 1280, height: 720 })
await panel.getByText("rebase", { exact: true }).hover()
await panel.getByText("rebase", { exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox")).toHaveValue("rebase")
await expect(dialog.getByRole("textbox")).toBeFocused()
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toBeHidden()
await expect(settings.getByRole("textbox", { name: "Project name", exact: true })).toHaveValue("rebase")
await settings.getByRole("button", { name: "Back to projects", exact: true }).click()
await expect(panel.getByText("rebase", { exact: true })).toBeVisible()
await page.setViewportSize({ width: 1280, height: 260 })
@@ -0,0 +1,471 @@
import { expect, test, type Page } from "@playwright/test"
import en from "../../src/runtime/i18n/en"
import { clientSettings } from "../../src/settings/search-catalog"
import { createMockServerHandler, mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
const directory = "/projects/opencode"
const config = {
directory,
project: {
id: "proj_search",
canonical: directory,
name: "OpenCode",
icon: { color: "orange" },
vcs: "git",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
}
function projectList(count: number) {
return Array.from({ length: count }, (_, index) => ({
...config.project,
id: `proj_search_${index}`,
canonical: `${directory}-${index}`,
name: `OpenCode ${String(index).padStart(2, "0")}`,
}))
}
function ui(page: Page) {
const settings = page.getByTestId("settings-screen")
return {
settings,
search: settings.getByRole("combobox", { name: "Search", exact: true }),
results: settings.getByRole("listbox", { name: "Settings results", exact: true }),
viewport: settings.locator(".settings-search-scroll > .scroll-view__viewport"),
}
}
async function findShortcut(page: Page) {
// Browser emulation can report a different OS than the machine running Playwright.
const key = await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "Meta+f" : "Control+f"))
await page.keyboard.press(key)
}
test.use({ viewport: { width: 1280, height: 900 } })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, config)
await page.route("https://api.github.com/**", (route) => route.fulfill({ json: [] }))
await page.goto("/")
if ((page.viewportSize()?.width ?? 1280) < 800) await page.getByRole("button", { name: "Tabs", exact: true }).click()
await page.getByRole("button", { name: "Settings", exact: true }).click()
const view = ui(page)
await expect(view.settings).toBeFocused()
// Readiness includes the server-backed project inventory, not just the settings shell.
await view.search.fill("OpenCode")
await expect(view.results.getByRole("option")).toHaveCount(1)
await view.search.clear()
})
test("pointer selection, keyboard navigation, and local find shortcut", async ({ page }) => {
const view = ui(page)
await view.search.fill("font")
const code = view.results.getByRole("option", { name: "Code Font, Appearance", exact: true })
const terminal = view.results.getByRole("option", { name: "Terminal Font, Appearance", exact: true })
const font = view.results.getByRole("option", { name: "UI Font, Appearance", exact: true })
await expect(view.results.getByRole("option")).toHaveText([
"Code FontAppearance",
"Terminal FontAppearance",
"UI FontAppearance",
])
await code.click()
await expect(code).toBeFocused()
await expect(view.settings.getByRole("textbox", { name: "Code Font", exact: true })).toBeInViewport()
await code.press("ArrowDown")
await expect(terminal).toBeFocused()
await expect(terminal).toHaveAttribute("aria-selected", "true")
await terminal.press("Enter")
await expect(terminal).toBeFocused()
await expect(view.settings.getByRole("textbox", { name: "Terminal Font", exact: true })).toBeInViewport()
await terminal.press("End")
await expect(font).toBeFocused()
await font.press("Home")
await expect(code).toBeFocused()
await terminal.focus()
await expect(terminal).toHaveAttribute("aria-selected", "true")
await terminal.press("Enter")
await expect(view.settings.locator('[data-search-target="row"]')).toContainText("Terminal Font")
await findShortcut(page)
await expect(view.search).toBeFocused()
expect(await view.search.evaluate((input: HTMLInputElement) => [input.selectionStart, input.selectionEnd])).toEqual([
0, 4,
])
await view.settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(view.settings).toBeHidden()
await findShortcut(page)
await expect(page.getByRole("textbox", { name: /Search sessions/ })).toBeFocused()
})
test("page priority, compact icons, section context, and the minimal empty state", async ({ page }) => {
const view = ui(page)
await view.search.fill("work")
await expect(view.results.getByRole("option")).toHaveText(["Worktrees", "Default environmentPreferences / General"])
const pageResult = view.results.getByRole("option", { name: /^Worktrees,/ })
await expect(pageResult).toHaveCSS("height", "28px")
await expect(pageResult.locator("svg")).toHaveCount(1)
await expect(view.settings.getByText("App settings", { exact: true })).toHaveCount(0)
await view.search.fill("Agent")
await expect(view.results.getByRole("option", { name: "Agent, Desktop notifications", exact: true })).toHaveText(
"AgentDesktop notifications",
)
await expect(view.results.getByRole("option", { name: "Agent, Sound effects", exact: true })).toHaveText(
"AgentSound effects",
)
await view.search.fill("zzzzzzzzzz")
await expect(view.results.getByRole("option")).toHaveCount(0)
await expect(view.settings.getByRole("status")).toHaveText('No results for "zzzzzzzzzz"')
await expect(view.settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
await view.search.press("Escape")
await expect(view.search).toHaveValue("")
await expect(view.settings.getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
})
test("empty queries keep the closing quote beside the ellipsis while typing and resizing", async ({ page }) => {
const view = ui(page)
const query = "zzzz 🧑🏽‍💻 ".repeat(40)
await view.search.fill(query)
const status = view.settings.getByRole("status")
const quoted = status.locator("bdi")
await expect(status).toHaveAccessibleName(`No results for "${query}"`)
await expect(quoted).toHaveText(/^".+…"$/)
await expect(status).toHaveCSS("height", "28px")
await expect
.poll(() =>
quoted.evaluate((element) => element.getBoundingClientRect().width <= element.parentElement!.clientWidth),
)
.toBe(true)
const text = await quoted.textContent()
await page.setViewportSize({ width: 390, height: 844 })
await expect(quoted).toHaveText(/^".+…"$/)
await expect(quoted).not.toHaveText(text!)
await expect
.poll(() =>
quoted.evaluate((element) => element.getBoundingClientRect().width <= element.parentElement!.clientWidth),
)
.toBe(true)
expect(await view.settings.evaluate((root) => root.scrollWidth <= root.clientWidth)).toBe(true)
await view.search.fill("zzzzzzzzzz")
await expect(status).toHaveText('No results for "zzzzzzzzzz"')
await view.search.pressSequentially("x")
await expect(status).toHaveText('No results for "zzzzzzzzzzx"')
})
test("Models and Shortcuts autofocus their filters on normal navigation", async ({ page }) => {
const view = ui(page)
for (const entry of [
{ tab: "Models", search: "Search models" },
{ tab: "Shortcuts", search: "Search shortcuts" },
{ tab: "Models", search: "Search models" },
]) {
await view.settings.getByRole("tab", { name: entry.tab, exact: true }).click()
await expect(view.settings.getByRole("searchbox", { name: entry.search, exact: true })).toBeFocused()
}
await view.search.fill("shortcuts")
const result = view.results.getByRole("option", { name: "Keyboard shortcuts", exact: true })
await result.click()
await expect(result).toBeFocused()
})
for (const count of [7, 8]) {
test(`Projects search uses the full list threshold with ${count} projects`, async ({ page }) => {
await page.route("**/api/project", (route) => route.fulfill({ json: projectList(count) }))
await page.reload()
const view = ui(page)
await view.search.fill("OpenCode")
await expect(view.results.getByRole("option")).toHaveCount(count)
await view.search.clear()
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
const projects = view.settings.getByRole("button", { name: /^OpenCode / })
await expect(projects).toHaveCount(count)
if (count === 7) {
await expect(search).toHaveCount(0)
return
}
await expect(search).toBeFocused()
await search.fill(" CODE 06 ")
await expect(projects).toHaveCount(1)
await expect(projects).toHaveAccessibleName("OpenCode 06")
await expect(search).toBeVisible()
await search.fill("missing-project")
await expect(projects).toHaveCount(0)
await expect(view.settings.getByText("No projects found", { exact: true })).toBeVisible()
await view.settings.getByRole("button", { name: "Clear", exact: true }).click()
await expect(search).toBeFocused()
await expect(projects).toHaveCount(count)
await view.settings.getByRole("tab", { name: "Models", exact: true }).click()
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
await expect(search).toBeFocused()
await search.fill("OpenCode 06")
await projects.click()
await expect(view.settings.getByRole("heading", { name: "OpenCode 06", exact: true })).toBeVisible()
})
}
test("Projects search focuses when the qualifying inventory arrives after opening", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
await page.route("**/api/project", async (route) => {
await inventory.promise
await route.fulfill({ json: projectList(8) })
})
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/project")
await page.reload()
await requested
const view = ui(page)
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
try {
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
await expect(view.settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
await expect(search).toHaveCount(0)
} finally {
inventory.resolve()
}
await expect(search).toBeFocused()
await expect(view.settings.getByRole("button", { name: /^OpenCode / })).toHaveCount(8)
})
test("all indexed client controls resolve to visible production controls", async ({ page }) => {
const view = ui(page)
for (const entry of clientSettings.filter((entry) => entry.target && !entry.available)) {
await view.search.fill(en[entry.label as keyof typeof en])
const result = view.results.locator(`[data-setting-target="${entry.target}"]`)
await expect(result).toHaveCount(1)
await result.click()
await expect(view.settings.locator(`[data-action="${entry.target}"]`)).toBeInViewport()
}
})
test("qualified project results preserve query and selection on return", async ({ page }) => {
const view = ui(page)
await view.search.fill("project name")
await expect(view.results.locator('[data-setting-target="settings-project-name"]')).toHaveCount(0)
await view.search.fill("OpenCode")
const project = view.results.getByRole("option")
await expect(project).toHaveText("OOpenCode")
await expect(project.locator('[data-component="project-avatar-v2"]')).toHaveCSS("width", "16px")
await expect(view.settings.locator(".settings-search-group")).toHaveCount(0)
await view.search.fill("OpenCode name")
const name = view.results.getByRole("option")
await expect(name).toHaveText("Project nameGeneral")
await name.click()
await expect(view.search).toHaveCount(0)
await expect(view.settings.getByRole("button", { name: "Back to settings", exact: true })).toBeVisible()
await expect(view.settings.getByRole("heading", { name: "OpenCode", exact: true })).toHaveCSS("line-height", "20px")
await expect(view.settings.getByRole("textbox", { name: "Project name", exact: true })).toHaveValue("OpenCode")
await page.keyboard.press("Escape")
await expect(view.search).toBeFocused()
await expect(view.search).toHaveValue("OpenCode name")
await expect(name).toHaveAttribute("aria-selected", "true")
})
test("returning from a project restores a scrolled result list", async ({ page }) => {
await page.route("**/api/project", (route) =>
route.fulfill({
json: projectList(30),
}),
)
await page.reload()
const view = ui(page)
await view.search.fill("OpenCode")
await expect(view.results.getByRole("option")).toHaveCount(30)
const scrollbar = view.settings.locator('.settings-search-scroll > .scroll-view__thumb[data-orientation="vertical"]')
await view.viewport.hover()
await expect(scrollbar).toHaveAttribute("data-visible", "true")
const bounds = (await scrollbar.boundingBox())!
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)
await page.mouse.down()
await expect(scrollbar).toHaveAttribute("data-dragging", "true")
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + 80, { steps: 4 })
await page.mouse.up()
await expect.poll(() => view.viewport.evaluate((list) => list.scrollTop)).toBeGreaterThan(0)
const project = view.results.getByRole("option", { name: /^OpenCode 25,/ })
await project.scrollIntoViewIfNeeded()
await expect.poll(() => view.viewport.evaluate((list) => list.scrollTop)).toBeGreaterThan(0)
const scroll = await view.viewport.evaluate((list) => list.scrollTop)
await project.click()
await expect(view.settings.getByRole("heading", { name: "OpenCode 25", exact: true })).toBeVisible()
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
await expect(view.search).toBeFocused()
await expect(project).toHaveAttribute("aria-selected", "true")
await expect.poll(() => view.viewport.evaluate((list) => list.scrollTop)).toBe(scroll)
await view.search.fill("about")
await expect(view.results.getByRole("option", { name: "About", exact: true })).toBeVisible()
await expect(scrollbar).toHaveCount(0)
})
test("IME confirmation does not activate a search result", async ({ page }) => {
const view = ui(page)
await view.search.fill("font")
await expect(view.results.getByRole("option")).toHaveCount(3)
await view.search.dispatchEvent("keydown", { key: "Enter", isComposing: true, bubbles: true, cancelable: true })
await expect(view.settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
await view.search.press("Enter")
await expect(view.settings.getByRole("heading", { name: "Appearance", exact: true })).toBeVisible()
})
test("header highlight runs once per search activation and finishes cleanly", async ({ page }) => {
const view = ui(page)
await view.settings.evaluate((root) => {
root.setAttribute("data-search-flashes", "0")
root.addEventListener("animationstart", (event) => {
if (!(event instanceof AnimationEvent) || event.animationName !== "settings-search-reveal") return
root.setAttribute("data-search-flashes", String(Number(root.getAttribute("data-search-flashes")) + 1))
})
})
await view.search.fill("skills")
await view.results.getByRole("option").click()
await expect(view.settings.getByRole("tab", { name: "Skills", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(view.settings).toHaveAttribute("data-search-flashes", "1")
await view.settings.evaluate(async (root) => {
await Promise.all(
root
.getAnimations({ subtree: true })
.filter(
(animation) => animation instanceof CSSAnimation && animation.animationName === "settings-search-reveal",
)
.map((animation) => animation.finished),
)
})
await expect(view.settings.locator("[data-search-target]")).toHaveCount(0)
for (const tab of ["MCPs", "Plugins", "Skills"]) {
await view.settings.getByRole("tab", { name: tab, exact: true }).click()
await expect(view.settings.getByRole("tab", { name: tab, exact: true })).toHaveAttribute("aria-selected", "true")
await expect(view.settings.locator("[data-search-target]")).toHaveCount(0)
}
await expect(view.settings).toHaveAttribute("data-search-flashes", "1")
await view.results.getByRole("option").click()
await expect(view.settings).toHaveAttribute("data-search-flashes", "2")
await view.search.fill("about")
await view.results.getByRole("option", { name: "About", exact: true }).click()
await expect(view.settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
await expect(view.settings).toHaveAttribute("data-search-flashes", "3")
})
test("multi-server results navigate to the named server and hide search in nested views", async ({ page }) => {
const server = "http://127.0.0.1:4097"
const remote = createMockServerHandler({
...config,
directory: "/remote/opencode",
project: { ...config.project, canonical: "/remote/opencode" },
})
page.on("close", () => void remote.dispose())
await installSseTransport(page, { server })
await page.route(`${server}/api/**`, async (route) => {
if (route.request().method() === "OPTIONS")
return route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "*" },
})
const response = await remote.handler(
new Request(route.request().url(), { method: route.request().method(), headers: route.request().headers() }),
)
await route.fulfill({
status: response.status,
headers: { ...Object.fromEntries(response.headers), "access-control-allow-origin": "*" },
body: Buffer.from(await response.arrayBuffer()),
})
})
await page.addInitScript(
(server) =>
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ list: [{ type: "http", displayName: "Build server", http: { url: server } }] }),
),
server,
)
await page.reload()
const view = ui(page)
await view.search.fill("MCPs")
await expect(view.results.getByRole("option")).toHaveCount(2)
await view.results.getByRole("option", { name: "MCPs, Build server, Extensions", exact: true }).click()
await expect(view.search).toHaveCount(0)
await expect(view.settings.getByRole("tab", { name: "Build server", exact: true })).toBeVisible()
await expect(view.settings.getByRole("tab", { name: "MCPs", exact: true })).toHaveAttribute("aria-selected", "true")
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
await expect(view.search).toHaveValue("MCPs")
await view.search.fill("Build server models")
await view.results.getByRole("option").click()
await expect(view.settings.getByRole("searchbox", { name: "Search models", exact: true })).toBeFocused()
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
await view.search.fill("Build server OpenCode name")
await expect(view.results.getByRole("option")).toHaveCount(1)
await view.results.getByRole("option").click()
await expect(view.settings.getByRole("button", { name: "Build server", exact: true })).toBeVisible()
await expect(view.settings.getByRole("textbox", { name: "Project name", exact: true })).toHaveValue("OpenCode")
})
for (const direction of ["ltr", "rtl"] as const) {
test.describe(`search layout ${direction}`, () => {
test.use({
viewport: { width: 390, height: 844 },
colorScheme: direction === "rtl" ? "dark" : "light",
contextOptions: { reducedMotion: "reduce" },
})
test("search input fades track typing, caret scrolling, resizing, and clearing", async ({ page }) => {
const view = ui(page)
await page.setViewportSize({ width: 1280, height: 900 })
await page.evaluate((direction) => {
document.documentElement.dir = direction
}, direction)
await view.search.fill(direction === "rtl" ? "غيرموجود ".repeat(30) : "zzzz ".repeat(30))
await view.search.press("End")
await expect(view.search).toHaveAttribute("data-overflow-start", "true")
await expect(view.search).toHaveAttribute("data-overflow-end", "false")
await expect(view.search).not.toHaveCSS("mask-image", "none")
await view.search.press("Home")
await expect(view.search).toHaveAttribute("data-overflow-start", "false")
await expect(view.search).toHaveAttribute("data-overflow-end", "true")
await view.search.evaluate((input: HTMLInputElement, direction) => {
input.scrollLeft = ((input.scrollWidth - input.clientWidth) / 2) * (direction === "rtl" ? -1 : 1)
}, direction)
await expect(view.search).toHaveAttribute("data-overflow-start", "true")
await expect(view.search).toHaveAttribute("data-overflow-end", "true")
await view.search.fill("z".repeat(50))
await expect(view.search).not.toHaveCSS("mask-image", "none")
await page.setViewportSize({ width: 600, height: 844 })
await expect(view.search).toHaveAttribute("data-overflow-start", "false")
await expect(view.search).toHaveAttribute("data-overflow-end", "false")
await expect(view.search).toHaveCSS("mask-image", "none")
await view.search.fill("zzzz ".repeat(30))
await expect(view.search).not.toHaveCSS("mask-image", "none")
await view.settings.getByRole("button", { name: "Clear", exact: true }).click()
await expect(view.search).toHaveValue("")
await expect(view.search).toBeFocused()
await expect(view.search).toHaveAttribute("data-overflow-start", "false")
await expect(view.search).toHaveAttribute("data-overflow-end", "false")
await expect(view.search).toHaveCSS("mask-image", "none")
})
test("keeps input/content stable and hides the active descendant when results collapse", async ({ page }) => {
const view = ui(page)
await page.evaluate((direction) => {
document.documentElement.dir = direction
}, direction)
const input = await view.search.boundingBox()
const content = await view.settings.locator(".settings-content").boundingBox()
await view.search.fill("e")
await expect.poll(() => view.search.boundingBox()).toEqual(input)
await expect.poll(() => view.settings.locator(".settings-content").boundingBox()).toEqual(content)
await view.search.fill("terminal font")
await view.results.getByRole("option").click()
await expect(view.results).toBeHidden()
await expect(view.search).toHaveAttribute("aria-expanded", "false")
await expect(view.search).not.toHaveAttribute("aria-activedescendant", /.+/)
await expect(view.settings.getByRole("textbox", { name: "Terminal Font", exact: true })).toBeInViewport()
await findShortcut(page)
await expect(view.search).toBeFocused()
await expect(view.results).toBeVisible()
await expect(view.search).toHaveAttribute("aria-activedescendant", /.+/)
expect(await view.settings.evaluate((root) => root.scrollWidth <= root.clientWidth)).toBe(true)
})
})
}
@@ -0,0 +1,48 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
test.use({ viewport: { width: 1280, height: 900 } })
for (const mode of ["failed", "stopped", "ready"] as const) {
test(`manages a ${mode} configured WSL server from nested settings`, async ({ page }) => {
await mockOpenCodeServer(page, {
directory: "/repo",
project: {
id: "proj_wsl_settings",
canonical: "/repo",
name: "WSL project",
sandboxes: [],
time: { created: 1, updated: 1 },
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.goto(`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server, mode })}`)
const settings = page.getByTestId("settings-screen")
await expect(settings.getByRole("tab", { name: "Local Server", exact: true })).toBeEnabled()
const ubuntu = settings.getByRole("tab", { name: "Ubuntu", exact: true })
await expect(ubuntu).toHaveCount(1)
await ubuntu.click()
await expect(settings.getByRole("heading", { name: "Ubuntu", exact: true })).toBeVisible()
const connection = settings.locator('[data-component="settings-server-connection"]')
if (mode !== "ready") {
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toBeDisabled()
await connection.getByRole("button", { name: "More options", exact: true }).click()
await page.getByRole("menuitem", { name: "Retry start", exact: true }).click()
await expect(page.getByLabel("WSL actions")).toHaveText("start:wsl:Ubuntu")
}
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toBeEnabled()
await connection.getByRole("button", { name: "Update OpenCode", exact: true }).click()
await expect(page.getByLabel("WSL actions")).toContainText("update:Ubuntu")
await expect(connection.getByRole("button", { name: "Update OpenCode", exact: true })).toHaveCount(0)
await connection.getByRole("button", { name: "More options", exact: true }).click()
await page.getByRole("menuitem", { name: "Remove", exact: true }).click()
await expect(page.getByLabel("WSL actions")).toContainText("remove:wsl:Ubuntu")
await expect(settings.getByRole("tab", { name: "Ubuntu", exact: true })).toHaveCount(0)
await expect(settings.getByRole("tab", { name: "Server", exact: true })).toBeEnabled()
})
}
@@ -188,16 +188,8 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
sidebar.getByRole("button", { name: "Home", exact: true }).getByText("Home", { exact: true }),
).toBeVisible()
await expect(sidebar.getByRole("button", { name: "New session" })).toBeVisible()
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toBeVisible()
const status = sidebar.getByRole("button", { name: "Status", exact: true })
await expect(status).toBeVisible()
await expect
.poll(async () => {
const bounds = await sidebar.boundingBox()
const button = await status.boundingBox()
return !!bounds && !!button && button.x >= bounds.x && button.x - bounds.x <= 12
})
.toBe(true)
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCount(0)
await expect(sidebar.getByRole("button", { name: "Status", exact: true })).toHaveCount(0)
await expect(page.locator('[data-slot="titlebar-v2"]')).toBeHidden()
await expect
.poll(async () => {
@@ -301,7 +293,7 @@ for (const count of [0, 26]) {
}
for (const direction of ["ltr", "rtl"]) {
test(`vertical tabs keep Status pinned without Settings in ${direction}`, async ({ page }, testInfo) => {
test(`vertical tabs scroll without the retired Status footer in ${direction}`, async ({ page }, testInfo) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB, directory }) => {
@@ -334,38 +326,23 @@ for (const direction of ["ltr", "rtl"]) {
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${hrefB}"]`)
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(26)
await expect(status).toHaveText("Status")
await expect(status).toHaveCount(0)
await expect(settings).toHaveCount(0)
await expect(status.locator('[data-slot="status-indicator"]')).toBeVisible()
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
for (const width of [1280, 800]) {
await page.setViewportSize({ width, height: 360 })
await expect(sidebar).toHaveCSS("padding-inline-start", "10px")
await expect(sidebar).toHaveCSS("padding-bottom", "10px")
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCSS("margin-top", "8px")
await expect(status).toBeInViewport({ ratio: 1 })
await expect(status).toHaveCSS("height", "28px")
await expect
.poll(() =>
sidebar.locator('[data-slot="vertical-tabs-footer"]').evaluate((element) => {
const content = Math.max(
0,
...Array.from(element.children, (child) => child.getBoundingClientRect().height),
)
return element.getBoundingClientRect().height - content
}),
)
.toBe(0)
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCount(0)
await expect(scroll).toHaveCSS("mask-image", /linear-gradient/)
await scroll.evaluate((element) => element.scrollTo(0, 0))
await expect(scroll).toHaveJSProperty("scrollTop", 0)
const pinnedStatus = await status.boundingBox()
await scroll.hover()
await page.mouse.wheel(0, 200)
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
await testInfo.attach(`vertical-tabs-status-${width}`, {
await expect(status).toHaveCount(0)
await testInfo.attach(`vertical-tabs-scroll-${width}`, {
body: await sidebar.screenshot(),
contentType: "image/png",
})
@@ -379,14 +356,9 @@ for (const direction of ["ltr", "rtl"]) {
return !!tab && !!viewport && tab.y + tab.height <= viewport.y + viewport.height - 16
})
.toBe(true)
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
await expect(status).toHaveCount(0)
await expect(settings).toHaveCount(0)
}
await status.click()
await expect(status).toHaveAttribute("aria-expanded", "true")
await status.press("Escape")
await expect(status).toHaveAttribute("aria-expanded", "false")
})
}
@@ -501,7 +473,7 @@ test("dedicated experimental settings control vertical tab details", async ({ pa
await settings.locator('[data-action="settings-show-project-name"] [data-slot="switch-control"]').click()
await expect(projectNameSwitch).toBeChecked()
await expect(projectNames).toHaveText(["tab-project"])
await expect(settings.getByRole("tablist")).toHaveCSS("width", "240px")
await expect(settings.getByRole("complementary")).toHaveCSS("width", "240px")
await page.setViewportSize({ width: 920, height: 720 })
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCSS("width", "260px")
@@ -0,0 +1,219 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "/repo/workspaces-prefetch"
const sandboxes = [`${directory}/first`, `${directory}/second`]
const project = {
id: "proj_workspaces_prefetch",
canonical: directory,
name: "Prefetch project",
sandboxes,
time: { created: 1, updated: 1 },
}
const other = { ...project, id: "proj_other", name: "Other project", canonical: "/repo/other", sandboxes: [] }
test.use({ viewport: { width: 1280, height: 900 } })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project,
provider: { all: [], connected: [], default: {} },
sessions: [
{
id: "ses_workspaces_cached",
title: "Cached worktree session",
projectID: project.id,
directory: sandboxes[0],
time: { created: 1, updated: 1 },
},
],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
}, directory)
await page.goto("/")
await expect(page.getByText("Cached worktree session", { exact: true })).toBeVisible()
await page.getByRole("button", { name: "Settings", exact: true }).click()
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible()
})
for (const interaction of ["hover", "focus"] as const) {
test(`project Worktrees ${interaction} prefetches only its inventory and reuses the request`, async ({ page }) => {
const inventory = Promise.withResolvers<void>()
const calls: string[] = []
const sessions: string[] = []
await page.route(
(url) => url.pathname === "/api/project",
(route) => route.fulfill({ json: [project, other] }),
)
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
calls.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
await inventory.promise
await route.fallback()
},
)
page.on("request", (request) => {
const url = new URL(request.url())
if (url.pathname === "/api/session" && url.searchParams.has("directory"))
sessions.push(url.searchParams.get("directory")!)
})
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: project.name, exact: true }).click()
const worktrees = settings.getByRole("tab", { name: "Worktrees", exact: true })
await expect(worktrees).toBeEnabled()
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/project")
await worktrees[interaction]()
await requested
await expect(worktrees).toHaveAttribute("aria-selected", "false")
await expect.poll(() => calls).toEqual([directory])
expect(sessions).toEqual([])
if (interaction === "hover") {
const finished = page.waitForEvent(
"requestfinished",
(request) => new URL(request.url()).pathname === "/api/worktree",
)
inventory.resolve()
await finished
}
await worktrees.click()
await expect(worktrees).toHaveAttribute("aria-selected", "true")
inventory.resolve()
await expect(settings.getByText("2 worktrees", { exact: true })).toBeVisible()
await expect(settings.getByText("Cached worktree session", { exact: true })).toBeVisible()
expect(calls).toEqual([directory])
await expect.poll(() => sessions.toSorted()).toEqual(sandboxes.toSorted())
})
}
for (const nested of [false, true]) {
test(`${nested ? "nested" : "root"} server Worktrees hover only prefetches metadata`, async ({ page }) => {
if (nested) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript((server) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
list: [
{ type: "http", displayName: "Settings server", http: { url: server } },
{ type: "http", displayName: "Other server", http: { url: "http://127.0.0.1:4097" } },
],
}),
)
}, server)
await page.reload()
await page.getByTestId("settings-screen").getByRole("tab", { name: "Settings server", exact: true }).click()
}
const calls = { projects: 0, worktrees: [] as string[] }
await page.route(
(url) => url.pathname === "/api/project",
async (route) => {
calls.projects += 1
await route.fulfill({ json: [project, other] })
},
)
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
const requested = new URL(route.request().url()).searchParams.get("location[directory]") ?? ""
calls.worktrees.push(requested)
if (requested === other.canonical) return route.fulfill({ json: [{ directory: other.canonical }] })
await route.fallback()
},
)
const settings = page.getByTestId("settings-screen")
const worktrees = settings.getByRole("tab", { name: "Worktrees", exact: true })
await expect(worktrees).toBeEnabled()
const fetched = page.waitForEvent(
"requestfinished",
(request) => new URL(request.url()).pathname === "/api/project",
)
await worktrees.hover()
await fetched
await worktrees.focus()
await expect(worktrees).toHaveAttribute("aria-selected", "false")
expect(calls).toEqual({ projects: 1, worktrees: [] })
await worktrees.click()
await expect(settings.getByText("2 worktrees", { exact: true })).toBeVisible()
expect(calls.projects).toBe(1)
expect(calls.worktrees.toSorted()).toEqual([directory, other.canonical].toSorted())
})
}
test("cached sessions render while directory sessions load without treating unknown rows as empty", async ({
page,
}) => {
const ready = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === "/api/session" && url.searchParams.has("directory"),
async (route) => {
await ready.promise
await route.fallback()
},
)
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest((request) => {
const url = new URL(request.url())
return url.pathname === "/api/session" && url.searchParams.has("directory")
})
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await requested
await expect(settings.getByText("Cached worktree session", { exact: true })).toBeVisible()
const empty = settings
.locator(".settings-workspaces-row")
.filter({ has: page.getByLabel(sandboxes[1], { exact: true }) })
await expect(empty).toContainText("Loading messages")
await settings.getByRole("button", { name: "More options", exact: true }).click()
await expect(page.getByRole("menuitem", { name: "Delete worktrees without sessions", exact: true })).toHaveCount(0)
await page.keyboard.press("Escape")
ready.resolve()
await expect(empty).toContainText("0 sessions")
await expect(settings.getByText("Cached worktree session", { exact: true })).toBeVisible()
})
test("project deletion updates the cached server-wide inventory", async ({ page }) => {
const removed = new Set<string>()
await page.route(
(url) => url.pathname === "/api/worktree",
(route) => {
if (route.request().method() === "DELETE") {
removed.add(route.request().postDataJSON().directory)
return route.fulfill({ status: 204 })
}
return route.fulfill({
json: [
{ directory },
...sandboxes
.filter((directory) => !removed.has(directory))
.map((directory) => ({ directory, strategy: "git" })),
],
})
},
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await expect(settings.getByText("2 worktrees", { exact: true })).toBeVisible()
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: project.name, exact: true }).click()
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await settings.getByRole("button", { name: "Delete “second”?", exact: true }).click()
await page
.getByRole("dialog", { name: "Delete “second”?", exact: true })
.getByRole("button", { name: "Delete worktree", exact: true })
.click()
await expect(settings.getByText("1 worktree", { exact: true })).toBeVisible()
await settings.getByRole("button", { name: "Back to projects", exact: true }).click()
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await expect(settings.getByText("1 worktree", { exact: true })).toBeVisible()
await expect(settings.getByLabel(sandboxes[1], { exact: true })).toHaveCount(0)
})
@@ -0,0 +1,39 @@
import { MemoryRouter, createMemoryHistory } from "@solidjs/router"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "../../src/app"
import { useLanguage, type Direction } from "../../src/runtime/i18n/language"
import { PlatformProvider } from "../../src/runtime/platform/platform"
import { createWebPlatform } from "../../src/runtime/platform/web"
import { ServerConnection } from "../../src/runtime/server/registry"
export function mount(input: { server: string; route: string; direction: Direction }) {
const root = document.getElementById("root")
if (!root) throw new Error("Missing fixture root")
const history = createMemoryHistory()
history.set({ value: input.route, replace: true, scroll: false })
const server: ServerConnection.Http = { type: "http", http: { url: input.server } }
function DirectedApp() {
const language = useLanguage()
language.setDirection(input.direction)
return (
<AppInterface
servers={[server]}
defaultServer={ServerConnection.key(server)}
canonicalLocalServer={ServerConnection.key(server)}
router={(props) => <MemoryRouter {...props} history={history} />}
/>
)
}
render(
() => (
<PlatformProvider value={createWebPlatform("test").platform}>
<AppBaseProviders locale="en">
<DirectedApp />
</AppBaseProviders>
</PlatformProvider>
),
root,
)
}
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content, viewport-fit=cover"
/>
</head>
<body
class="antialiased overscroll-none font-(family-name:--font-family-text) text-[13px] font-[440] overflow-hidden bg-v2-background-bg-deep"
>
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
<script type="module">
import { mount } from "./app-direction.fixture.tsx"
const query = new URLSearchParams(location.search)
mount({ server: query.get("server"), route: query.get("route"), direction: query.get("direction") })
</script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
import type { Page } from "@playwright/test"
export async function openWithDirection(page: Page, route: string, direction: "ltr" | "rtl") {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.goto(`/e2e/utils/app-direction.html?${new URLSearchParams({ server, route, direction })}`)
}
+16
View File
@@ -70,7 +70,23 @@ const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("mcp", "/api/mcp", { success: Json }))
.add(HttpApiEndpoint.get("mcpResource", "/api/mcp/resource", { success: Json }))
.add(HttpApiEndpoint.get("projectList", "/api/project", { success: Json }))
.add(
HttpApiEndpoint.patch("projectUpdate", "/api/project/:projectID", {
params: { projectID: Schema.String },
payload: JsonPayload,
success: Json,
}),
)
.add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json }))
.add(HttpApiEndpoint.get("configPreferences", "/api/config/preferences", { success: Json }))
.add(
HttpApiEndpoint.patch("configUpdatePreferences", "/api/config/preferences", {
payload: JsonPayload,
success: Json,
}),
)
.add(HttpApiEndpoint.get("configShells", "/api/config/shell", { success: Json }))
.add(HttpApiEndpoint.get("websearchProviders", "/api/websearch/provider", { success: Json }))
.add(
HttpApiEndpoint.get("worktreeList", "/api/worktree", {
success: Json,
+26 -2
View File
@@ -6,9 +6,13 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { MockApi, MockBadRequest, MockNotFound } from "./mock-api"
export interface MockServerConfig {
server?: string
provider: unknown | (() => unknown)
integrationMethods?: Record<string, unknown[]>
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
preferences?: Record<string, unknown>
shells?: unknown[]
websearchProviders?: unknown[]
directory: string
project: unknown
sessions: ({ id: string } & Record<string, unknown>)[]
@@ -47,7 +51,9 @@ type MockStreamWindow = Window & {
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const server =
config.server ??
`http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript(
({ server, retry }) => {
@@ -185,13 +191,14 @@ export function createMockServerHandler(config: MockServerConfig) {
const corsHeaders = {
"access-control-allow-origin": "*",
"access-control-allow-headers": "*",
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
"access-control-expose-headers": "x-next-cursor",
}
function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, string>; nextCursor: number }) {
const noContent = Effect.succeed(HttpApiSchema.NoContent.make())
const delay = config.messageDelay === undefined ? Effect.void : Effect.sleep(Duration.millis(config.messageDelay))
const preferences = { current: config.preferences ?? {} }
return HttpApiBuilder.group(MockApi, "mock", (handlers) =>
handlers
.handleRaw("event", () => {
@@ -269,12 +276,29 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
return Effect.succeed([{ ...project, canonical: project.canonical ?? project.worktree ?? config.directory }])
},
projectUpdate: (ctx) => {
const project = config.project as { canonical?: string }
return Effect.succeed({
...project,
...ctx.payload,
id: ctx.params.projectID,
canonical: project.canonical ?? config.directory,
})
},
projectCurrent: () =>
Effect.succeed({
id: (config.project as { id?: string }).id,
directory: config.directory,
canonical: config.directory,
}),
configPreferences: () => Effect.succeed(preferences.current),
configUpdatePreferences: (ctx) =>
Effect.sync(() => {
preferences.current = { ...preferences.current, ...ctx.payload }
return preferences.current
}),
configShells: () => Effect.succeed(config.shells ?? []),
websearchProviders: () => Effect.succeed({ location: location(config), data: config.websearchProviders ?? [] }),
worktreeList: () =>
Effect.succeed([
{ directory: config.directory },
@@ -0,0 +1,135 @@
import { MemoryRouter, createMemoryHistory } from "@solidjs/router"
import { createMemo, Show } from "solid-js"
import { createStore, unwrap } from "solid-js/store"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "../../src/app"
import { PlatformProvider, type Platform } from "../../src/runtime/platform/platform"
import { ServerConnection } from "../../src/runtime/server/registry"
import { useWslServers } from "../../src/servers/wsl/context"
import type { WslServersEvent, WslServersPlatform, WslServersState } from "../../src/servers/wsl/types"
export function mount(input: { server: string; mode: "failed" | "stopped" | "ready" }) {
const root = document.getElementById("root")
if (!root) throw new Error("Missing fixture root")
const history = createMemoryHistory()
history.set({ value: "/settings", replace: true, scroll: false })
render(() => {
const [store, setStore] = createStore<{ calls: string[]; state: WslServersState }>({
calls: [],
state: {
runtime: { available: true, version: "2", error: null },
installed: [],
online: [],
distroProbes: {},
pendingRestart: false,
job: null,
servers: [
{
config: { id: "wsl:Ubuntu", distro: "Ubuntu" },
runtime:
input.mode === "ready"
? { kind: "ready", url: input.server, password: null }
: input.mode === "failed"
? { kind: "failed", message: "WSL failed to start" }
: { kind: "stopped" },
},
],
opencodeChecks: {
Ubuntu: {
distro: "Ubuntu",
resolvedPath: "/usr/bin/opencode",
version: "old",
expectedVersion: "current",
matchesDesktop: false,
error: null,
},
},
},
})
const listeners = new Set<(event: WslServersEvent) => void>()
const publish = () =>
listeners.forEach((listener) => listener({ type: "state", state: structuredClone(unwrap(store.state)) }))
const unused = async () => {
throw new Error("Unexpected fixture action")
}
const wsl: WslServersPlatform = {
getState: async () => structuredClone(unwrap(store.state)),
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
probeRuntime: unused,
refreshDistros: unused,
installWsl: unused,
installDistro: unused,
probeAddable: unused,
openTerminal: unused,
addServer: unused,
async installOpencode(distro) {
setStore("calls", (calls) => [...calls, `update:${distro}`])
setStore("state", "opencodeChecks", distro, { version: "current", matchesDesktop: true })
publish()
},
async startServer(id) {
setStore("calls", (calls) => [...calls, `start:${id}`])
setStore("state", "servers", (server) => server.config.id === id, "runtime", {
kind: "ready",
url: input.server,
password: null,
})
publish()
},
async removeServer(id) {
setStore("calls", (calls) => [...calls, `remove:${id}`])
setStore("state", "servers", (servers) => servers.filter((server) => server.config.id !== id))
publish()
},
}
const platform: Platform = {
platform: "desktop",
os: "windows",
windowID: "settings-wsl-test",
openExternal: () => undefined,
openDirectoryPickerDialog: async () => null,
notify: async () => undefined,
restart: unused,
wslServers: wsl,
}
function Interface() {
const wsl = useWslServers()
const servers = createMemo<ServerConnection.Any[]>(() => [
{ type: "sidecar", variant: "base", displayName: "Local Server", http: { url: input.server } },
...(wsl.data?.servers ?? []).flatMap((item): ServerConnection.Any[] =>
item.runtime.kind === "ready"
? [
{
type: "sidecar",
variant: "wsl",
distro: item.config.distro,
displayName: item.config.distro,
http: { url: item.runtime.url },
},
]
: [],
),
])
return (
<Show when={wsl.data}>
<AppInterface
servers={servers()}
defaultServer={ServerConnection.Key.make("sidecar")}
router={(props) => <MemoryRouter {...props} history={history} />}
/>
</Show>
)
}
return (
<PlatformProvider value={platform}>
<AppBaseProviders locale="en">
<output aria-label="WSL actions">{store.calls.join(",")}</output>
<Interface />
</AppBaseProviders>
</PlatformProvider>
)
}, root)
}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
</head>
<body>
<div id="root"></div>
<script type="module">
import { mount } from "./settings-wsl.fixture.tsx"
const query = new URLSearchParams(location.search)
mount({ server: query.get("server"), mode: query.get("mode") })
</script>
</body>
</html>
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "1.18.15",
"version": "2.0.2",
"description": "",
"type": "module",
"exports": {
@@ -84,6 +84,7 @@
"@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:",
"core-js": "3.50.0",
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
+1
View File
@@ -40,6 +40,7 @@ export default defineConfig({
reuseExistingServer: !built,
timeout: 120_000,
env: {
VITE_OPENCODE_TEST_FIXTURES: "1",
VITE_OPENCODE_SERVER_HOST: serverHost,
VITE_OPENCODE_SERVER_PORT: serverPort,
},
+1
View File
@@ -1,5 +1,6 @@
// @refresh reload
import "@/runtime/polyfills"
import { init } from "@sentry/solid"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app"
@@ -1,6 +1,7 @@
import { useDirectoryPicker } from "@/workspaces/selection/picker"
import { useServerActionsController } from "@/servers/registry/controller"
import { useSettingsCommand } from "@/settings/command"
import { useSettingsSurface } from "@/settings/surface"
import { type LocalProject } from "@/shell/state/layout"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
@@ -27,6 +28,7 @@ export function createHomeProjectsController(home: HomeController) {
const dialog = useDialog()
const language = useLanguage()
const openSettings = useSettingsCommand()
const settings = useSettingsSurface()
const serverManagement = useServerActionsController()
const global = useGlobal()
const authenticate = useSshAuthenticate()
@@ -131,8 +133,9 @@ export function createHomeProjectsController(home: HomeController) {
})
},
edit: (conn: ServerConnection.Any, project: LocalProject) => {
void import("@/settings/workspaces/project-dialog").then(({ DialogEditProject }) => {
void dialog.show(() => <DialogEditProject server={conn} project={project} />)
settings.openProject({
server: ServerConnection.key(conn),
project: project.worktree,
})
},
unseenCount: (conn: ServerConnection.Any, project: LocalProject) => {
+32
View File
@@ -38,6 +38,38 @@
}
@layer components {
[data-slot="session-chat-panel"] {
container-name: session-chat;
}
[data-slot="session-chat-panel"]
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
translate: var(--session-summary-resize-translate, var(--session-summary-translate, none));
transition: translate 240ms cubic-bezier(0.4, 0, 0.2, 1);
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
[data-slot="session-chat-panel"][data-summary-resizing="true"]
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
transition: none;
}
/* Keep the 1000px conversation intact, with 320px for the summary and its gutters.
Move only as far as needed; wider panels already have enough space in the margin. */
@container session-chat (min-width: 1320px) {
[data-slot="session-chat-panel"][data-summary-open="true"]
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
--session-summary-translate: min(0px, calc(50cqi - 820px));
&:dir(rtl) {
--session-summary-translate: max(0px, calc(820px - 50cqi));
}
}
}
[data-slot="session-chat-panel"][data-scrollbar-hidden="true"]
[data-slot="session-timeline-scroll"]
> .scroll-view__thumb {
@@ -17,12 +17,14 @@ import { useSessionKey } from "@/session/session-layout"
import { showToast } from "@/shell/notifications/toast"
import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
import { clearSessionMessageHandoff, setSessionMessageHandoff } from "@/session/handoff"
import type { DraftMcpControls } from "./mcp"
export function createNewSessionComposerAdapter(props: {
draftID: string
worktree: () => string
branch: () => string | undefined
submitted: () => void
mcp: DraftMcpControls
}) {
const route = useSessionKey()
const prompt = useComposerState()
@@ -49,6 +51,7 @@ export function createNewSessionComposerAdapter(props: {
const projectDirectory = location().directory
const worktree = props.worktree()
const branch = props.branch()
const mcp = props.mcp.capture()
const id = Session.ID.create()
const pending =
worktree === "create"
@@ -68,6 +71,18 @@ export function createNewSessionComposerAdapter(props: {
return
}
const rollback = async () => {
if (!pending) return
data.project.invalidate()
await data.project.sync().catch(() => undefined)
await pending.rollback(sessionDirectory)
}
if (!(await props.mcp.prepare(sessionDirectory, mcp))) {
await rollback()
if (pending) props.mcp.remember(sessionDirectory, mcp)
return
}
const created = data.session.create({
id,
agent: selection.agent,
@@ -89,10 +104,7 @@ export function createNewSessionComposerAdapter(props: {
},
)
if (pending && !(await creation).ok) {
// Keep retries on the worktree that was already created, not another new checkout.
data.project.invalidate()
await data.project.sync().catch(() => undefined)
await pending.rollback(sessionDirectory)
await rollback()
return
}
const afterCreation = async <T>(run: () => Promise<T>) => {
+117
View File
@@ -0,0 +1,117 @@
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useMcpToggle, type McpControls } from "@/providers/connect/mcp"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { useServer } from "@/runtime/server/current"
import { useTabs } from "@/shell/tabs/tabs"
import { showToast } from "@/shell/notifications/toast"
import { useWorkspaceLocation } from "@/workspaces/location"
export function createDraftMcpControls(input: { draftID: string; worktree: () => string }) {
const tabs = useTabs()
const location = useWorkspaceLocation()
const server = useServer()
const sdk = useServerSDK()
const language = useLanguage()
const [store, setStore] = createStore<{
preparing: boolean
pending: Record<string, Promise<boolean> | undefined>
}>({ preparing: false, pending: {} })
const key = (worktree: string) => JSON.stringify([server.key, location().directory, worktree])
const target = createMemo(() => key(input.worktree()))
const preview = () => input.worktree() === "create"
const directory = createMemo(() => {
const selected = input.worktree()
return selected === "main" || selected === "create" ? location().directory : selected
})
const states = createMemo(() => {
const draft = tabs.store.find((tab) => tab.type === "draft" && tab.draftID === input.draftID)
return draft?.type === "draft" && draft.mcp?.target === target() ? draft.mcp.states : {}
})
const toggle = useMcpToggle(directory)
const controls: McpControls = {
get preview() {
return preview()
},
get states() {
return states()
},
get pending() {
return store.preparing || (!preview() && store.pending[directory()] !== undefined)
},
change(name, enabled) {
if (controls.pending) return
tabs.updateDraft(input.draftID, { mcp: { target: target(), states: { ...states(), [name]: enabled } } })
if (preview()) return
const current = directory()
const request = toggle.mutateAsync({ name, enabled, directory: current }).then(
() => true,
() => false,
)
setStore("pending", current, request)
void request.finally(() => setStore("pending", current, undefined))
},
}
const apply = async (directory: string, states: Readonly<Record<string, boolean>>) => {
const pending = store.pending[directory]
if (pending && !(await pending)) return false
const entries = Object.entries(states)
if (entries.length === 0) return true
const catalog = await sdk.api.mcp.list({ location: { directory } })
const missing = entries.find(([name, enabled]) => enabled && !catalog.data.some((server) => server.name === name))
if (missing) throw new Error(language.t("session.summary.mcp.unavailable", { name: missing[0] }))
const results = await Promise.all(
entries
.filter(([name, enabled]) => {
const server = catalog.data.find((server) => server.name === name)
return server && (enabled ? server.status.status !== "connected" : server.status.status !== "disabled")
})
.map(([name, enabled]) =>
toggle.mutateAsync({ name, enabled, directory }).then(
() => true,
() => false,
),
),
)
if (results.some((success) => !success)) return false
const current = await sdk.api.mcp.list({ location: { directory } })
const unresolved = entries.find(([name, enabled]) => {
const status = current.data.find((server) => server.name === name)?.status.status
return enabled ? status !== "connected" : status !== undefined && status !== "disabled"
})
if (!unresolved) return true
const status = current.data.find((server) => server.name === unresolved[0])?.status.status
throw new Error(
language.t(status === "needs_auth" ? "session.summary.mcp.signInBeforeSend" : "session.summary.mcp.notReady", {
name: unresolved[0],
}),
)
}
return {
controls,
directory,
capture: () => ({ ...states() }),
remember(directory: string, states: Readonly<Record<string, boolean>>) {
tabs.updateDraft(input.draftID, { mcp: { target: key(directory), states: { ...states } } })
},
async prepare(directory: string, states: Readonly<Record<string, boolean>>) {
if (!store.pending[directory] && !Object.keys(states).length) return true
setStore("preparing", true)
return apply(directory, states)
.catch((error) => {
showToast({
variant: "error",
title: language.t("session.summary.mcp.prepareFailed"),
description: error instanceof Error ? error.message : String(error),
})
return false
})
.finally(() => setStore("preparing", false))
},
}
}
export type DraftMcpControls = ReturnType<typeof createDraftMcpControls>
+17 -7
View File
@@ -1,25 +1,34 @@
import { createPromptProjectController } from "@/new-session/project/selector"
import { useSettingsDialog } from "@/settings/command"
import { useSettings } from "@/settings/model"
import { useSettingsSurface } from "@/settings/surface"
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
import { useSettingsServers } from "@/settings/servers/inventory"
import { useSearchParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, untrack } from "solid-js"
import { createComposerModel } from "@/composer/model"
import { useComposerCommands } from "@/composer/commands"
import { createNewSessionComposerAdapter } from "./composer-adapter"
import { NewSessionStatus, NewSessionView } from "./view"
import { NewSessionView } from "./view"
import { createNewSessionWorkspaceController } from "./workspace/controller"
import { useNewSessionCommands } from "./commands"
import { createDraftMcpControls } from "./mcp"
/** The draft-only Session page. Submitting promotes the draft into a real Session. */
export default function NewSessionPage(props: { draftId: string }) {
const settings = useSettings()
const [search, setSearch] = useSearchParams<{ draftId?: string; prompt?: string }>()
const tabs = useTabs()
const openWorkspaces = useSettingsDialog("workspaces")
const servers = useSettingsServers()
const settingsSurface = useSettingsSurface()
const draftTab = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId),
)
const openWorkspaces = () => {
const draft = draftTab()
if (servers().length > 1 && draft) {
settingsSurface.openServer(draft.server, "workspaces")
return
}
settingsSurface.open("workspaces")
}
const workspace = createNewSessionWorkspaceController({
selectedWorktree: () => draftTab()?.worktree,
selectedBranch: () => draftTab()?.branch,
@@ -31,11 +40,13 @@ export default function NewSessionPage(props: { draftId: string }) {
},
onViewAll: openWorkspaces,
})
const mcp = createDraftMcpControls({ draftID: props.draftId, worktree: workspace.selection.value })
const composer = createNewSessionComposerAdapter({
draftID: props.draftId,
worktree: workspace.selection.value,
branch: workspace.bar.branch,
submitted: workspace.selection.remember,
mcp,
})
const model = createComposerModel(composer.adapter)
useComposerCommands({ model: composer.model })
@@ -72,9 +83,8 @@ export default function NewSessionPage(props: { draftId: string }) {
return (
<div class="relative size-full overflow-hidden flex flex-col">
{suspendUntilPromptReady()}
<NewSessionStatus visible={settings.visibility.status()} />
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<NewSessionView composer={model} project={project} workspace={workspace} />
<NewSessionView composer={model} project={project} workspace={workspace} mcp={mcp} />
</div>
</div>
)

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