chore: merge dev into v2 (#34788)

Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Affan Ali <93028901+affanali2k3@users.noreply.github.com>
Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Jay V <air@live.ca>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: Ben Guthrie <benjee.012@gmail.com>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com>
Co-authored-by: Max Anderson <max.a.anderson95@gmail.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: runvip <164729189+runvip@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
This commit is contained in:
James Long 2026-07-01 17:12:00 -04:00 committed by GitHub
parent 932a40cfd9
commit 8c94e9005f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
590 changed files with 15772 additions and 5530 deletions

View file

@ -34,10 +34,48 @@ jobs:
const now = Date.now();
const twoHours = 2 * 60 * 60 * 1000;
const orgMemberAssociations = new Set(['OWNER', 'MEMBER']);
const agentLogin = 'opencode-agent[bot]';
const { data: file } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/TEAM_MEMBERS',
ref: 'dev',
});
const teamMembers = new Set(
Buffer.from(file.content, 'base64')
.toString()
.split('\n')
.map((line) => line.trim().toLowerCase())
.filter(Boolean)
);
function isExempt(item) {
const login = item.user?.login?.toLowerCase();
return (
login === agentLogin ||
orgMemberAssociations.has(item.author_association) ||
(login && teamMembers.has(login))
);
}
for (const item of items) {
const isPR = !!item.pull_request;
const kind = isPR ? 'PR' : 'issue';
const login = item.user?.login;
if (isExempt(item)) {
core.info(`Skipping ${kind} #${item.number}; author ${login || 'unknown'} is exempt`);
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
name: 'needs:compliance',
});
} catch (e) {}
continue;
}
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,

View file

@ -17,12 +17,31 @@ jobs:
with:
fetch-depth: 1
- name: Check exempt issue author
id: author
run: |
LOGIN="${{ github.event.issue.user.login }}"
ASSOCIATION="${{ github.event.issue.author_association }}"
if [ "$LOGIN" = "opencode-agent[bot]" ] ||
[ "$ASSOCIATION" = "OWNER" ] ||
[ "$ASSOCIATION" = "MEMBER" ] ||
grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- uses: ./.github/actions/setup-bun
if: steps.author.outputs.skip != 'true'
- name: Install opencode
if: steps.author.outputs.skip != 'true'
run: curl -fsSL https://opencode.ai/install | bash
- name: Check duplicates and compliance
if: steps.author.outputs.skip != 'true'
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -38,6 +57,7 @@ jobs:
opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created:
Issue number: ${{ github.event.issue.number }}
Issue author association: ${{ github.event.issue.author_association }}
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
@ -49,6 +69,8 @@ jobs:
Check whether the issue follows our contributing guidelines and issue templates.
If the issue author association is OWNER or MEMBER, skip this compliance check. Do not add the needs:compliance label for organization-owned issues.
This project has three issue templates that every issue MUST use one of:
1. Bug Report - requires a Description field with real content
@ -83,7 +105,7 @@ jobs:
Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows:
If the issue is NOT compliant, start the comment with:
If the issue is NOT compliant and the author association is not OWNER or MEMBER, start the comment with:
<!-- issue-compliance -->
Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance
@ -129,12 +151,31 @@ jobs:
with:
fetch-depth: 1
- name: Check exempt issue author
id: author
run: |
LOGIN="${{ github.event.issue.user.login }}"
ASSOCIATION="${{ github.event.issue.author_association }}"
if [ "$LOGIN" = "opencode-agent[bot]" ] ||
[ "$ASSOCIATION" = "OWNER" ] ||
[ "$ASSOCIATION" = "MEMBER" ] ||
grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- uses: ./.github/actions/setup-bun
if: steps.author.outputs.skip != 'true'
- name: Install opencode
if: steps.author.outputs.skip != 'true'
run: curl -fsSL https://opencode.ai/install | bash
- name: Recheck compliance
if: steps.author.outputs.skip != 'true'
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -148,9 +189,12 @@ jobs:
}
run: |
opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited.
Issue author association: ${{ github.event.issue.author_association }}
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
If the issue author association is OWNER or MEMBER, remove the needs:compliance label if present, delete the previous compliance comment if present, and do not post a new comment.
Re-check whether the issue now follows our contributing guidelines and issue templates.
This project has three issue templates that every issue MUST use one of:

View file

@ -16,13 +16,32 @@ jobs:
with:
fetch-depth: 1
- name: Check exempt issue author
id: author
run: |
LOGIN="${{ github.event.issue.user.login }}"
ASSOCIATION="${{ github.event.issue.author_association }}"
if [ "$LOGIN" = "opencode-agent[bot]" ] ||
[ "$ASSOCIATION" = "OWNER" ] ||
[ "$ASSOCIATION" = "MEMBER" ] ||
grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Bun
if: steps.author.outputs.skip != 'true'
uses: ./.github/actions/setup-bun
- name: Install opencode
if: steps.author.outputs.skip != 'true'
run: curl -fsSL https://opencode.ai/install | bash
- name: Triage issue
if: steps.author.outputs.skip != 'true'
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
@ -92,7 +92,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.17.11",
"version": "1.17.13",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@ -144,7 +144,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@ -180,7 +180,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@ -207,7 +207,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@ -229,7 +229,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@ -253,7 +253,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@ -273,7 +273,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.17.11",
"version": "1.17.13",
"bin": {
"opencode": "./bin/opencode",
},
@ -326,7 +326,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.9.3",
"gitlab-ai-provider": "6.10.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@ -339,7 +339,7 @@
"npm-package-arg": "13.0.2",
"semver": "^7.6.3",
"turndown": "7.2.0",
"venice-ai-sdk-provider": "2.0.2",
"venice-ai-sdk-provider": "2.1.1",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
"zod": "catalog:",
@ -368,7 +368,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@ -422,7 +422,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@ -436,7 +436,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"effect": "catalog:",
},
@ -448,7 +448,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@ -480,7 +480,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@ -496,7 +496,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83",
@ -527,7 +527,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
@ -546,7 +546,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.17.11",
"version": "1.17.13",
"bin": {
"opencode": "./bin/opencode",
},
@ -619,7 +619,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.9.3",
"gitlab-ai-provider": "6.10.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@ -643,7 +643,7 @@
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"ulid": "catalog:",
"venice-ai-sdk-provider": "2.0.2",
"venice-ai-sdk-provider": "2.1.1",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"ws": "8.21.0",
@ -677,7 +677,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/llm": "workspace:*",
@ -762,7 +762,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"cross-spawn": "catalog:",
},
@ -777,7 +777,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@ -792,7 +792,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@ -836,7 +836,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@ -849,7 +849,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*",
@ -882,7 +882,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@ -901,7 +901,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@ -942,7 +942,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
@ -971,7 +971,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@ -1022,7 +1022,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@ -3828,7 +3828,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.9.3", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-lWo6b6es5+k9iXaDIvE9ECzyK4zfEza4+dQ5FN8SJpEuVRi3ZBCpHIOTa32QoYEDCBaiPh+tcyca86PfNodmlg=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.10.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-oWEZ06rDO6JjB7INHO882wyBAQqCZVHiDHwCs5M+VPmdDj8TzhGXcYesA2CcV5RoI5lfHLKwGp5uKFB62VWpqw=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@ -5446,7 +5446,7 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.0.2", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.47", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.27" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-aoa05nI3BTK5aGbjBflq+Gfln2AHAkwNbWuGGvCzUIsOfp5Y3iPD4O4PUGDAEiWVJWbjpPn0KfDa0H/HebwsaA=="],
"venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.1.1", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.51", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.30" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-w3OHkuzzKZ3r2TOxER6myBYzZJNoDqol+DUHu3NnfBN/GETnUVxecZJab0CHQQ8GZc0jjzpFymepjcLDPS4SQg=="],
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
@ -5998,6 +5998,8 @@
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@opencode-ai/core/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="],
"@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
@ -6008,6 +6010,8 @@
"@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
@ -6346,6 +6350,8 @@
"opencode/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="],
"opencode/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
@ -6448,11 +6454,11 @@
"unzipper/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.32" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SoPSkrL5cbNQnAljRsJ7pOzJ2FmWgnhC0lfFOda873ycCdFJL1A+h3Ib7mX2spcv3XnNaO13y/45/0RyqNWlIQ=="],
"venice-ai-sdk-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.32", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Kwj499fTcN9bP/AfGoPU7JWIXeP6VZqKI6omsH062c9E2G4gdjeJczkz4z/tYSkzYjLE2AI3DtZbMfs6D7vn2Q=="],
"verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
@ -7066,6 +7072,10 @@
"unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="],
"venice-ai-sdk-provider/@ai-sdk/provider-utils/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="],
"venice-ai-sdk-provider/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],

View file

@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-JXQ9PAqqRlJtHa8T3ZxqdRRyxC+0ip+2wSnehvKXUbI=",
"aarch64-linux": "sha256-nI+RaxDXmAcjhSjCtIvyi292xBEg0E5NXaoyGrJE69s=",
"aarch64-darwin": "sha256-UleKpm7Khf3kibm4BqZJ9bmu0N2kOjNd77g1Bd2tmfw=",
"x86_64-darwin": "sha256-V4AQH868dOfVhEj1mKTnZlhpZwFqBYsHS28Tx9VXHkE="
"x86_64-linux": "sha256-zjYaBa+TKuHVBO0z0YqOOFLOxpxj2hB6Iq1o+BXBeVc=",
"aarch64-linux": "sha256-S2QDDoKcg8QCqjxCnMmLp/3xmiVHAbCJ0CuHQRjDBbE=",
"aarch64-darwin": "sha256-GtdfHbk4fhvosgo6iEjXrWSxteK+eNrT0P9swEccbew=",
"x86_64-darwin": "sha256-zmWFdPSTS/DpU40CgHA/KKiSIs0PlI092TzLv/cG0jE="
}
}

View file

@ -45,7 +45,7 @@ export async function installStressSessionTabs(page: Page, input?: { draftID?: s
}),
)
localStorage.setItem(
"opencode.global.dat:tabs",
"opencode.window.browser.dat:tabs",
JSON.stringify([
...sessionIDs.map((sessionId) => ({
type: "session",

View file

@ -14,7 +14,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
localStorage.setItem(
"opencode.global.dat:tabs",
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
{ type: "session", server: serverB, sessionId: sessionB },
@ -52,7 +52,7 @@ test("legacy session routes preserve an existing tab's server", async ({ page })
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
localStorage.setItem(
"opencode.global.dat:tabs",
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]),
)
},

View file

@ -66,7 +66,7 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
await expect(control).toBeVisible()
await control.locator('[data-action="prompt-model-variant"]').click()
const high = page.getByRole("option", { name: "high" })
const high = page.getByRole("menuitemradio", { name: "high" })
await expect(high).toBeVisible()
await page.mouse.move(0, 0)
await expect(control).toBeVisible()

View file

@ -38,6 +38,29 @@ test("shows a pending question dock", async ({ page }) => {
await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible()
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
const rejectRequests: string[] = []
page.on("request", (request) => {
if (request.method() !== "POST") return
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
})
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
await expect(question).toBeVisible()
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
await expect(question.getByText("Select one answer")).toBeHidden()
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeHidden()
await expect(question.getByRole("radio", { name: /Extended/ })).toBeHidden()
await expect(question.getByRole("button", { name: "Dismiss" })).toBeVisible()
await expect(question.getByRole("button", { name: "Submit" })).toBeVisible()
await expect(page.locator('[data-component="question-minimized-dock"]')).toHaveCount(0)
expect(rejectRequests).toEqual([])
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
await expect(question).toBeVisible()
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
expect(rejectRequests).toEqual([])
await question.getByRole("radio", { name: /Minimal/ }).click()
const reply = page.waitForRequest(
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",

View file

@ -145,7 +145,7 @@ async function configurePage(page: Page) {
}),
)
localStorage.setItem(
"opencode.global.dat:tabs",
"opencode.window.browser.dat:tabs",
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
)
},

View file

@ -127,7 +127,7 @@ test.describe("smoke: session timeline", () => {
await page.addInitScript(
({ dirBase64, sourceID, targetID }) => {
localStorage.setItem(
"opencode.global.dat:tabs",
"opencode.window.browser.dat:tabs",
JSON.stringify(
[sourceID, targetID].map((sessionId) => ({
type: "session",
@ -253,7 +253,7 @@ test.describe("smoke: session timeline", () => {
await page.addInitScript(
({ dirBase64, sourceID, targetID }) => {
localStorage.setItem(
"opencode.global.dat:tabs",
"opencode.window.browser.dat:tabs",
JSON.stringify(
[sourceID, targetID].map((sessionId) => ({
type: "session",

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.17.11",
"version": "1.17.13",
"description": "",
"type": "module",
"exports": {

View file

@ -28,22 +28,22 @@ import {
Show,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/context/command"
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { NotificationProvider, useNotification } from "@/context/notification"
import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider, useSettings } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider, useSDK } from "@/context/sdk"
import { WslServersProvider } from "@/wsl/context"
@ -52,16 +52,9 @@ import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import {
legacySessionHref,
legacySessionServer,
requireServerKey,
selectSessionLineage,
sessionHref,
} from "./utils/session-route"
import { isSessionNotFoundError } from "./utils/server-errors"
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import Session from "@/pages/session"
import { SessionPage, TargetSessionRoute as TargetSessionRouteContent } from "@/pages/session"
import { NewHome, LegacyHome } from "@/pages/home"
const NewSession = lazy(() => import("@/pages/new-session"))
@ -95,11 +88,7 @@ const SessionRoute = () => {
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
})
return (
<SessionProviders>
<Session />
</SessionProviders>
)
return <SessionPage />
}
const TargetSessionRoute = () => {
@ -114,76 +103,13 @@ const TargetSessionRoute = () => {
<Show when={requireServerKey(params.serverKey)} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<ResolvedTargetSessionRoute />
<TargetSessionRouteContent />
</ServerSyncProvider>
</ServerSDKProvider>
</Show>
)
}
function ResolvedTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
const settings = useSettings()
const tabs = useTabs()
const sync = useServerSync()
const serverKey = createMemo(() => requireServerKey(params.serverKey))
const cached = createMemo(() => sync().session.lineage.peek(params.id))
const [resolved] = createResource(
() => {
if (cached()) return
return { id: params.id, server: serverKey(), sync: sync() }
},
({ id, server, sync }) =>
sync.session.lineage.resolve(id).catch((error) => {
if (isSessionNotFoundError(error, id)) tabs.removeSessionTab({ server, sessionId: id })
throw error
}),
)
const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved()))
const directory = createMemo(() => current()?.session.directory)
const targetDirectory = () => directory()!
createEffect(() => {
const session = current()
if (!session) return
tabs.addSessionTab({
server: serverKey(),
sessionId: session.root.id,
})
})
return (
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
<Show when={!!current() || resolved.state !== "errored"} fallback={<ErrorPage error={resolved.error} />}>
<Show when={directory()}>
<Show
when={settings.general.newLayoutDesigns()}
fallback={<Navigate href={legacySessionHref(directory()!, params.id)} />}
>
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<TargetSessionPage />
</DirectoryDataProvider>
</SDKProvider>
</Show>
</Show>
</Show>
</TargetServerScopedProviders>
)
}
function TargetSessionPage() {
const sdk = useSDK()
const serverSDK = useServerSDK()
return (
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
<SessionProviders>
<Session />
</SessionProviders>
</Show>
)
}
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
// Notification/Models + the visual Layout) for that server.
@ -231,7 +157,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<TargetServerScopedProviders directory={directory}>
<DraftServerScopedProviders directory={directory}>
<SDKProvider directory={directory}>
<DirectoryDataProvider directory={directory} server={serverKey}>
<DraftProviders>
@ -239,7 +165,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
</TargetServerScopedProviders>
</DraftServerScopedProviders>
</ServerSyncProvider>
</ServerSDKProvider>
</Show>
@ -300,12 +226,36 @@ function SharedProviders(props: ParentProps) {
<>
<BodyDesignClass />
<CommandProvider>
<DesktopCommands />
<HighlightsProvider>{props.children}</HighlightsProvider>
</CommandProvider>
</>
)
}
function DesktopCommands() {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
command.register("desktop", () => {
const commands: CommandOption[] = []
if (platform.platform === "desktop" && platform.exportDebugLogs) {
commands.push({
id: "logs.export",
title: "Export logs",
category: language.t("command.category.settings"),
onSelect: () => {
void platform.exportDebugLogs?.()
},
})
}
return commands
})
return null
}
// Server-scoped providers shared by the legacy shell and the top-level new shell.
type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined
@ -340,38 +290,14 @@ function NewAppLayout(props: ParentProps) {
)
}
function TargetServerScopedProviders(props: ServerScopedShellProps) {
function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) {
return (
<PermissionProvider directory={props.directory}>
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</PermissionProvider>
)
}
function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
const notification = useNotification()
createEffect(() => {
const sessionID = props.sessionID?.()
if (!notification.ready() || !sessionID) return
if (notification.session.unseenCount(sessionID) === 0) return
notification.session.markViewed(sessionID)
})
return null
}
function SessionProviders(props: ParentProps) {
return (
<TerminalProvider>
<FileProvider>
<PromptProvider>
<CommentsProvider>{props.children}</CommentsProvider>
</PromptProvider>
</FileProvider>
</TerminalProvider>
)
}
// The draft page only renders the prompt composer, so it drops TerminalProvider.
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
function DraftProviders(props: ParentProps) {

View file

@ -3,13 +3,26 @@ import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Button } from "@opencode-ai/ui/button"
import type { Component } from "solid-js"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog as DialogV2, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { Switch as SwitchV2 } from "@opencode-ai/ui/v2/switch-v2"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { For, Show, type Component } from "solid-js"
import { useLocal } from "@/context/local"
import { popularProviders } from "@/hooks/use-providers"
import { useLanguage } from "@/context/language"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectProvider } from "./dialog-select-provider"
import { decode64 } from "@/utils/base64"
import { SettingsListV2 } from "./settings-v2/parts/list"
import { SettingsRowV2 } from "./settings-v2/parts/row"
import "./settings-v2/settings-v2.css"
type ModelItem = ReturnType<ReturnType<typeof useLocal>["model"]["list"]>[number]
export const DialogManageModels: Component = () => {
const local = useLocal()
@ -102,3 +115,151 @@ export const DialogManageModels: Component = () => {
</Dialog>
)
}
export const DialogManageModelsV2: Component = () => {
const local = useLocal()
const language = useLanguage()
const dialog = useDialog()
const directory = () => decode64(local.slug())
const handleConnectProvider = () => {
dialog.show(() => <DialogSelectProvider directory={directory} />)
}
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
const providerVisible = (providerID: string) =>
providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id }))
const setProviderVisibility = (providerID: string, checked: boolean) => {
providerList(providerID).forEach((x) => {
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked)
})
}
const setModelVisibility = (item: ModelItem, checked: boolean) => {
local.model.setVisibility({ modelID: item.id, providerID: item.provider.id }, checked)
}
const list = useFilteredList<ModelItem>({
items: () => local.model.list(),
key: (x) => `${x.provider.id}:${x.id}`,
filterKeys: ["provider.name", "name", "id"],
sortBy: (a, b) => a.name.localeCompare(b.name),
groupBy: (x) => x.provider.id,
sortGroupsBy: (a, b) => {
const aRank = popularProviders.indexOf(a.category)
const bRank = popularProviders.indexOf(b.category)
const aPopular = aRank >= 0
const bPopular = bRank >= 0
if (aPopular && !bPopular) return -1
if (!aPopular && bPopular) return 1
return aRank - bRank
},
})
return (
<DialogV2 size="large" variant="settings" class="settings-v2-manage-models-dialog">
<DialogHeader hideClose={true} closeLabel={language.t("common.close")}>
<DialogTitleGroup
title={language.t("dialog.model.manage")}
description={language.t("dialog.model.manage.description")}
/>
<ButtonV2 variant="neutral" icon="plus" onClick={handleConnectProvider}>
{language.t("command.provider.connect")}
</ButtonV2>
</DialogHeader>
<DialogBody class="flex min-h-0 flex-1 flex-col">
<div class="px-4 pt-px pb-3">
<div class="relative">
<TextInputV2
type="search"
appearance="base"
class="!w-full self-stretch"
value={list.filter()}
onInput={(event) => list.onInput(event.currentTarget.value)}
placeholder={language.t("dialog.model.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
autofocus
aria-label={language.t("dialog.model.search.placeholder")}
/>
<Show when={list.filter()}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
class="settings-v2-tab-search-clear"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => list.clear()}
aria-label={language.t("common.clear")}
/>
</Show>
</div>
</div>
<div data-slot="manage-models-scroll" class="relative min-h-0 flex-1">
<div class="settings-v2-panel settings-v2-models h-full px-4 pt-4 pb-4">
<Show
when={!list.grouped.loading}
fallback={
<div class="settings-v2-models-status">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<Show
when={list.flat().length > 0}
fallback={
<div class="settings-v2-models-status">
<span>{language.t("dialog.model.empty")}</span>
<Show when={list.filter()}>
<span class="settings-v2-models-status-filter">&quot;{list.filter()}&quot;</span>
</Show>
</div>
}
>
<For each={list.grouped.latest}>
{(group) => (
<div class="settings-v2-section" data-component="settings-models-provider">
<div class="settings-v2-models-group-header justify-between">
<div class="flex min-w-0 items-center gap-2">
<ProviderIcon id={group.category} width={16} height={16} class="ml-4 shrink-0" />
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
</div>
<div>
<SwitchV2
class="mr-6"
checked={providerVisible(group.category)}
onChange={(checked) => setProviderVisibility(group.category, checked)}
hideLabel
>
{group.items[0].provider.name}
</SwitchV2>
</div>
</div>
<SettingsListV2>
<For each={group.items}>
{(item) => (
<SettingsRowV2 title={item.name} description="">
<div>
<SwitchV2
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
onChange={(checked) => setModelVisibility(item, checked)}
hideLabel
>
{item.name}
</SwitchV2>
</div>
</SettingsRowV2>
)}
</For>
</SettingsListV2>
</div>
)}
</For>
</Show>
</Show>
</div>
</div>
</DialogBody>
</DialogV2>
)
}

View file

@ -1,15 +1,19 @@
import { Popover as Kobalte } from "@kobalte/core/popover"
import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js"
import { Component, ComponentProps, createMemo, For, JSX, Show, ValidComponent } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { popularProviders } from "@/hooks/use-providers"
import { Button } from "@opencode-ai/ui/button"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Tag } from "@opencode-ai/ui/tag"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
@ -18,6 +22,22 @@ const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
type ModelState = ReturnType<typeof useLocal>["model"]
type ModelItem = ReturnType<ModelState["list"]>[number]
const modelKey = (model: ModelItem) => `${model.provider.id}:${model.id}`
const manageKey = "action:manage"
const sortModelGroups = (a: { category: string; items: ModelItem[] }, b: { category: string; items: ModelItem[] }) => {
const aIndex = popularProviders.indexOf(a.category)
const bIndex = popularProviders.indexOf(b.category)
const aPopular = aIndex >= 0
const bPopular = bIndex >= 0
if (aPopular && !bPopular) return -1
if (!aPopular && bPopular) return 1
if (aPopular && bPopular) return aIndex - bIndex
return a.items[0].provider.name.localeCompare(b.items[0].provider.name)
}
const ModelList: Component<{
provider?: string
@ -200,6 +220,271 @@ export function ModelSelectorPopover(props: {
)
}
export function ModelSelectorPopoverV2(props: {
provider?: string
model?: ModelState
children?: JSX.Element
triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps
onClose?: () => void
}) {
const model = props.model ?? useLocal().model
const language = useLanguage()
const dialog = useDialog()
const [store, setStore] = createStore({ open: false, search: "", active: "" })
let searchRef: HTMLInputElement | undefined
let contentRef: HTMLDivElement | undefined
let restoreTrigger = true
const allModels = createMemo(() =>
model
.list()
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
)
const models = createMemo(() => {
const search = store.search.trim().toLowerCase()
const filtered = search
? allModels().filter(
(item) =>
item.name.toLowerCase().includes(search) ||
item.id.toLowerCase().includes(search) ||
item.provider.name.toLowerCase().includes(search),
)
: allModels()
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
})
const groups = createMemo(() => {
const byProvider = new Map<string, ModelItem[]>()
for (const item of models()) {
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
}
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
})
const keys = () => [...models().map(modelKey), manageKey]
const current = () => {
const value = model.current()
return value ? `${value.provider.id}:${value.id}` : undefined
}
const initialActive = () => {
const selected = current()
const options = keys()
if (selected && options.includes(selected)) return selected
return options[0] ?? ""
}
const activeItem = () =>
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
const afterClose = (callback: () => void) => {
const complete = () => {
if (contentRef?.isConnected) {
requestAnimationFrame(complete)
return
}
requestAnimationFrame(() => requestAnimationFrame(callback))
}
requestAnimationFrame(complete)
}
const setOpen = (open: boolean) => {
if (open) {
restoreTrigger = true
setStore({ open: true, active: initialActive() })
setTimeout(() =>
requestAnimationFrame(() => {
searchRef?.focus()
activeItem()?.scrollIntoView({ block: "nearest" })
}),
)
return
}
setStore({ open: false, search: "", active: "" })
}
const select = (item: ModelItem) => {
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
props.onClose?.()
}
const selectModel = (item: ModelItem) => {
restoreTrigger = false
setOpen(false)
afterClose(() => select(item))
}
const manage = () => {
restoreTrigger = false
setOpen(false)
afterClose(() => {
void import("./dialog-manage-models").then((x) => {
dialog.show(() => <x.DialogManageModelsV2 />)
})
})
}
const selectActive = () => {
const item = models().find((item) => modelKey(item) === store.active)
if (item) {
selectModel(item)
return
}
if (store.active === manageKey) manage()
}
const moveActive = (delta: number) => {
const options = keys()
if (options.length === 0) return
const index = options.indexOf(store.active)
const start = index === -1 ? 0 : index
setStore("active", options[(start + delta + options.length) % options.length])
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
}
const setSearch = (value: string) => {
const search = value.trim().toLowerCase()
const first = [...allModels()]
.sort((a, b) => a.name.localeCompare(b.name))
.find(
(item) =>
!search ||
item.name.toLowerCase().includes(search) ||
item.id.toLowerCase().includes(search) ||
item.provider.name.toLowerCase().includes(search),
)
setStore({ search: value, active: first ? modelKey(first) : manageKey })
}
return (
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
{props.children}
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content
ref={(el: HTMLDivElement) => (contentRef = el)}
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onPointerDownOutside={() => (restoreTrigger = false)}
onFocusOutside={() => (restoreTrigger = false)}
onCloseAutoFocus={(event) => {
if (!restoreTrigger) event.preventDefault()
}}
>
<div class="flex flex-col p-0.5">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(el) => (searchRef = el)}
value={store.search}
placeholder={language.t("dialog.model.search.placeholder")}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
onInput={(event) => setSearch(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Tab") return
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
restoreTrigger = false
setOpen(false)
afterClose(() => props.onClose?.())
return
}
if (event.altKey || event.metaKey) return
if (event.key === "ArrowDown") {
event.preventDefault()
moveActive(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
moveActive(-1)
return
}
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault()
selectActive()
}
}}
/>
<Show when={store.search.trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
onPointerDown={(event) => event.preventDefault()}
onClick={() => setSearch("")}
aria-label={language.t("common.clear")}
>
<Icon name="close" size="small" />
</button>
</Show>
</div>
</div>
<div class="h-px bg-v2-border-border-muted" />
<ScrollView data-slot="model-selector-scroll" class="max-h-[220px] min-h-0">
<div class="flex flex-col p-0.5 pt-0">
<Show
when={models().length > 0}
fallback={
<div class="flex h-12 items-center px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
{language.t("dialog.model.empty")}
</div>
}
>
<For each={groups()}>
{(group) => (
<MenuV2.Group>
<MenuV2.GroupLabel class="gap-2 px-3">
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
</MenuV2.GroupLabel>
<MenuV2.RadioGroup value={current()}>
<For each={group.items}>
{(item) => (
<MenuV2.RadioItem
value={modelKey(item)}
data-option-key={modelKey(item)}
data-selected-model={current() === modelKey(item) ? true : undefined}
class="scroll-my-6"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => {
setStore("active", modelKey(item))
setTimeout(() => searchRef?.focus())
}}
onSelect={() => selectModel(item)}
>
<span class="min-w-0 truncate">{item.name}</span>
<Show when={isFree(item.provider.id, item.cost)}>
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
</Show>
<Show when={item.latest}>
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
</Show>
</MenuV2.RadioItem>
)}
</For>
</MenuV2.RadioGroup>
</MenuV2.Group>
)}
</For>
</Show>
</div>
</ScrollView>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5">
<MenuV2.Item
data-option-key={manageKey}
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === manageKey }}
onMouseEnter={() => {
setStore("active", manageKey)
setTimeout(() => searchRef?.focus())
}}
onSelect={manage}
>
<Icon name="outline-sliders" size="small" />
<span class="min-w-0 flex-1 truncate leading-5">{language.t("dialog.model.manage")}</span>
</MenuV2.Item>
</div>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
)
}
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
const dialog = useDialog()
const language = useLanguage()

View file

@ -35,12 +35,14 @@ import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist"
import { usePermission } from "@/context/permission"
@ -68,6 +70,7 @@ import { promptPlaceholder } from "./prompt-input/placeholder"
import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
export type PromptInputState = ReturnType<typeof usePrompt>
@ -214,6 +217,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let fileInputRef: HTMLInputElement | undefined
let scrollRef!: HTMLDivElement
let slashPopoverRef!: HTMLDivElement
let restoreEndOnFocus = true
const mirror = { input: false }
const inset = 56
@ -593,6 +597,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
}
const handleFocus = () => {
if (!restoreEndOnFocus) return
restoreEndOnFocus = false
requestAnimationFrame(() => {
if (document.activeElement !== editorRef) return
setCursorPosition(editorRef, prompt.cursor() ?? promptLength(prompt.current()))
queueScroll()
})
}
const renderEditorWithCursor = (parts: Prompt) => {
const cursor = currentCursor()
renderEditor(parts)
@ -629,24 +643,89 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
}
const referenceDescription = (reference: ReferenceInfo) =>
reference.source.type === "git" ? reference.source.repository : reference.source.path
const referenceList = createMemo(() =>
sync()
.data.reference.filter((reference) => !reference.hidden)
.map(
(reference): AtOption => ({
type: "reference",
name: reference.name,
path: reference.path,
display: reference.name,
description: reference.description ?? referenceDescription(reference),
}),
),
)
const agentList = createMemo(() =>
props.controls.agents.available
.filter((agent) => !agent.hidden && agent.mode !== "primary")
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
)
const mcpResourceList = createMemo(() =>
Object.values(sync().data.mcp_resource).map(
(resource): AtOption => ({
type: "resource",
name: resource.name,
uri: resource.uri,
client: resource.client,
display: resource.name,
description: resource.description,
mime: resource.mimeType,
}),
),
)
const handleAtSelect = (option: AtOption | undefined) => {
if (!option) return
if (option.type === "agent") {
addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 })
} else {
addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
return
}
if (option.type === "reference") {
addPart({
type: "file",
path: option.path,
content: "@" + option.name,
start: 0,
end: 0,
mime: "application/x-directory",
filename: option.name,
})
return
}
if (option.type === "resource") {
addPart({
type: "file",
path: option.uri,
content: "@" + option.name,
start: 0,
end: 0,
mime: option.mime ?? "text/plain",
filename: option.name,
url: option.uri,
source: {
type: "resource",
text: { value: "@" + option.name, start: 0, end: 0 },
clientName: option.client,
uri: option.uri,
},
})
return
}
addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
}
const atKey = (x: AtOption | undefined) => {
if (!x) return ""
return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}`
if (x.type === "agent") return `agent:${x.name}`
if (x.type === "reference") return `reference:${x.name}`
if (x.type === "resource") return `resource:${x.client}:${x.uri}`
return `file:${x.path}`
}
const {
@ -657,30 +736,36 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onKeyDown: atOnKeyDown,
} = useFilteredList<AtOption>({
items: async (query) => {
const references = referenceList()
const agents = agentList()
const mcpResources = mcpResourceList()
const open = recent()
const seen = new Set(open)
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
if (!query.trim()) return [...agents, ...pinned]
if (!query.trim()) return [...references, ...agents, ...mcpResources, ...pinned]
const paths = await files.searchFilesAndDirectories(query)
const fileOptions: AtOption[] = paths
.filter((path) => !seen.has(path))
.map((path) => ({ type: "file", path, display: path }))
return [...agents, ...pinned, ...fileOptions]
return [...references, ...agents, ...mcpResources, ...pinned, ...fileOptions]
},
key: atKey,
filterKeys: ["display"],
skipFilter: (item) => item.type === "file" && !item.recent,
groupBy: (item) => {
if (item.type === "reference") return "reference"
if (item.type === "agent") return "agent"
if (item.type === "resource") return "resource"
if (item.recent) return "recent"
return "file"
},
sortGroupsBy: (a, b) => {
const rank = (category: string) => {
if (category === "agent") return 0
if (category === "recent") return 1
return 2
if (category === "reference") return 0
if (category === "agent") return 1
if (category === "resource") return 2
if (category === "recent") return 3
return 4
}
return rank(a.category) - rank(b.category)
},
@ -746,7 +831,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const pill = document.createElement("span")
pill.textContent = part.content
pill.setAttribute("data-type", part.type)
if (part.type === "file") pill.setAttribute("data-path", part.path)
if (part.type === "file") {
pill.setAttribute("data-path", part.path)
if (part.mime) pill.setAttribute("data-mime", part.mime)
if (part.filename) pill.setAttribute("data-filename", part.filename)
if (part.url) pill.setAttribute("data-url", part.url)
if (part.source?.type === "resource") {
pill.setAttribute("data-source-type", part.source.type)
pill.setAttribute("data-source-client-name", part.source.clientName)
pill.setAttribute("data-source-uri", part.source.uri)
}
}
if (part.type === "agent") pill.setAttribute("data-name", part.name)
pill.setAttribute("contenteditable", "false")
pill.style.userSelect = "text"
@ -791,8 +886,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
// Auto-scroll active command into view when navigating with keyboard
createEffect(() => {
const scrollSlashActiveIntoView = () => {
const activeId = slashActive()
if (!activeId || !slashPopoverRef) return
@ -800,7 +894,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`)
element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
})
})
}
const selectPopoverActive = () => {
if (store.popover === "at") {
const items = atFlat()
@ -862,12 +956,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const pushFile = (file: HTMLElement) => {
const content = file.textContent ?? ""
const source =
file.dataset.sourceType === "resource" && file.dataset.sourceClientName && file.dataset.sourceUri
? {
type: "resource" as const,
text: {
value: content,
start: position,
end: position + content.length,
},
clientName: file.dataset.sourceClientName,
uri: file.dataset.sourceUri,
}
: undefined
parts.push({
type: "file",
path: file.dataset.path!,
content,
start: position,
end: position + content.length,
...(file.dataset.mime ? { mime: file.dataset.mime } : {}),
...(file.dataset.filename ? { filename: file.dataset.filename } : {}),
...(file.dataset.url ? { url: file.dataset.url } : {}),
...(source ? { source } : {}),
})
position += content.length
}
@ -1287,6 +1398,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
if (store.popover === "slash") {
slashOnKeyDown(event)
if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) {
scrollSlashActiveIntoView()
}
}
event.preventDefault()
return
@ -1368,6 +1482,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
model: props.controls.model.selection,
providerID: props.controls.model.selection.current()?.provider?.id,
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
newLayoutDesigns: props.controls.newLayoutDesigns,
style: control(),
onClose: restoreFocus,
onUnpaidClick: () => {
@ -1378,6 +1493,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}))
const newSession = () => props.variant === "new-session"
const bindEditorRef = (el: HTMLDivElement) => {
editorRef = el
restoreEndOnFocus = true
props.ref?.(el)
}
const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
title: language.t("command.agent.cycle"),
@ -1406,6 +1526,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setSlashActive={setSlashActive}
onSlashSelect={handleSlashSelect}
commandKeybind={command.keybind}
commandKeybindParts={command.keybindParts}
newLayoutDesigns={props.controls.newLayoutDesigns}
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<Switch>
@ -1459,10 +1581,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="relative max-h-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
<div
data-component="prompt-input"
ref={(el) => {
editorRef = el
props.ref?.(el)
}}
ref={bindEditorRef}
role="textbox"
aria-multiline="true"
aria-label={designPlaceholder()}
@ -1477,6 +1596,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
classList={{
@ -1497,7 +1617,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div>
</div>
<div class="flex h-11 items-center px-2">
<div class="flex min-w-0 flex-1 items-center gap-0">
<div class="flex min-w-0 flex-1 items-center gap-1">
{fileAttachmentInput()}
<TooltipV2
placement="top"
@ -1529,6 +1649,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
<div
data-component="prompt-variant-control"
class="[&_[data-action=prompt-model-variant]]:![font-weight:440]"
classList={{
"animate-in fade-in": providersShouldFadeIn(),
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
@ -1545,22 +1666,45 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</>
}
>
<Select
size="normal"
options={variants()}
current={props.controls.model.selection.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
<MenuV2
gutter={6}
modal={false}
placement="top-start"
onOpenChange={(open) => setStore("variantOpen", open)}
onSelect={(value) => {
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
class="capitalize max-w-[160px] justify-start text-v2-text-text-faint"
valueClass="truncate text-[13px] font-[440] leading-5 text-v2-text-text-faint"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost"
/>
>
<MenuV2.Trigger
as={ButtonV2}
data-action="prompt-model-variant"
variant="ghost-muted"
size="normal"
class="max-w-[160px] justify-start capitalize"
style={control()}
>
<span class="truncate">
{props.controls.model.selection.variant.current() ?? language.t("common.default")}
</span>
<span class="-ml-0.5 -mr-1 flex shrink-0">
<Icon name="chevron-down" size="small" />
</span>
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.RadioGroup
value={props.controls.model.selection.variant.current() ?? "default"}
onChange={(value) => {
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
>
{variants().map((value) => (
<MenuV2.RadioItem value={value} class="capitalize">
{value === "default" ? language.t("common.default") : value}
</MenuV2.RadioItem>
))}
</MenuV2.RadioGroup>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</TooltipV2>
</div>
</Show>
@ -1640,10 +1784,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<div
data-component="prompt-input"
ref={(el) => {
editorRef = el
props.ref?.(el)
}}
ref={bindEditorRef}
role="textbox"
aria-multiline="true"
aria-label={placeholder()}
@ -1658,6 +1799,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
classList={{
@ -1942,6 +2084,7 @@ type ComposerModelControlState = {
model: ReturnType<typeof useLocal>["model"]
providerID?: string
modelName: string
newLayoutDesigns: boolean
style: JSX.CSSProperties | undefined
onClose: () => void
onUnpaidClick: () => void
@ -2032,36 +2175,65 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
</>
}
>
<ModelSelectorPopover
model={props.state.model}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: props.state.style,
class:
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
classList: { "animate-in fade-in": props.state.shouldAnimate },
"data-action": "prompt-model",
}}
onClose={props.state.onClose}
<Show
when={props.state.newLayoutDesigns}
fallback={
<ModelSelectorPopover
model={props.state.model}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: props.state.style,
class:
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
classList: { "animate-in fade-in": props.state.shouldAnimate },
"data-action": "prompt-model",
}}
onClose={props.state.onClose}
>
<ModelControlContent state={props.state} />
</ModelSelectorPopover>
}
>
<Show when={props.state.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<span class="-ml-1 shrink-0 flex size-fit">
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span>
</ModelSelectorPopover>
<ModelSelectorPopoverV2
model={props.state.model}
triggerAs={ButtonV2}
triggerProps={{
variant: "ghost-muted",
size: "normal",
style: props.state.style,
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
classList: { "animate-in fade-in": props.state.shouldAnimate },
"data-action": "prompt-model",
}}
onClose={props.state.onClose}
>
<ModelControlContent state={props.state} v2 />
</ModelSelectorPopoverV2>
</Show>
</TooltipV2>
</Show>
</Show>
)
}
function ModelControlContent(props: { state: ComposerModelControlState; v2?: boolean }) {
return (
<>
<Show when={props.state.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}>
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span>
</>
)
}

View file

@ -100,6 +100,41 @@ describe("buildRequestParts", () => {
)
})
test("preserves reference aliases as directory file parts", () => {
const result = buildRequestParts({
prompt: [
{
type: "file",
path: "/repo/../docs",
content: "@docs",
start: 0,
end: 5,
mime: "application/x-directory",
filename: "docs",
},
],
context: [],
images: [],
text: "@docs",
messageID: "msg_reference",
sessionID: "ses_reference",
sessionDirectory: "/repo/app",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
expect(filePart.mime).toBe("application/x-directory")
expect(filePart.filename).toBe("docs")
expect(filePart.url).toBe("file:///repo/../docs")
expect(filePart.source?.type).toBe("file")
if (filePart.source?.type === "file") {
expect(filePart.source.path).toBe("/repo/../docs")
expect(filePart.source.text.value).toBe("@docs")
}
}
})
test("deduplicates context files when prompt already includes same path", () => {
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]

View file

@ -99,21 +99,31 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
const path = absolute(input.sessionDirectory, attachment.path)
const source = attachment.source
? {
...attachment.source,
text: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
}
: {
type: "file" as const,
text: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
path,
}
return {
id: Identifier.ascending("part"),
type: "file",
mime: "text/plain",
url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
filename: getFilename(attachment.path),
source: {
type: "file",
text: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
path,
},
mime: attachment.mime ?? "text/plain",
url: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
filename: attachment.filename ?? getFilename(attachment.path),
source,
} satisfies PromptRequestPart
})

View file

@ -37,7 +37,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
</span>
}
placement="top"
openDelay={2000}
openDelay={800}
>
<div
classList={{

View file

@ -1,10 +1,22 @@
import { Component, For, Match, Show, Switch } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
export type AtOption =
| { type: "agent"; name: string; display: string }
| {
type: "resource"
name: string
uri: string
client: string
display: string
description?: string
mime?: string
}
| { type: "reference"; name: string; path: string; display: string; description: string }
| { type: "file"; path: string; display: string; recent?: boolean }
export interface SlashCommand {
@ -30,6 +42,8 @@ type PromptPopoverProps = {
setSlashActive: (id: string) => void
onSlashSelect: (item: SlashCommand) => void
commandKeybind: (id: string) => string | undefined
commandKeybindParts: (id: string) => string[]
newLayoutDesigns: boolean
t: (key: string) => string
}
@ -41,15 +55,29 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
if (props.popover === "slash") props.setSlashPopoverRef(el)
}}
class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10
overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px]
bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]"
overflow-auto no-scrollbar flex flex-col p-2"
classList={{
"z-[70] rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": props.newLayoutDesigns,
"rounded-[12px] bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]":
!props.newLayoutDesigns,
}}
onMouseDown={(e) => e.preventDefault()}
>
<Switch>
<Match when={props.popover === "at"}>
<Show
when={props.atFlat.length > 0}
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
fallback={
<div
class="px-2 py-1"
classList={{
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{props.t("prompt.popover.emptyResults")}
</div>
}
>
<For each={props.atFlat.slice(0, 10)}>
{(item) => {
@ -58,13 +86,117 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
if (item.type === "agent") {
return (
<button
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
class="w-full flex items-center gap-x-2 px-2 py-0.5"
classList={{
"rounded-[4px]": props.newLayoutDesigns,
"rounded-md": !props.newLayoutDesigns,
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
}}
onClick={() => props.onAtSelect(item)}
onMouseEnter={() => props.setAtActive(key)}
onPointerMove={() => props.setAtActive(key)}
>
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
<span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span>
<span
class="whitespace-nowrap"
classList={{
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
props.newLayoutDesigns,
"text-v2-text-text-base": props.newLayoutDesigns,
"text-14-regular": !props.newLayoutDesigns,
"text-text-strong": !props.newLayoutDesigns,
}}
>
@{item.name}
</span>
</button>
)
}
if (item.type === "resource") {
return (
<button
class="w-full flex items-center gap-x-2 px-2 py-0.5"
classList={{
"rounded-[4px]": props.newLayoutDesigns,
"rounded-md": !props.newLayoutDesigns,
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
}}
onClick={() => props.onAtSelect(item)}
onPointerMove={() => props.setAtActive(key)}
>
<FileIcon node={{ path: item.uri, type: "file" }} class="shrink-0 size-4" />
<div
class="flex items-center min-w-0"
classList={{
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
props.newLayoutDesigns,
"text-14-regular": !props.newLayoutDesigns,
}}
>
<span
class="text-text-strong whitespace-nowrap"
classList={{ "text-v2-text-text-base": props.newLayoutDesigns }}
>
@{item.name}
</span>
<Show when={item.description}>
{(description) => (
<span
class="whitespace-nowrap truncate min-w-0 ml-2"
classList={{
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{description()}
</span>
)}
</Show>
</div>
</button>
)
}
if (item.type === "reference") {
return (
<button
class="w-full flex items-center gap-x-2 px-2 py-0.5"
classList={{
"rounded-[4px]": props.newLayoutDesigns,
"rounded-md": !props.newLayoutDesigns,
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
}}
onClick={() => props.onAtSelect(item)}
onPointerMove={() => props.setAtActive(key)}
>
<FileIcon node={{ path: item.path, type: "directory" }} class="shrink-0 size-4" />
<div
class="flex items-center min-w-0"
classList={{
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
props.newLayoutDesigns,
"text-14-regular": !props.newLayoutDesigns,
}}
>
<span
class="text-text-strong whitespace-nowrap"
classList={{ "text-v2-text-text-base": props.newLayoutDesigns }}
>
@{item.name}
</span>
<span
class="whitespace-nowrap truncate min-w-0 ml-2"
classList={{
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{item.description}
</span>
</div>
</button>
)
}
@ -75,16 +207,44 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
return (
<button
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
class="w-full flex items-center gap-x-2 px-2 py-0.5"
classList={{
"rounded-[4px]": props.newLayoutDesigns,
"rounded-md": !props.newLayoutDesigns,
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
}}
onClick={() => props.onAtSelect(item)}
onMouseEnter={() => props.setAtActive(key)}
onPointerMove={() => props.setAtActive(key)}
>
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{directory}</span>
<div
class="flex items-center min-w-0"
classList={{
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
props.newLayoutDesigns,
"text-14-regular": !props.newLayoutDesigns,
}}
>
<span
class="whitespace-nowrap truncate min-w-0"
classList={{
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{directory}
</span>
<Show when={!isDirectory}>
<span class="text-text-strong whitespace-nowrap">{filename}</span>
<span
class="whitespace-nowrap"
classList={{
"text-v2-text-text-base": props.newLayoutDesigns,
"text-text-strong": !props.newLayoutDesigns,
}}
>
{filename}
</span>
</Show>
</div>
</button>
@ -96,41 +256,98 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
<Match when={props.popover === "slash"}>
<Show
when={props.slashFlat.length > 0}
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyCommands")}</div>}
fallback={
<div
class="px-2 py-1"
classList={{
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{props.t("prompt.popover.emptyCommands")}
</div>
}
>
<For each={props.slashFlat}>
{(cmd) => (
<button
data-slash-id={cmd.id}
classList={{
"w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
"bg-surface-raised-base-hover": props.slashActive === cmd.id,
}}
onClick={() => props.onSlashSelect(cmd)}
onMouseEnter={() => props.setSlashActive(cmd.id)}
>
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
<Show when={cmd.description}>
<span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
</Show>
</div>
<div class="flex items-center gap-2 shrink-0">
<Show when={cmd.type === "custom" && cmd.source !== "command"}>
<span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
{cmd.source === "skill"
? props.t("prompt.slash.badge.skill")
: cmd.source === "mcp"
? props.t("prompt.slash.badge.mcp")
: props.t("prompt.slash.badge.custom")}
{(cmd) => {
const keybind = () => props.commandKeybind(cmd.id)
const keybindParts = () => props.commandKeybindParts(cmd.id)
return (
<button
data-slash-id={cmd.id}
classList={{
"w-full flex items-center justify-between gap-4 px-2 py-1": true,
"rounded-[4px] scroll-my-2": props.newLayoutDesigns,
"rounded-md": !props.newLayoutDesigns,
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.slashActive === cmd.id,
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.slashActive === cmd.id,
}}
onClick={() => props.onSlashSelect(cmd)}
onPointerMove={() => props.setSlashActive(cmd.id)}
>
<div class="flex items-center gap-2 min-w-0">
<span
class="whitespace-nowrap"
classList={{
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
props.newLayoutDesigns,
"text-v2-text-text-base": props.newLayoutDesigns,
"text-14-regular": !props.newLayoutDesigns,
"text-text-strong": !props.newLayoutDesigns,
}}
>
/{cmd.trigger}
</span>
</Show>
<Show when={props.commandKeybind(cmd.id)}>
<span class="text-12-regular text-text-subtle">{props.commandKeybind(cmd.id)}</span>
</Show>
</div>
</button>
)}
<Show when={cmd.description}>
<span
class="truncate"
classList={{
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
props.newLayoutDesigns,
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-14-regular": !props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{cmd.description}
</span>
</Show>
</div>
<div class="flex items-center gap-2 shrink-0">
<Show when={cmd.type === "custom" && cmd.source !== "command"}>
<Show
when={props.newLayoutDesigns}
fallback={
<span class="text-11-regular px-1.5 py-0.5 rounded bg-surface-base text-text-subtle">
{cmd.source === "skill"
? props.t("prompt.slash.badge.skill")
: cmd.source === "mcp"
? props.t("prompt.slash.badge.mcp")
: props.t("prompt.slash.badge.custom")}
</span>
}
>
<Tag>
{cmd.source === "skill"
? props.t("prompt.slash.badge.skill")
: cmd.source === "mcp"
? props.t("prompt.slash.badge.mcp")
: props.t("prompt.slash.badge.custom")}
</Tag>
</Show>
</Show>
<Show when={props.newLayoutDesigns ? keybindParts().length > 0 : keybind()}>
<Show
when={props.newLayoutDesigns}
fallback={<span class="text-12-regular text-text-subtle">{keybind()}</span>}
>
<KeybindV2 keys={keybindParts()} variant="neutral" />
</Show>
</Show>
</div>
</button>
)
}}
</For>
</Show>
</Match>

View file

@ -92,11 +92,17 @@ export function PromptWorkspaceSelector(props: {
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{props.branch || "main"}</span>
</div>
<Show when={props.branch}>
{(branch) => (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{branch()}</span>
</div>
</>
)}
</Show>
</>
)
}

View file

@ -1,7 +1,9 @@
import { Match, Show, Switch, createMemo } from "solid-js"
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
import { Match, Show, Switch, createMemo, type ComponentProps, type JSX } from "solid-js"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
import { Button } from "@opencode-ai/ui/button"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useFile } from "@/context/file"
import { useLayout } from "@/context/layout"
@ -9,13 +11,23 @@ import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
interface SessionContextUsageProps {
variant?: "button" | "indicator"
placement?: TooltipProps["placement"]
buttonAppearance?: "default" | "v2"
placement?: ComponentProps<typeof TooltipV2>["placement"]
}
function ContextTooltipRow(props: { name: JSX.Element; value: JSX.Element }) {
return (
<div class="flex min-w-0 items-center gap-4">
<span class="shrink-0 text-v2-text-text-muted">{props.name}</span>
<span class="ml-auto min-w-0 truncate text-right text-v2-text-text-base">{props.value}</span>
</div>
)
}
function openSessionContext(args: {
@ -23,7 +35,7 @@ function openSessionContext(args: {
layout: ReturnType<typeof useLayout>
tabs: ReturnType<ReturnType<typeof useLayout>["tabs"]>
}) {
if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open()
args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button")
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
void args.tabs.open("context")
args.tabs.setActive("context")
@ -39,12 +51,14 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const { params, tabs, view } = useSessionLayout()
const variant = createMemo(() => props.variant ?? "button")
const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
})
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
const usd = createMemo(
() =>
@ -54,21 +68,30 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}),
)
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
const context = createMemo(() => metrics().context)
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
const tokens = createMemo(() => info()?.tokens)
const cost = createMemo(() => {
return usd().format(metrics().totalCost)
return usd().format(info()?.cost ?? 0)
})
const contextVisible = createMemo(() => view().reviewPanel.opened() && tabState.activeTab() === "context")
const hasOtherTabs = createMemo(() =>
tabs()
.all()
.some((tab) => tab !== "context" && tab !== "review"),
)
const openContext = () => {
if (!params.id) return
if (tabState.activeTab() === "context") {
const sessionView = view()
if (contextVisible()) {
tabs().close("context")
if (sessionView.reviewPanel.source() === "context-button" && !hasOtherTabs()) sessionView.reviewPanel.close()
return
}
openSessionContext({
view: view(),
view: sessionView,
layout,
tabs: tabs(),
})
@ -76,38 +99,54 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const circle = () => (
<div class="flex items-center justify-center">
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.usage ?? 0} />
<ProgressCircle
size={16}
strokeWidth={2}
percentage={context()?.usage ?? 0}
style={
variant() === "indicator"
? {
"--progress-circle-background": "var(--v2-background-bg-layer-04, var(--border-weak-base))",
"--progress-circle-background-overlay": "var(--v2-overlay-simple-overlay-pressed, transparent)",
"--progress-circle-progress": "var(--v2-icon-icon-base, var(--icon-base))",
}
: undefined
}
/>
</div>
)
const circleV2 = () => (
<div class="flex items-center justify-center">
<ProgressCircleV2 percentage={context()?.usage ?? 0} />
</div>
)
const tooltipValue = () => (
<div>
<Show when={context()}>
{(ctx) => (
<>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().total.toLocaleString(language.intl())}</span>
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
</div>
</>
)}
</Show>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{cost()}</span>
<span class="text-text-invert-base">{language.t("context.usage.cost")}</span>
</div>
<div class="flex w-[120px] flex-col gap-2">
<ContextTooltipRow name={language.t("context.usage.cost")} value={cost()} />
<ContextTooltipRow name={language.t("context.usage.usage")} value={`${context()?.usage ?? 0}%`} />
<ContextTooltipRow
name={language.t("context.usage.tokens")}
value={getSessionTokenTotal(tokens())?.toLocaleString(language.intl()) ?? "0"}
/>
</div>
)
return (
<Show when={params.id}>
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
<TooltipV2 value={tooltipValue()} placement={props.placement ?? "top"} shift={-8}>
<Switch>
<Match when={variant() === "indicator"}>{circle()}</Match>
<Match when={buttonAppearance() === "v2"}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
icon={circleV2()}
onClick={openContext}
aria-label={language.t("context.usage.view")}
/>
</Match>
<Match when={true}>
<Button
type="button"
@ -120,7 +159,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
</Button>
</Match>
</Switch>
</Tooltip>
</TooltipV2>
</Show>
)
}

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { getSessionContextMetrics } from "./session-context-metrics"
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
const assistant = (
id: string,
@ -37,8 +37,8 @@ const user = (id: string) => {
} as unknown as Message
}
describe("getSessionContextMetrics", () => {
test("computes totals and usage from latest assistant with tokens", () => {
describe("getSessionContext", () => {
test("computes usage from latest assistant with tokens", () => {
const messages = [
user("u1"),
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
@ -57,45 +57,52 @@ describe("getSessionContextMetrics", () => {
},
]
const metrics = getSessionContextMetrics(messages, providers)
const ctx = getSessionContext(messages, providers)
expect(metrics.totalCost).toBe(1.75)
expect(metrics.context?.message.id).toBe("a2")
expect(metrics.context?.total).toBe(500)
expect(metrics.context?.usage).toBe(50)
expect(metrics.context?.providerLabel).toBe("OpenAI")
expect(metrics.context?.modelLabel).toBe("GPT-4.1")
expect(ctx?.message.id).toBe("a2")
expect(ctx?.usage).toBe(50)
expect(ctx?.providerLabel).toBe("OpenAI")
expect(ctx?.modelLabel).toBe("GPT-4.1")
})
test("preserves fallback labels and null usage when model metadata is missing", () => {
const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
const providers = [{ id: "p-1", models: {} }]
const metrics = getSessionContextMetrics(messages, providers)
const ctx = getSessionContext(messages, providers)
expect(metrics.context?.providerLabel).toBe("p-1")
expect(metrics.context?.modelLabel).toBe("m-1")
expect(metrics.context?.limit).toBeUndefined()
expect(metrics.context?.usage).toBeNull()
expect(ctx?.providerLabel).toBe("p-1")
expect(ctx?.modelLabel).toBe("m-1")
expect(ctx?.limit).toBeUndefined()
expect(ctx?.usage).toBeNull()
})
test("recomputes when message array is mutated in place", () => {
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
const providers = [{ id: "openai", models: {} }]
const one = getSessionContextMetrics(messages, providers)
const one = getSessionContext(messages, providers)
messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75))
const two = getSessionContextMetrics(messages, providers)
const two = getSessionContext(messages, providers)
expect(one.context?.message.id).toBe("a1")
expect(two.context?.message.id).toBe("a2")
expect(two.totalCost).toBe(1)
expect(one?.message.id).toBe("a1")
expect(two?.message.id).toBe("a2")
})
test("returns empty metrics when inputs are undefined", () => {
const metrics = getSessionContextMetrics(undefined, undefined)
test("returns undefined when inputs are undefined", () => {
const ctx = getSessionContext(undefined, undefined)
expect(metrics.totalCost).toBe(0)
expect(metrics.context).toBeUndefined()
expect(ctx).toBeUndefined()
})
test("computes stored session token totals", () => {
expect(
getSessionTokenTotal({
input: 10,
output: 20,
reasoning: 30,
cache: { read: 40, write: 50 },
}),
).toBe(150)
})
})

View file

@ -1,4 +1,4 @@
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client"
type Provider = {
id: string
@ -21,19 +21,9 @@ type Context = {
modelLabel: string
limit: number | undefined
input: number
output: number
reasoning: number
cacheRead: number
cacheWrite: number
total: number
usage: number | null
}
type Metrics = {
totalCost: number
context: Context | undefined
}
const tokenTotal = (msg: AssistantMessage) => {
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
}
@ -47,10 +37,9 @@ const lastAssistantWithTokens = (messages: Message[]) => {
}
}
const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => {
const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => {
const message = lastAssistantWithTokens(messages)
if (!message) return { totalCost, context: undefined }
if (!message) return undefined
const provider = providers.find((item) => item.id === message.providerID)
const model = provider?.models[message.modelID]
@ -58,25 +47,22 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Metrics =>
const total = tokenTotal(message)
return {
totalCost,
context: {
message,
provider,
model,
providerLabel: provider?.name ?? message.providerID,
modelLabel: model?.name ?? message.modelID,
limit,
input: message.tokens.input,
output: message.tokens.output,
reasoning: message.tokens.reasoning,
cacheRead: message.tokens.cache.read,
cacheWrite: message.tokens.cache.write,
total,
usage: limit ? Math.round((total / limit) * 100) : null,
},
message,
provider,
model,
providerLabel: provider?.name ?? message.providerID,
modelLabel: model?.name ?? message.modelID,
limit,
input: message.tokens.input,
usage: limit ? Math.round((total / limit) * 100) : null,
}
}
export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) {
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
return build(messages, providers)
}
export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) {
if (!tokens) return undefined
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
}

View file

@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
import { useSessionLayout } from "@/pages/session/session-layout"
import { getSessionContextMetrics } from "./session-context-metrics"
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format"
@ -134,12 +134,12 @@ export function SessionContextTab() {
}),
)
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
const ctx = createMemo(() => metrics().context)
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
const tokens = createMemo(() => info()?.tokens)
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
const cost = createMemo(() => {
return usd().format(metrics().totalCost)
return usd().format(info()?.cost ?? 0)
})
const counts = createMemo(() => {
@ -204,14 +204,14 @@ export function SessionContextTab() {
{ label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel },
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
{ label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) },
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) },
{ label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) },
{ label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) },
{
label: "context.stats.cacheTokens",
value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`,
},
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },

View file

@ -541,6 +541,7 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
</Show>
<Show when={props.state.reviewVisible}>
<TooltipV2
class="shrink-0"
placement="bottom"
value={
<>

View file

@ -21,7 +21,7 @@
position: relative;
}
[data-titlebar-tab-slot]:not(:first-child)::before {
[data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before {
content: "";
position: absolute;
top: 8px;
@ -32,6 +32,10 @@
background: var(--v2-background-bg-layer-02);
}
[data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before {
display: none;
}
[data-titlebar-tab] [data-slot="tab-close"]::before {
content: "";
position: absolute;

View file

@ -5,19 +5,24 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { projectForSession } from "@/pages/layout/helpers"
import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { showToast } from "@/utils/toast"
import type { Session } from "@opencode-ai/sdk/v2"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import { TabPreviewPopover } from "./titlebar-tab-popover"
import "./titlebar-tab-nav.css"
// MouseEvent.button uses 1 for the middle/wheel button.
const MIDDLE_MOUSE_BUTTON = 1
export function TabNavItem(props: {
ref?: Ref<HTMLDivElement>
href: string
server: ServerConnection.Key
session: () => Session | undefined
fallbackTitle?: string
onTitleChange?: (title: string) => void
onTitleChangeFailed?: (title: string) => void
onClose: () => void
@ -53,6 +58,33 @@ export function TabNavItem(props: {
if (!session) return
return projectForSession(session, serverCtx()?.projects.list() ?? [])
})
const title = createMemo(() => props.session()?.title ?? props.fallbackTitle)
const projectName = createMemo(() => {
const session = props.session()
if (!session) return
return displayName(project() ?? { worktree: session.directory })
})
const previewPath = createMemo(() => {
const session = props.session()
if (!session) return
const home = serverCtx()?.sync.data.path.home
return home ? session.directory.replace(home, "~") : session.directory
})
const branch = createMemo(() => {
const session = props.session()
if (!session) return
return serverCtx()?.sync.child(session.directory, { bootstrap: false })[0].vcs?.branch
})
// Only label the server when multiple servers are connected.
const serverLabel = createMemo(() => {
if (global.servers.list().length <= 1) return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
return conn ? serverName(conn) : undefined
})
const [popoverOpen, setPopoverOpen] = createSignal(false)
const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session()
const measureTitleOverflow = () => {
if (!titleEl || editing()) {
@ -71,7 +103,7 @@ export function TabNavItem(props: {
}
createEffect(() => {
props.session()?.title
title()
props.forceTruncate
editing()
scheduleTitleOverflow()
@ -130,9 +162,9 @@ export function TabNavItem(props: {
createEffect(() => {
if (editing()) return
if (!titleEl) return
const title = props.session()?.title
if (title === undefined) return
titleEl.textContent = title
const value = title()
if (value === undefined) return
titleEl.textContent = value
})
const openRename = (event: MouseEvent) => {
@ -168,7 +200,7 @@ export function TabNavItem(props: {
onCleanup(cleanup)
})
return (
const tab = (
<div
ref={(el) => {
tabRoot = el
@ -184,30 +216,35 @@ export function TabNavItem(props: {
data-dragging={props.dragging}
data-pressed={props.pressed}
onMouseDown={(event) => {
if (event.button !== 1) return
if (event.button !== MIDDLE_MOUSE_BUTTON) return
event.preventDefault()
event.stopPropagation()
}}
onAuxClick={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return
closeTab(event)
}}
>
<Show when={props.session()}>
{(session) => {
return (
<a
data-slot="tab-link"
data-titlebar-tab-link
href={props.href}
draggable={false}
onDragStart={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<Show when={title() !== undefined}>
<a
data-slot="tab-link"
data-titlebar-tab-link
href={props.href}
draggable={false}
onDragStart={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<Show when={props.session()}>
{(session) => (
<span data-slot="project-avatar-slot">
<SessionTabAvatar
project={project()}
@ -216,45 +253,45 @@ export function TabNavItem(props: {
activeServer={props.activeServer}
/>
</span>
<span
ref={(el) => {
titleEl = el
titleEl.textContent = session().title
}}
data-slot="tab-title"
data-titlebar-tab-title
class="min-w-0 flex-1 outline-none leading-4"
classList={{
"overflow-hidden text-clip whitespace-nowrap": !editing(),
"select-text": editing(),
}}
contenteditable={editing() ? true : undefined}
onDblClick={openRename}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void closeRename(true)
return
}
if (event.key !== "Escape") return
event.preventDefault()
titleEl.textContent = session().title
void closeRename(false)
}}
onBlur={() => void closeRename(true)}
onPointerDown={(event) => {
if (!editing()) return
event.stopPropagation()
}}
onClick={(event) => {
if (!editing()) return
event.preventDefault()
}}
/>
</a>
)
}}
)}
</Show>
<span
ref={(el) => {
titleEl = el
titleEl.textContent = title() ?? ""
}}
data-slot="tab-title"
data-titlebar-tab-title
class="min-w-0 flex-1 outline-none leading-4"
classList={{
"overflow-hidden text-clip whitespace-nowrap": !editing(),
"select-text": editing(),
}}
contenteditable={editing() ? true : undefined}
onDblClick={openRename}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void closeRename(true)
return
}
if (event.key !== "Escape") return
event.preventDefault()
titleEl.textContent = props.session()?.title ?? ""
void closeRename(false)
}}
onBlur={() => void closeRename(true)}
onPointerDown={(event) => {
if (!editing()) return
event.stopPropagation()
}}
onClick={(event) => {
if (!editing()) return
event.preventDefault()
}}
/>
</a>
</Show>
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
@ -272,6 +309,24 @@ export function TabNavItem(props: {
</div>
</div>
)
return (
<TabPreviewPopover
trigger={tab}
open={popoverOpen() && !previewBlocked()}
onOpenChange={(value) => {
if (value && previewBlocked()) return
setPopoverOpen(value)
}}
data={{
projectName: projectName(),
title: props.session()?.title,
path: previewPath(),
branch: branch(),
serverName: serverLabel(),
}}
/>
)
}
export function DraftTabItem(props: {
@ -302,7 +357,12 @@ export function DraftTabItem(props: {
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
classList={{ invisible: props.hidden }}
onMouseDown={(event) => {
if (event.button !== 1) return
if (event.button !== MIDDLE_MOUSE_BUTTON) return
event.preventDefault()
event.stopPropagation()
}}
onAuxClick={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return
closeTab(event)
}}
>

View file

@ -0,0 +1,133 @@
[data-component="session-tab-popover-trigger"] {
display: flex;
width: 100%;
min-width: 0;
}
[data-component="session-tab-popover"] {
z-index: 50;
box-sizing: border-box;
width: 256px;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
background: var(--v2-background-bg-base);
border-radius: 6px;
box-shadow: var(--v2-elevation-floating);
color: var(--v2-text-text-base);
font-variant-numeric: tabular-nums;
user-select: none;
pointer-events: none;
transform-origin: var(--kb-hovercard-content-transform-origin);
/* Entrance: fade in while scaling up from 0.96 to 1.0. */
animation: sessionTabPopoverIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
&:focus-visible,
&:focus-within {
outline: none;
}
/* Exit: subtle fade + scale down, using the same ease-out curve as entry. */
&[data-closed] {
animation: sessionTabPopoverOut 100ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
[data-slot="header"] {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
[data-slot="project"] {
font-weight: 440;
font-size: 13px;
line-height: 1;
letter-spacing: -0.04px;
color: var(--v2-text-text-muted);
}
[data-slot="title"] {
font-weight: 530;
font-size: 13px;
line-height: 1.2;
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
overflow-wrap: anywhere;
}
[data-slot="row"] {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
}
[data-slot="icon"] {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 16px;
height: 16px;
color: var(--v2-icon-icon-muted);
}
[data-slot="detail"] {
flex: 1 0 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 440;
font-size: 13px;
line-height: 1;
letter-spacing: -0.04px;
color: var(--v2-text-text-muted);
}
[data-slot="server"] {
font-weight: 440;
font-size: 13px;
line-height: 1;
letter-spacing: -0.04px;
color: var(--v2-text-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@keyframes sessionTabPopoverIn {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes sessionTabPopoverOut {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.96);
}
}
@media (prefers-reduced-motion: reduce) {
[data-component="session-tab-popover"],
[data-component="session-tab-popover"][data-closed] {
animation: none;
}
}

View file

@ -0,0 +1,99 @@
import { HoverCard as Kobalte } from "@kobalte/core/hover-card"
import { Show, type JSXElement } from "solid-js"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import "./titlebar-tab-popover.css"
// Initial hover delay before the preview appears, per design.
const OPEN_DELAY = 200
// Mouse-out delay: begin closing immediately (a brief exit animation plays).
const CLOSE_DELAY = 0
// After a preview closes, hovering a neighbouring tab within this window skips
// the open delay — mirrors the tooltip's skipDelayDuration so moving across
// tabs doesn't re-wait the full delay each time.
const SKIP_WINDOW = 300
let lastClosedAt = 0
export interface TabPreviewData {
projectName?: string
title?: string
path?: string
branch?: string
serverName?: string
}
export function TabPreviewPopover(props: {
trigger: JSXElement
open: boolean
onOpenChange: (open: boolean) => void
data: TabPreviewData
}) {
let triggerEl: HTMLDivElement | undefined
// Kobalte reads openDelay lazily when the pointer enters the trigger, so this
// resolves the skip window per-hover.
const resolveOpenDelay = () => (Date.now() - lastClosedAt < SKIP_WINDOW ? 0 : OPEN_DELAY)
const handleOpenChange = (open: boolean) => {
if (!open) lastClosedAt = Date.now()
props.onOpenChange(open)
}
return (
<Kobalte
open={props.open}
onOpenChange={handleOpenChange}
openDelay={resolveOpenDelay()}
closeDelay={CLOSE_DELAY}
// The preview is non-interactive (pointer-events: none), so there is no
// safe area to traverse — leaving the tab hides it immediately.
ignoreSafeArea
placement="bottom-start"
gutter={6}
>
<Kobalte.Trigger ref={triggerEl} as="div" data-component="session-tab-popover-trigger" tabIndex={-1}>
{props.trigger}
</Kobalte.Trigger>
<Kobalte.Portal>
<Kobalte.Content
ref={(el) => {
// Portalled content lives outside the themed subtree, so mirror the
// active theme like the v2 tooltip does.
const theme = triggerEl?.closest("[data-theme]")?.getAttribute("data-theme")
if (theme) el.setAttribute("data-theme", theme)
}}
data-component="session-tab-popover"
>
<div data-slot="header">
<Show when={props.data.projectName}>
<span data-slot="project">{props.data.projectName}</span>
</Show>
<Show when={props.data.title}>
<span data-slot="title">{props.data.title}</span>
</Show>
</div>
<Show when={props.data.path}>
<div data-slot="row">
<span data-slot="icon">
<IconV2 name="folder" />
</span>
<span data-slot="detail">{props.data.path}</span>
</div>
</Show>
<Show when={props.data.branch}>
<div data-slot="row">
<span data-slot="icon">
<IconV2 name="branch" />
</span>
<span data-slot="detail">{props.data.branch}</span>
</div>
</Show>
<Show when={props.data.serverName}>
<div data-slot="server">{props.data.serverName}</div>
</Show>
</Kobalte.Content>
</Kobalte.Portal>
</Kobalte>
)
}

View file

@ -31,6 +31,7 @@ function SessionTabSlot(props: {
onClose: () => void
}) {
const tabs = useTabs()
const language = useLanguage()
const sortable = useSortable({
get id() {
return props.id
@ -50,6 +51,7 @@ function SessionTabSlot(props: {
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
)
const session = createMemo(() => cachedSession() ?? loadedSession())
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
let prefetched = false
createEffect(() => {
@ -85,8 +87,9 @@ function SessionTabSlot(props: {
ref={sortable.ref}
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
classList={{ hidden: !session() }}
classList={{ hidden: !session() && !missingSession() }}
>
<TabNavItem
ref={(el) => {
@ -95,6 +98,7 @@ function SessionTabSlot(props: {
href={tabHref(props.tab)}
server={props.tab.server}
session={session}
fallbackTitle={missingSession() ? language.t("session.tab.unknown") : undefined}
onTitleChange={(title) => {
const value = session()
const ctx = props.serverCtx()
@ -140,6 +144,7 @@ function DraftTabSlot(props: {
ref={sortable.ref}
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
>
<DraftTabItem

View file

@ -229,7 +229,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
}}
style={{
"min-height": minHeight(),
"padding-left": mac() && !mobile() ? `${84 / zoom()}px` : 0,
// Keep native macOS traffic lights clear even when the desktop window is narrow.
"padding-left": mac() ? `${84 / zoom()}px` : 0,
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
"max-width": electronWindows()
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
@ -571,7 +572,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
placement="bottom"
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
openDelay={2000}
openDelay={800}
>
<Button
variant="ghost"
@ -601,7 +602,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
>
<Show when={hasProjects() && nav()}>
<div class="flex items-center gap-0 transition-transform">
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={800}>
<Button
variant="ghost"
icon="chevron-left"
@ -611,7 +612,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={800}>
<Button
variant="ghost"
icon="chevron-right"

View file

@ -16,6 +16,7 @@ describe("bootstrapDirectory", () => {
status: "loading",
agent: [],
command: [],
reference: [],
project: "",
projectMeta: undefined,
icon: undefined,
@ -35,6 +36,7 @@ describe("bootstrapDirectory", () => {
question: {},
mcp_ready: true,
mcp: {},
mcp_resource: {},
lsp_ready: true,
lsp: [],
vcs: undefined,
@ -67,6 +69,7 @@ describe("bootstrapDirectory", () => {
},
permission: { list: async () => ({ data: [] }) },
question: { list: async () => ({ data: [] }) },
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
mcp: {
status: async () => {
mcpReads.push("status")

View file

@ -6,6 +6,7 @@ import type {
Project,
ProviderAuthResponse,
QuestionRequest,
ReferenceInfo,
Session,
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast"
@ -18,7 +19,7 @@ import type { ServerSession } from "../server-session"
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery } from "../server-sync"
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
@ -195,6 +196,13 @@ export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk:
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
})
export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
queryOptions<ReferenceInfo[]>({
queryKey: [scope, directory, "references"] as const,
queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []),
placeholderData: [],
})
export async function bootstrapDirectory(input: {
directory: string
scope: ServerScope
@ -277,6 +285,7 @@ export async function bootstrapDirectory(input: {
}),
),
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)),
() =>
retry(() =>
input.sdk.permission.list().then((x) => {
@ -339,6 +348,7 @@ export async function bootstrapDirectory(input: {
),
() => Promise.resolve(input.loadSessions(input.directory)),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))),
() =>
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
const project = getFilename(input.directory)

View file

@ -34,7 +34,9 @@ const queryOptionsApi = {
}),
agents: (directory: string) => ({ queryKey: [directory, "agents"], queryFn: async () => [] }),
mcp: (directory: string) => ({ queryKey: [directory, "mcp"], queryFn: async () => ({}) }),
mcpResources: (directory: string) => ({ queryKey: [directory, "mcpResources"], queryFn: async () => ({}) }),
lsp: (directory: string) => ({ queryKey: [directory, "lsp"], queryFn: async () => [] }),
references: (directory: string) => ({ queryKey: [directory, "references"], queryFn: async () => [] }),
sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }),
} as unknown as QueryOptionsApi
@ -197,14 +199,18 @@ describe("createChildStoreManager", () => {
try {
if (!manager) throw new Error("manager required")
const [store, setStore] = manager.child("/project", { bootstrap: false })
expect(querySingles.length - offset).toBe(4)
expect(querySingles.length - offset).toBe(6)
const query = querySingles[offset + 1]
const resourceQuery = querySingles[offset + 2]
if (!query) throw new Error("query required")
if (!resourceQuery) throw new Error("resource query required")
expect(query().enabled).toBe(false)
expect(resourceQuery().enabled).toBe(false)
setStore("status", "complete")
manager.child("/project", { bootstrap: false, mcp: true })
expect(query().enabled).toBe(true)
expect(resourceQuery().enabled).toBe(true)
expect(store.mcp).toEqual({ demo: { status: "disabled" } })
expect(mcpLoads).toEqual(["/project"])

View file

@ -185,8 +185,10 @@ export function createChildStoreManager(input: {
const pathQuery = useQuery(() => input.queryOptions.path(key))
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
const lspQuery = useQuery(() => input.queryOptions.lsp(key))
const providerQuery = useQuery(() => input.queryOptions.providers(key))
const referenceQuery = useQuery(() => input.queryOptions.references(key))
const child = createStore<State>({
project: "",
@ -210,6 +212,9 @@ export function createChildStoreManager(input: {
status: "loading" as const,
agent: [],
command: [],
get reference() {
return referenceQuery.isLoading ? [] : (referenceQuery.data ?? [])
},
session: [],
sessionTotal: 0,
session_status: {},
@ -227,6 +232,9 @@ export function createChildStoreManager(input: {
get mcp() {
return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {})
},
get mcp_resource() {
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
},
get lsp_ready() {
return !lspQuery.isLoading
},

View file

@ -112,6 +112,7 @@ export function applyDirectoryEvent(input: {
push: (directory: string) => void
directory: string
loadLsp: () => void
loadReferences?: () => void
vcsCache?: VcsCache
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
retainedLimit?: number
@ -404,5 +405,9 @@ export function applyDirectoryEvent(input: {
input.loadLsp()
break
}
case "reference.updated": {
input.loadReferences?.()
break
}
}
}

View file

@ -3,12 +3,14 @@ import type {
Command,
Config,
LspStatus,
McpResource,
McpStatus,
Message,
Part,
Path,
PermissionRequest,
QuestionRequest,
ReferenceInfo,
Session,
SessionStatus,
SnapshotFileDiff,
@ -34,6 +36,7 @@ export type State = {
status: "loading" | "partial" | "complete"
agent: Agent[]
command: Command[]
reference: ReferenceInfo[]
project: string
projectMeta: ProjectMeta | undefined
icon: string | undefined
@ -63,6 +66,9 @@ export type State = {
mcp: {
[name: string]: McpStatus
}
mcp_resource: {
[key: string]: McpResource
}
lsp_ready: boolean
lsp: LspStatus[]
vcs: VcsInfo | undefined

View file

@ -28,6 +28,7 @@ const DEFAULT_SIDEBAR_WIDTH = 344
const DEFAULT_FILE_TREE_WIDTH = 200
const DEFAULT_SESSION_WIDTH = 600
const DEFAULT_TERMINAL_HEIGHT = 280
const DEFAULT_REVIEW_PANEL_OPENED = false
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
export function getAvatarColors(key?: string) {
@ -77,6 +78,7 @@ export type LocalProject = Partial<Project> & { worktree: string; expanded: bool
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
export type ReviewDiffStyle = "unified" | "split"
export type ReviewPanelSource = "context-button" | "other"
export type LayoutRoute =
| { type: "home" }
@ -210,7 +212,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (!isRecord(review)) return review
if (typeof review.panelOpened === "boolean") return review
const opened = isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : true
const opened =
isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : DEFAULT_REVIEW_PANEL_OPENED
return {
...review,
panelOpened: opened,
@ -279,7 +282,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
review: {
diffStyle: "split" as ReviewDiffStyle,
panelOpened: true,
panelOpened: DEFAULT_REVIEW_PANEL_OPENED,
},
fileTree: {
opened: false,
@ -302,6 +305,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
}),
)
const [ephemeral, setEphemeral] = createStore({
reviewPanelSource: "other" as ReviewPanelSource,
})
const MAX_SESSION_KEYS = 50
const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
@ -662,7 +668,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
setDiffStyle(diffStyle: ReviewDiffStyle) {
if (!store.review) {
setStore("review", { diffStyle, panelOpened: true })
setStore("review", { diffStyle, panelOpened: DEFAULT_REVIEW_PANEL_OPENED })
return
}
setStore("review", "diffStyle", diffStyle)
@ -777,7 +783,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const key = createSessionKeyReader(sessionKey, ensureKey)
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other"))
function setTerminalOpened(next: boolean) {
const current = store.terminal
@ -791,16 +798,26 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("terminal", "opened", next)
}
function setReviewPanelOpened(next: boolean) {
function setReviewPanelOpened(next: boolean, source: ReviewPanelSource) {
const nextSource = next ? source : "other"
const current = store.review
if (!current) {
setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next })
batch(() => {
setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next })
setEphemeral("reviewPanelSource", nextSource)
})
return
}
const value = current.panelOpened ?? true
if (value === next) return
setStore("review", "panelOpened", next)
const value = current.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED
if (value === next) {
if (ephemeral.reviewPanelSource !== nextSource) setEphemeral("reviewPanelSource", nextSource)
return
}
batch(() => {
setStore("review", "panelOpened", next)
setEphemeral("reviewPanelSource", nextSource)
})
}
return {
@ -836,14 +853,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
reviewPanel: {
opened: reviewPanelOpened,
open() {
setReviewPanelOpened(true)
source: reviewPanelSource,
open(source: ReviewPanelSource = "other") {
setReviewPanelOpened(true, source)
},
close() {
setReviewPanelOpened(false)
setReviewPanelOpened(false, "other")
},
toggle() {
setReviewPanelOpened(!reviewPanelOpened())
setReviewPanelOpened(!reviewPanelOpened(), "other")
},
},
review: {

View file

@ -1,7 +1,7 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo } from "solid-js"
import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
import { useModels } from "@/context/models"
import { useProviders } from "@/hooks/use-providers"
@ -80,6 +80,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [store, setStore] = createStore<{
current?: string
draft?: State
promoting?: State
last?: {
type: "agent" | "model" | "variant"
agent?: string
@ -123,7 +124,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const scope = createMemo<State | undefined>(() => {
const session = id()
if (!session) return store.draft
if (!session) return store.draft ?? store.promoting
return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
})
@ -136,11 +137,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!next) return
if (saved.session[session] !== undefined) {
handoff.delete(key)
setStore("promoting", undefined)
return
}
setSaved("session", session, clone(next))
handoff.delete(key)
setStore("promoting", undefined)
})
const configuredModel = () => {
@ -294,19 +297,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model.set({ providerID: entry.provider.id, modelID: entry.id })
},
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => {
setStore("last", {
type: "model",
agent: agent.current()?.name,
model: item ?? null,
variant: selected(),
})
write({ model: item })
if (!item) return
models.setVisibility(item, true)
if (!options?.recent) return
models.recent.push(item)
})
startTransition(() =>
batch(() => {
setStore("last", {
type: "model",
agent: agent.current()?.name,
model: item ?? null,
variant: selected(),
})
write({ model: item })
if (!item) return
models.setVisibility(item, true)
if (!options?.recent) return
models.recent.push(item)
}),
)
},
visible(item: ModelKey) {
return models.visible(item)
@ -335,19 +340,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return Object.keys(item.variants)
},
set(value: string | undefined) {
batch(() => {
const model = current()
setStore("last", {
type: "variant",
agent: agent.current()?.name,
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
variant: value ?? null,
})
write({ variant: value ?? null })
if (model) {
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
}
})
startTransition(() =>
batch(() => {
const model = current()
setStore("last", {
type: "variant",
agent: agent.current()?.name,
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
variant: value ?? null,
})
write({ variant: value ?? null })
if (model) {
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
}
}),
)
},
cycle() {
const items = this.list()
@ -369,19 +376,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
agent,
session: {
reset() {
setStore("draft", undefined)
setStore({ draft: undefined, promoting: undefined })
},
promote(dir: string, session: string) {
const next = clone(snapshot())
if (!next) return
const key = handoffKey(serverSDK().scope, dir, session)
handoff.set(key, next)
if (dir === sdk().directory) {
setSaved("session", session, next)
setStore("draft", undefined)
return
}
handoff.set(handoffKey(serverSDK().scope, dir, session), next)
setStore("promoting", next)
setStore("draft", undefined)
},
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {

View file

@ -64,6 +64,9 @@ type PlatformBase = {
/** Storage mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage
/** Stable platform window identity for window-scoped persistence */
windowID?: string
/** Application-global desktop updater */
updater?: UpdaterPlatform

View file

@ -12,6 +12,7 @@ import { useTabs, type Tab } from "./tabs"
import { ServerConnection } from "./server"
import { requireServerKey } from "@/utils/session-route"
import { useSettings } from "./settings"
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
interface PartBase {
content: string
@ -27,6 +28,10 @@ export interface FileAttachmentPart extends PartBase {
type: "file"
path: string
selection?: FileSelection
mime?: string
filename?: string
url?: string
source?: FilePartSource
}
export interface AgentPart extends PartBase {
@ -73,7 +78,13 @@ function isPartEqual(partA: ContentPart, partB: ContentPart) {
case "text":
return partB.type === "text" && partA.content === partB.content
case "file":
return partB.type === "file" && partA.path === partB.path && isSelectionEqual(partA.selection, partB.selection)
return (
partB.type === "file" &&
partA.path === partB.path &&
partA.mime === partB.mime &&
partA.filename === partB.filename &&
isSelectionEqual(partA.selection, partB.selection)
)
case "agent":
return partB.type === "agent" && partA.name === partB.name
case "image":

View file

@ -1,4 +1,11 @@
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse } from "@opencode-ai/sdk/v2/client"
import type {
Config,
McpResource,
OpencodeClient,
Path,
Project,
ProviderAuthResponse,
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
@ -15,6 +22,7 @@ import {
loadPathQuery,
loadProjectsQuery,
loadProvidersQuery,
loadReferencesQuery,
} from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
@ -56,6 +64,13 @@ export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: Opencod
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
})
export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
queryOptions<Record<string, McpResource>>({
queryKey: [scope, directory, "mcpResources"] as const,
queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}),
placeholderData: {},
})
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
queryOptions({
queryKey: [scope, directory, "lsp"] as const,
@ -75,7 +90,9 @@ function makeQueryOptionsApi(
path: (directory: PathKey | null) =>
loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)),
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)),
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
}
@ -396,6 +413,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
loadLsp: () => {
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
},
loadReferences: () => {
void queryClient.fetchQuery(queryOptionsApi.references(key))
},
})
})
@ -484,6 +504,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
},
refresh: async () => {
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
await queryClient.refetchQueries(queryOptionsApi.mcpResources(key))
},
})
},

View file

@ -51,7 +51,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const fallback = server.key
const [store, setStore, _, ready] = persisted(
{
...Persist.global("tabs"),
...Persist.window("tabs"),
migrate: (value: unknown) => {
if (!Array.isArray(value)) return value
return value.map((tab) => {
@ -62,7 +62,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
},
createStore<Tab[]>([]),
)
const [recent, setRecent, , recentReady] = persisted(Persist.global("tabs.recent"), createStore<RecentTab>({}))
const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
const params = useParams()
const navigate = useNavigate()
@ -138,12 +138,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const next = { type: "session" as const, ...tab }
const existing = store.find((item) => tabKey(item) === tabKey(next))
if (existing) return existing
setStore(
produce((tabs) => {
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
tabs.push(next)
}),
)
void startTransition(() => {
setStore(
produce((tabs) => {
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
tabs.push(next)
}),
)
})
return next
},
reorder(keys: string[]) {

View file

@ -0,0 +1,13 @@
import { describe, expect, test } from "bun:test"
import { DESKTOP_MENU } from "./desktop-menu"
describe("desktop menu", () => {
test("exports logs through the desktop command registry", () => {
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
(item) => item.type === "item" && item.label === "Export Logs...",
)
expect(items).toHaveLength(2)
expect(items.every((item) => item.type === "item" && item.command === "logs.export" && !item.action)).toBe(true)
})
})

View file

@ -388,6 +388,20 @@ export const dict = {
"wsl.onboarding.chooseDistroFirst": "Choose a distro first.",
"wsl.onboarding.loadFailed": "Failed to load WSL state.",
"wsl.onboarding.loading": "Loading...",
"wsl.onboarding.installedDistros": "Installed distros",
"wsl.onboarding.checkAgain": "Check again",
"wsl.onboarding.distroStatus.ready": "Ready",
"wsl.onboarding.distroStatus.checking": "Checking...",
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode not installed",
"wsl.onboarding.distroStatus.missingTools": "Missing bash, curl",
"wsl.onboarding.distroStatus.unsupported": "Unsupported · Use WSL 2",
"wsl.onboarding.needAnotherDistro": "Need another distro?",
"wsl.onboarding.needAnotherDistroHint": "Install a Linux distribution from the WSL catalog",
"wsl.onboarding.wslNotInstalled.title": "WSL not installed",
"wsl.onboarding.wslNotInstalled.description":
"WSL (Windows Subsystem for Linux) is required before OpenCode can add a WSL server",
"wsl.onboarding.wslUnavailable.title": "WSL unavailable",
"wsl.onboarding.wslUnavailable.description": "OpenCode could not verify WSL on this machine.",
"wsl.onboarding.installWsl": "Install WSL",
"wsl.onboarding.windowsRestartRequired": "Restart Windows to finish installing WSL, then reopen OpenCode.",
"wsl.onboarding.next": "Next",
@ -397,6 +411,7 @@ export const dict = {
"wsl.onboarding.install": "Install",
"wsl.onboarding.installing": "Installing...",
"wsl.onboarding.installDistro": "Install distro",
"wsl.onboarding.searchDistros": "Search distros",
"wsl.onboarding.wsl2Required": "WSL 2 is required.",
"wsl.onboarding.toolsRequired": "This distro needs bash and curl.",
"wsl.onboarding.openTerminal": "Open terminal",
@ -604,7 +619,12 @@ export const dict = {
"session.tab.session": "Session",
"session.tab.review": "Review",
"session.tab.context": "Context",
"session.tab.unknown": "Unknown Session",
"session.panel.reviewAndFiles": "Review and files",
"session.error.notFound": "This session cannot be found",
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
"session.error.notFound.closeTab": "Close Tab",
"session.error.serverConnection": "Can't connect to this server",
"session.review.filesChanged": "{{count}} Files Changed",
"session.review.change.one": "Change",
"session.review.change.other": "Changes",
@ -637,6 +657,10 @@ export const dict = {
"session.todo.expand": "Expand",
"session.todo.progress": "{{done}} of {{total}} todos completed",
"session.question.progress": "{{current}} of {{total}} questions",
"session.question.minimize": "Minimize question",
"session.question.restore": "Restore question",
"session.question.pending.one": "{{count}} pending question",
"session.question.pending.other": "{{count}} pending questions",
"session.followupDock.summary.one": "{{count}} queued message",
"session.followupDock.summary.other": "{{count}} queued messages",
"session.followupDock.sendNow": "Send now",

View file

@ -108,6 +108,45 @@
}
}
.home-session-group-header::before {
content: "";
position: absolute;
top: -12px;
left: 0;
width: 100%;
height: 12px;
background: var(--v2-background-bg-base);
}
.home-session-group-header::after {
content: "";
position: absolute;
top: 100%;
left: 0;
width: 100%;
height: 16px;
pointer-events: none;
background: linear-gradient(
180deg,
var(--v2-background-bg-base) 0%,
color-mix(in srgb, var(--v2-background-bg-base) 92.0456%, transparent) 7.93%,
color-mix(in srgb, var(--v2-background-bg-base) 84.9947%, transparent) 14.14%,
color-mix(in srgb, var(--v2-background-bg-base) 78.6813%, transparent) 19%,
color-mix(in srgb, var(--v2-background-bg-base) 72.9394%, transparent) 22.85%,
color-mix(in srgb, var(--v2-background-bg-base) 67.6028%, transparent) 26.05%,
color-mix(in srgb, var(--v2-background-bg-base) 62.5055%, transparent) 28.95%,
color-mix(in srgb, var(--v2-background-bg-base) 57.4815%, transparent) 31.91%,
color-mix(in srgb, var(--v2-background-bg-base) 52.3647%, transparent) 35.27%,
color-mix(in srgb, var(--v2-background-bg-base) 46.989%, transparent) 39.4%,
color-mix(in srgb, var(--v2-background-bg-base) 41.1884%, transparent) 44.65%,
color-mix(in srgb, var(--v2-background-bg-base) 34.7969%, transparent) 51.36%,
color-mix(in srgb, var(--v2-background-bg-base) 27.6484%, transparent) 59.9%,
color-mix(in srgb, var(--v2-background-bg-base) 19.5767%, transparent) 70.62%,
color-mix(in srgb, var(--v2-background-bg-base) 10.416%, transparent) 83.87%,
transparent 100%
);
}
[data-slot="titlebar-update-loader"] {
display: block;
flex-shrink: 0;
@ -132,4 +171,122 @@
transform: rotate(360deg);
}
}
@keyframes home-projects-fade-top {
from {
visibility: hidden;
}
to {
visibility: visible;
}
}
@keyframes home-projects-fade-bottom {
from {
visibility: visible;
}
to {
visibility: hidden;
}
}
[data-slot="home-projects-scroll"] {
timeline-scope: --home-projects-scroll;
}
[data-slot="model-selector-scroll"] {
timeline-scope: --model-selector-scroll;
}
[data-slot="manage-models-scroll"] {
timeline-scope: --manage-models-scroll;
}
[data-slot="home-projects-scroll"]::before,
[data-slot="home-projects-scroll"]::after,
[data-slot="model-selector-scroll"]::before,
[data-slot="model-selector-scroll"]::after,
[data-slot="manage-models-scroll"]::before {
content: "";
position: absolute;
left: 0;
right: 0;
z-index: 10;
height: 16px;
pointer-events: none;
visibility: hidden;
}
[data-slot="home-projects-scroll"]::before {
top: 0;
background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent);
}
[data-slot="model-selector-scroll"]::before {
top: 0;
background: linear-gradient(to bottom, var(--v2-background-bg-layer-01), transparent);
}
[data-slot="manage-models-scroll"]::before {
top: 0;
background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent);
}
[data-slot="home-projects-scroll"]::after {
bottom: 0;
background: linear-gradient(to top, var(--v2-background-bg-base), transparent);
}
[data-slot="model-selector-scroll"]::after {
bottom: 0;
background: linear-gradient(to top, var(--v2-background-bg-layer-01), transparent);
}
@supports (animation-timeline: --home-projects-scroll) and (timeline-scope: --home-projects-scroll) {
[data-slot="home-projects-scroll"] .scroll-view__viewport {
scroll-timeline: --home-projects-scroll y;
}
[data-slot="home-projects-scroll"]::before {
animation: home-projects-fade-top linear both;
animation-timeline: --home-projects-scroll;
animation-range: 0 0.1px;
}
[data-slot="home-projects-scroll"]::after {
animation: home-projects-fade-bottom linear both;
animation-timeline: --home-projects-scroll;
animation-range: calc(100% - 1.1px) calc(100% - 1px);
}
}
@supports (animation-timeline: --model-selector-scroll) and (timeline-scope: --model-selector-scroll) {
[data-slot="model-selector-scroll"] .scroll-view__viewport {
scroll-timeline: --model-selector-scroll y;
}
[data-slot="model-selector-scroll"]::before {
animation: home-projects-fade-top linear both;
animation-timeline: --model-selector-scroll;
animation-range: 0 0.1px;
}
[data-slot="model-selector-scroll"]::after {
animation: home-projects-fade-bottom linear both;
animation-timeline: --model-selector-scroll;
animation-range: calc(100% - 1.1px) calc(100% - 1px);
}
}
@supports (animation-timeline: --manage-models-scroll) and (timeline-scope: --manage-models-scroll) {
[data-slot="manage-models-scroll"] .settings-v2-panel {
scroll-timeline: --manage-models-scroll y;
}
[data-slot="manage-models-scroll"]::before {
animation: home-projects-fade-top linear both;
animation-timeline: --manage-models-scroll;
animation-range: 0 0.1px;
}
}
}

View file

@ -276,7 +276,10 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
}
return (
<div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
<div
class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center font-sans"
data-tauri-drag-region
>
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
<Logo class="w-58.5 opacity-12 shrink-0" />
<div class="flex flex-col items-center gap-2 text-center">

View file

@ -1,5 +1,6 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import {
type ComponentProps,
createEffect,
createMemo,
createResource,
@ -68,6 +69,9 @@ import { archiveHomeSession } from "./home-session-archive"
import { showToast } from "@/utils/toast"
const HOME_SESSION_LIMIT = 64
const HOME_SESSION_HEADER_STICKY_TOP = 12
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
const HOME_SESSION_HEADER_FADE_DISTANCE = 16
const SHOW_HOME_SESSION_ARCHIVE = false
const HOME_ROW_LAYOUT =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
@ -133,6 +137,107 @@ function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.directory)}:${record.session.id}`
}
function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
let viewport: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
let positionFrame: number | undefined
let resizeObserver: ResizeObserver | undefined
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
const [state, setState] = createStore({
titleOpacity: {} as Partial<Record<HomeSessionGroup["id"], number>>,
})
createEffect(() => {
const items = groups()
const ids = new Set(items.map((group) => group.id))
headerRefs.forEach((_, id) => {
if (!ids.has(id)) headerRefs.delete(id)
})
headerOffsets.forEach((_, id) => {
if (!ids.has(id)) headerOffsets.delete(id)
})
if (items.length === 0) {
content = undefined
bindResizeObserver()
}
queuePositionUpdate()
})
onCleanup(() => {
if (positionFrame !== undefined) cancelAnimationFrame(positionFrame)
resizeObserver?.disconnect()
})
function setViewport(el: HTMLDivElement) {
viewport = el
bindResizeObserver()
queuePositionUpdate()
}
function setContentRef(el: HTMLDivElement) {
content = el
bindResizeObserver()
queuePositionUpdate()
}
function setHeaderRef(id: HomeSessionGroup["id"], el: HTMLDivElement) {
headerRefs.set(id, el)
queuePositionUpdate()
}
function queuePositionUpdate() {
if (typeof requestAnimationFrame === "undefined") {
updatePositionCache()
return
}
if (positionFrame !== undefined) return
positionFrame = requestAnimationFrame(() => {
positionFrame = undefined
updatePositionCache()
})
}
function updatePositionCache() {
if (!viewport) return
groups().forEach((group) => {
const el = headerRefs.get(group.id)
if (!el) return
headerOffsets.set(group.id, el.offsetTop)
})
update(viewport.scrollTop)
}
function update(scrollTop: number) {
const items = groups()
items.forEach((group, index) => {
const nextOffset = items
.slice(index + 1)
.map((item) => headerOffsets.get(item.id))
.find((offset) => offset !== undefined)
const fadeEnd = HOME_SESSION_HEADER_STICKY_TOP + HOME_SESSION_HEADER_TEXT_HEIGHT
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
const opacity =
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000)
})
}
function titleOpacity(id: HomeSessionGroup["id"]) {
return state.titleOpacity[id] ?? 1
}
function bindResizeObserver() {
resizeObserver?.disconnect()
if (typeof ResizeObserver === "undefined") return
resizeObserver = new ResizeObserver(() => queuePositionUpdate())
if (viewport) resizeObserver.observe(viewport)
if (content) resizeObserver.observe(content)
}
return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
}
export function NewHome() {
const sync = useServerSync()
const layout = useLayout()
@ -223,6 +328,7 @@ export function NewHome() {
})
const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
const groups = createMemo(() => groupSessions(records(), language))
const sessionHeaderOpacity = useHomeSessionHeaderOpacity(groups)
const prefetched = new Set<string>()
createEffect(() => {
@ -435,7 +541,7 @@ export function NewHome() {
/>
<section
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12"
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12 relative"
aria-label={language.t("sidebar.project.recentSessions")}
>
<HomeSessionSearch
@ -456,7 +562,25 @@ export function NewHome() {
onClose={closeSearch}
onSelect={selectSearchSession}
/>
<ScrollView class="mt-3 -mr-3 min-h-0 flex-1">
<ScrollView
class="mt-3 -mr-3 min-h-0 flex-1 relative"
viewportRef={sessionHeaderOpacity.setViewport}
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
>
<Show when={groups().length > 0 && newSessionProject()}>
<div class="pointer-events-none absolute top-3 right-3 z-20 flex">
<ButtonV2
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={openNewSession}
>
{language.t("command.session.new")}
</ButtonV2>
</div>
</Show>
<Show
when={!sessionLoad.isLoading}
fallback={
@ -469,15 +593,19 @@ export function NewHome() {
when={groups().length > 0}
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
>
<div class="flex flex-col gap-6 pt-3 pr-3 pb-16">
<div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
<For each={groups()}>
{(group, index) => (
<div class="flex min-w-0 flex-col gap-4">
<>
<HomeSessionGroupHeader
title={group.title}
onNewSession={index() === 0 && newSessionProject() ? openNewSession : undefined}
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
elevated={index() === 0}
/>
<div class="flex min-w-0 flex-col gap-px">
<div
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
>
<For each={group.sessions}>
{(record) => (
<HomeSessionRow
@ -491,7 +619,7 @@ export function NewHome() {
)}
</For>
</div>
</div>
</>
)}
</For>
</div>
@ -540,10 +668,10 @@ function HomeProjectColumn(props: {
return (
<aside
class="mt-6 flex min-w-0 flex-col gap-4 lg:mt-14 lg:pt-[52px]"
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:mt-14 lg:pt-[52px]"
aria-label={props.language.t("home.projects")}
>
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
<Show when={global.servers.list().length === 1}>
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
@ -560,42 +688,51 @@ function HomeProjectColumn(props: {
</TooltipV2>
</Show>
</div>
<Show
when={global.servers.list().length > 1}
fallback={<HomeProjectList {...props} server={global.servers.list()[0]!} />}
>
<For each={global.servers.list()}>
{(item) => {
const key = ServerConnection.key(item)
const healthy = () => !!global.servers.health[key]?.healthy
const serverCtx = global.ensureServerCtx(item)
const collapsed = () => !!state().collapsed[key]
return (
<div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<HomeServerRow
server={item}
selected={props.selected.server === key && !props.selected.directory}
healthy={healthy()}
collapsed={collapsed()}
health={global.servers.health[key]}
controller={controller}
focusServer={props.focusServer}
chooseProject={props.chooseProject}
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])}
language={props.language}
/>
<Show when={healthy() && !collapsed()}>
<div class="mx-3 h-px bg-v2-border-border-base" />
<HomeProjectList {...props} server={item} projects={serverCtx.projects.list()} />
</Show>
</div>
)
}}
</For>
</Show>
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
<Show
when={global.servers.list().length > 1}
fallback={
<div class="pr-3">
<HomeProjectList {...props} server={global.servers.list()[0]!} />
</div>
}
>
<div class="flex min-w-0 flex-col gap-4 pr-3">
<For each={global.servers.list()}>
{(item) => {
const key = ServerConnection.key(item)
const healthy = () => !!global.servers.health[key]?.healthy
const serverCtx = global.ensureServerCtx(item)
const projects = () => serverCtx.projects.list()
const hasProjects = () => projects().length > 0
const collapsed = () => !!state().collapsed[key]
return (
<div class="flex min-w-0 flex-col gap-1">
<HomeServerRow
server={item}
selected={props.selected.server === key && !props.selected.directory}
collapsed={collapsed()}
health={global.servers.health[key]}
controller={controller}
focusServer={props.focusServer}
chooseProject={props.chooseProject}
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])}
language={props.language}
/>
<Show when={healthy() && hasProjects() && !collapsed()}>
<div class="mx-3 h-px bg-v2-border-border-base" />
<HomeProjectList {...props} server={item} projects={projects()} />
</Show>
</div>
)
}}
</For>
</div>
</Show>
</ScrollView>
<HomeUtilityNav
class="mt-4 hidden lg:flex"
class="mb-8 mt-4 hidden shrink-0 lg:flex"
openSettings={props.openSettings}
openHelp={props.openHelp}
language={props.language}
@ -635,7 +772,6 @@ function HomeUtilityNav(props: {
function HomeServerRow(props: {
server: ServerConnection.Any
selected: boolean
healthy: boolean
collapsed: boolean
health: ServerHealth | undefined
controller: ReturnType<typeof useServerManagementController>
@ -645,39 +781,46 @@ function HomeServerRow(props: {
toggleCollapsed: () => void
language: ReturnType<typeof useLanguage>
}) {
const global = useGlobal()
const [state, setState] = createStore({ menuOpen: false })
const healthy = () => !!props.health?.healthy
const canToggle = () => healthy() && global.ensureServerCtx(props.server).projects.list().length > 0
return (
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
data-selected={props.selected ? "" : undefined}
disabled={!props.healthy}
disabled={!healthy()}
onClick={() => props.focusServer(props.server)}
>
<Show when={props.healthy}>
<span
data-action="home-server-collapse"
class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
aria-label={
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
}
aria-expanded={!props.collapsed}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
props.toggleCollapsed()
}}
onPointerDown={(event) => event.preventDefault()}
>
<IconV2
name="chevron-down"
size="small"
class="transition-transform duration-150 ease-in-out"
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
/>
</span>
</Show>
<span
data-action="home-server-collapse"
class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted"
classList={{
"hover:bg-v2-overlay-simple-overlay-hover": canToggle(),
"cursor-default opacity-40": !canToggle(),
}}
aria-label={
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
}
aria-disabled={!canToggle()}
aria-expanded={canToggle() ? !props.collapsed : undefined}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
if (!canToggle()) return
props.toggleCollapsed()
}}
onPointerDown={(event) => event.preventDefault()}
>
<IconV2
name="chevron-down"
size="small"
class="transition-transform duration-150 ease-in-out"
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
/>
</span>
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
<ServerHealthIndicator health={props.health} />
</div>
@ -854,6 +997,7 @@ function HomeSessionLeading(props: {
session: Session
server: ServerConnection.Key
activeServer: boolean
revealProjectOnHover: boolean
}) {
const tabs = useTabs()
const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session))
@ -862,8 +1006,8 @@ function HomeSessionLeading(props: {
<Show when={hasOpenTab()}>
<span
aria-hidden="true"
class="pointer-events-none absolute top-1/2 h-[7px] w-[3px] -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
style={{ right: "calc(100% + 5px)" }}
class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
style={{ right: "calc(100% + 4px)" }}
/>
</Show>
<SessionTabAvatar
@ -871,6 +1015,7 @@ function HomeSessionLeading(props: {
directory={props.session.directory}
sessionId={props.session.id}
activeServer={props.activeServer}
revealProjectOnHover={props.revealProjectOnHover}
/>
</div>
)
@ -961,7 +1106,7 @@ function HomeSessionSearch(props: {
return (
<div class="w-full">
<div ref={root} data-component="home-session-search" class="relative z-10 w-full">
<div ref={root} data-component="home-session-search" class="relative z-30 w-full">
<Show when={props.open}>
<div
data-component="home-session-search-panel"
@ -1018,13 +1163,7 @@ function HomeSessionSearch(props: {
</div>
</div>
</Show>
<label
class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out"
classList={{
"focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]": !props.open,
"shadow-[0_0_0_0.5px_var(--v2-border-border-focus)]": props.open,
}}
>
<label class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02/60 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02">
<IconV2 name="magnifying-glass" />
<input
ref={input}
@ -1110,6 +1249,7 @@ function HomeSessionSearchResultRow(props: {
classList={{
[HOME_SEARCH_RESULT_ROW]: true,
"bg-v2-overlay-simple-overlay-hover": props.selected,
group: !!showProjectName(),
}}
onMouseEnter={() => props.onHighlight()}
onClick={() => props.onSelect(props.record.session)}
@ -1119,6 +1259,7 @@ function HomeSessionSearchResultRow(props: {
session={props.record.session}
server={props.server}
activeServer={props.activeServer}
revealProjectOnHover={!!showProjectName()}
/>
<div class="flex min-w-0 flex-1 items-center gap-1.5">
<span
@ -1134,25 +1275,20 @@ function HomeSessionSearchResultRow(props: {
)
}
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
const language = useLanguage()
function HomeSessionGroupHeader(props: {
title: string
titleOpacity: number
ref: ComponentProps<"div">["ref"]
elevated?: boolean
}) {
return (
<div class="flex h-7 min-w-0 items-center justify-between pl-3">
<div class={HOME_SECTION_LABEL}>{props.title}</div>
<Show when={props.onNewSession}>
{(onNewSession) => (
<ButtonV2
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="h-7 px-2 [font-weight:530]"
onClick={onNewSession()}
>
{language.t("command.session.new")}
</ButtonV2>
)}
</Show>
<div
ref={props.ref}
class={`pointer-events-none sticky top-3 flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
>
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
{props.title}
</div>
</div>
)
}
@ -1170,7 +1306,10 @@ function HomeSessionRow(props: {
const showProjectName = () => props.showProjectName && props.record.projectName
return (
<div class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]">
<div
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
classList={{ group: !!showProjectName() }}
>
<button
type="button"
data-component="home-session-row"
@ -1182,6 +1321,7 @@ function HomeSessionRow(props: {
session={props.record.session}
server={props.server}
activeServer={props.activeServer}
revealProjectOnHover={!!showProjectName()}
/>
<span
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}

View file

@ -943,18 +943,6 @@ export default function LegacyLayout(props: ParentProps) {
keybind: "mod+comma",
onSelect: () => openSettings(),
},
...(platform.platform === "desktop" && platform.exportDebugLogs
? [
{
id: "logs.export",
title: "Export logs",
category: language.t("command.category.settings"),
onSelect: () => {
void platform.exportDebugLogs?.()
},
},
]
: []),
{
id: "session.previous",
title: language.t("command.session.previous"),

View file

@ -11,32 +11,28 @@ export function SessionTabAvatar(props: {
directory: string
sessionId: string
activeServer: boolean
revealProjectOnHover?: boolean
}) {
const directory = () => props.directory
const sessionId = () => props.sessionId
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
const projectAvatar = () => (
<ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
unread={state.unread()}
/>
)
return (
<Show
when={state.loading()}
fallback={
<ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
unread={state.unread()}
/>
}
>
<Show when={state.loading()} fallback={projectAvatar()}>
<span class="relative block size-4 shrink-0">
<SessionProgressIndicatorV2 class="absolute inset-0 group-hover:invisible" />
<span class="invisible absolute inset-0 group-hover:visible">
<ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
unread={state.unread()}
/>
</span>
<SessionProgressIndicatorV2
class={`absolute inset-0 ${props.revealProjectOnHover === false ? "" : "group-hover:invisible"}`}
/>
<Show when={props.revealProjectOnHover !== false}>
<span class="invisible absolute inset-0 group-hover:visible">{projectAvatar()}</span>
</Show>
</span>
</Show>
)

View file

@ -3,6 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
import {
batch,
ErrorBoundary,
onCleanup,
Show,
Match,
@ -10,8 +11,10 @@ import {
createMemo,
createEffect,
createComputed,
createResource,
on,
onMount,
type ParentProps,
untrack,
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
@ -19,29 +22,37 @@ import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { debounce } from "@solid-primitives/scheduled"
import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { FileProvider, selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { createStore } from "solid-js/store"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Select } from "@opencode-ai/ui/select"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@/utils/toast"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useLocation, useNavigate, useSearchParams } from "@solidjs/router"
import { Navigate, useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
import { ErrorPage } from "@/pages/error"
import { CommentsProvider, useComments } from "@/context/comments"
import { DirectoryDataProvider } from "@/pages/directory-layout"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePrompt } from "@/context/prompt"
import { ModelsProvider } from "@/context/models"
import { useNotification } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { PromptProvider, usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { SDKProvider, useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { ServerConnection, serverName, useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { useTabs } from "@/context/tabs"
import { TerminalProvider, useTerminal } from "@/context/terminal"
import { PromptInput } from "@/components/prompt-input"
import { useSettingsCommand } from "@/components/settings-dialog"
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
@ -73,8 +84,8 @@ import { Identifier } from "@/utils/id"
import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist"
import { extractPromptFromParts } from "@/utils/prompt"
import { formatServerError } from "@/utils/server-errors"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { formatServerError, isSessionNotFoundError } from "@/utils/server-errors"
import { legacySessionHref, requireServerKey, selectSessionLineage, sessionHref } from "@/utils/session-route"
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
import { createSessionOwnership } from "./session/session-ownership"
@ -91,6 +102,15 @@ const sessionViewState = () => ({
changes: "git" as ChangeMode,
})
function isLocalSessionNotFoundError(error: unknown, sessionID: string) {
return error instanceof Error && error.message === `Session not found: ${sessionID}`
}
function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undefined) {
if (!sessionID) return false
return isSessionNotFoundError(error, sessionID) || isLocalSessionNotFoundError(error, sessionID)
}
async function runPromptRollbackMutation<T, R>(input: {
capturePrompt: () => { current: () => T[]; set: (value: T[]) => void; reset: () => void }
optimistic: (prompt: { set: (value: T[]) => void; reset: () => void }) => void
@ -114,6 +134,204 @@ async function runPromptRollbackMutation<T, R>(input: {
})
}
export function SessionPage() {
return (
<SessionProviders>
<Page />
</SessionProviders>
)
}
export function TargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
return (
<Show when={`${params.serverKey}\0${params.id}`} keyed>
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
<ResolvedTargetSessionRoute />
</SessionRouteErrorBoundary>
</Show>
)
}
function SessionRouteErrorBoundary(
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>,
) {
const settings = useSettings()
return (
<ErrorBoundary
fallback={(error) =>
settings.general.newLayoutDesigns() ? (
<SessionRouteFrame padded={props.padded}>
<SessionPanelFrame newLayout raised={!!props.sessionID}>
<SessionErrorFallback error={error} sessionID={props.sessionID} serverKey={props.serverKey} />
</SessionPanelFrame>
</SessionRouteFrame>
) : (
<ErrorPage error={error} />
)
}
>
{props.children}
</ErrorBoundary>
)
}
function SessionErrorFallback(props: { error: unknown; sessionID?: string; serverKey?: ServerConnection.Key }) {
const language = useLanguage()
const server = useServer()
const tabs = useTabs()
const displayServer = createMemo(() => {
const key = props.serverKey ?? server.key
const conn = server.list.find((item) => ServerConnection.key(item) === key)
return conn ? serverName(conn) : key
})
const closeTab = () => {
if (!props.sessionID) return
tabs.removeSessionTab({ server: props.serverKey ?? server.key, sessionId: props.sessionID })
}
if (isCurrentSessionNotFoundError(props.error, props.sessionID)) {
return (
<div class="flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-4">
<div class="flex flex-col items-center gap-2">
<div class="text-16-medium text-text max-w-md">{language.t("session.error.notFound")}</div>
<div class="text-13-regular text-text-weak max-w-md">
{language.t("session.error.notFound.description")}
</div>
</div>
<Show when={props.sessionID}>
{(sessionID) => (
<div class="max-w-full flex flex-col items-center gap-1">
<div class="max-w-full text-11-regular text-text-faint break-all">{displayServer()}</div>
<code class="max-w-full rounded-[4px] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-text-base break-all bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)]">
{sessionID()}
</code>
</div>
)}
</Show>
<ButtonV2 variant="neutral" size="normal" icon="xmark-small" onClick={closeTab}>
{language.t("session.error.notFound.closeTab")}
</ButtonV2>
</div>
</div>
)
}
return <ErrorPage error={props.error} />
}
function ResolvedTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
const settings = useSettings()
const tabs = useTabs()
const sync = useServerSync()
const serverKey = createMemo(() => requireServerKey(params.serverKey))
const cached = createMemo(() => sync().session.lineage.peek(params.id))
const [resolved] = createResource(
() => {
if (cached()) return
return { id: params.id, sync: sync() }
},
({ id, sync }) => sync.session.lineage.resolve(id),
)
const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved()))
const directory = createMemo(() => current()?.session.directory)
const targetDirectory = () => directory()!
createEffect(() => {
const session = current()
if (!session) return
tabs.addSessionTab({
server: serverKey(),
sessionId: session.root.id,
})
})
return (
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
<Show when={directory()}>
<Show
when={settings.general.newLayoutDesigns()}
fallback={<Navigate href={legacySessionHref(directory()!, params.id)} />}
>
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<TargetSessionPage />
</DirectoryDataProvider>
</SDKProvider>
</Show>
</Show>
</TargetServerScopedProviders>
)
}
function TargetSessionPage() {
const sdk = useSDK()
const serverSDK = useServerSDK()
return (
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
<SessionPage />
</Show>
)
}
function TargetServerScopedProviders(
props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>,
) {
return (
<PermissionProvider directory={props.directory}>
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</PermissionProvider>
)
}
function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
const notification = useNotification()
createEffect(() => {
const sessionID = props.sessionID?.()
if (!notification.ready() || !sessionID) return
if (notification.session.unseenCount(sessionID) === 0) return
notification.session.markViewed(sessionID)
})
return null
}
function SessionProviders(props: ParentProps) {
return (
<TerminalProvider>
<FileProvider>
<PromptProvider>
<CommentsProvider>{props.children}</CommentsProvider>
</PromptProvider>
</FileProvider>
</TerminalProvider>
)
}
function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
return (
<div class="relative size-full overflow-hidden flex flex-col" classList={{ "p-2": props.padded }}>
{props.children}
</div>
)
}
function SessionPanelFrame(props: ParentProps<{ newLayout: boolean; raised?: boolean }>) {
return (
<div
classList={{
"flex-1 min-h-0 flex flex-col": true,
"bg-v2-background-bg-base": props.newLayout,
"bg-background-stronger": !props.newLayout,
"rounded-[10px] overflow-hidden": props.newLayout,
"shadow-[var(--v2-elevation-raised)]": props.newLayout && props.raised,
}}
>
{props.children}
</div>
)
}
export default function Page() {
const serverSync = useServerSync()
const layout = useLayout()
@ -1695,9 +1913,88 @@ export default function Page() {
() => !isDesktop() && settings.general.newLayoutDesigns() && settings.general.mobileTitlebarPosition() === "bottom",
)
return (
<div class="relative size-full overflow-hidden flex flex-col">
const sessionErrorFallback = (error: unknown, reset: () => void) => {
createEffect(on(sessionKey, reset, { defer: true }))
return <SessionErrorFallback error={error} sessionID={params.id} />
}
const sessionPanelContent = () => (
<>
{sessionSync() ?? ""}
<Show when={!isDesktop() && !!params.id && settings.general.newLayoutDesigns() && !mobileTabsBottom()}>
{mobileTabs(true)}
</Show>
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id && mobileChanges()}>
<div class="relative h-full overflow-hidden">
{reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8 [&_[data-slot=session-review-list]]:pb-0",
header: "px-4 !h-16 !pb-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
</div>
</Match>
<Match when={params.id}>
<Show when={messagesReady() ? params.id : undefined} keyed>
{(_id) => (
<MessageTimeline
actions={actions}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
onUserScroll={markUserScroll}
onHistoryScroll={onHistoryScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
shouldAnchorBottom={() =>
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
const root = scroller
if (root) scheduleScrollState(root)
}}
userMessages={visibleUserMessages()}
setHistoryAnchor={(handlers) => {
captureHistoryAnchor = handlers.capture
restoreHistoryAnchor = handlers.restore
}}
anchor={anchor}
setRevealMessage={(fn) => {
revealMessage = fn
}}
setScrollToEnd={(fn) => {
scrollToEnd = fn
}}
/>
)}
</Show>
</Match>
<Match when={true}>
<NewSessionView worktree={newSessionWorktree()} />
</Match>
</Switch>
</div>
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{(_) => composerRegion()}</Show>
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
</>
)
return (
<SessionRouteFrame>
<SessionHeader />
<div
class="flex-1 min-h-0 flex flex-col md:flex-row"
@ -1717,85 +2014,13 @@ export default function Page() {
width: sessionPanelWidth(),
}}
>
<div
classList={{
"flex-1 min-h-0 flex flex-col": true,
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
"rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(),
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
}}
>
<Show when={!isDesktop() && !!params.id && settings.general.newLayoutDesigns() && !mobileTabsBottom()}>
{mobileTabs(true)}
</Show>
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id && mobileChanges()}>
<div class="relative h-full overflow-hidden">
{reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8 [&_[data-slot=session-review-list]]:pb-0",
header: "px-4 !h-16 !pb-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
</div>
</Match>
<Match when={params.id}>
<Show when={messagesReady() ? params.id : undefined} keyed>
{(_id) => (
<MessageTimeline
actions={actions}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
onUserScroll={markUserScroll}
onHistoryScroll={onHistoryScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
shouldAnchorBottom={() =>
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
const root = scroller
if (root) scheduleScrollState(root)
}}
userMessages={visibleUserMessages()}
setHistoryAnchor={(handlers) => {
captureHistoryAnchor = handlers.capture
restoreHistoryAnchor = handlers.restore
}}
anchor={anchor}
setRevealMessage={(fn) => {
revealMessage = fn
}}
setScrollToEnd={(fn) => {
scrollToEnd = fn
}}
/>
)}
</Show>
</Match>
<Match when={true}>
<NewSessionView worktree={newSessionWorktree()} />
</Match>
</Switch>
</div>
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{(_) => composerRegion()}</Show>
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
</div>
<SessionPanelFrame newLayout={settings.general.newLayoutDesigns()} raised={!!params.id}>
{settings.general.newLayoutDesigns() ? (
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
) : (
sessionPanelContent()
)}
</SessionPanelFrame>
<Show when={desktopReviewOpen()}>
<div onPointerDown={() => size.start()}>
@ -1832,6 +2057,6 @@ export default function Page() {
</div>
<TerminalPanel />
</div>
</SessionRouteFrame>
)
}

View file

@ -125,7 +125,7 @@ export function SessionComposerRegion(props: {
</Show>
<div
classList={{
"relative z-30": true,
"relative z-[70]": true,
}}
style={{
"margin-top": `${-controller.lift()}px`,

View file

@ -1,9 +1,10 @@
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
import { For, Show, createEffect, createMemo, onCleanup, onMount, type Component } from "solid-js"
import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { showToast } from "@/utils/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language"
@ -77,9 +78,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
customOn: cached?.customOn ?? ([] as boolean[]),
editing: false,
focus: 0,
minimized: false,
optionsHeight: 180,
})
let root: HTMLDivElement | undefined
let optionsRef: HTMLDivElement | undefined
let customRef: HTMLButtonElement | undefined
let optsRef: HTMLButtonElement[] = []
let replied = false
@ -96,11 +100,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const n = Math.min(store.tab + 1, total())
return language.t("session.question.progress", { current: n, total: total() })
})
const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
const customPlaceholder = () => language.t("ui.question.custom.placeholder")
const last = createMemo(() => store.tab >= total() - 1)
const collapse = useSpring(() => (store.minimized ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
const hidden = createMemo(() => Math.max(0, Math.min(1, collapse())))
const optionsOff = createMemo(() => hidden() > 0.98)
const customUpdate = (value: string, selected: boolean = on()) => {
const prev = input().trim()
@ -192,6 +198,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
focus(pickFocus())
})
createEffect(() => {
const el = optionsRef
if (!el) return
const update = () => setStore("optionsHeight", (height) => Math.max(height, el.scrollHeight))
update()
createResizeObserver(el, update)
})
onCleanup(() => {
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
if (replied) return
@ -405,7 +419,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const tab = store.tab + 1
setStore("tab", tab)
setStore("editing", false)
focus(pickFocus(tab))
if (!store.minimized) focus(pickFocus(tab))
}
const back = () => {
@ -414,163 +428,212 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const tab = store.tab - 1
setStore("tab", tab)
setStore("editing", false)
focus(pickFocus(tab))
if (!store.minimized) focus(pickFocus(tab))
}
const jump = (tab: number) => {
if (sending()) return
setStore("tab", tab)
setStore("editing", false)
focus(pickFocus(tab))
if (!store.minimized) focus(pickFocus(tab))
}
const minimize = () => {
if (sending()) return
setStore("editing", false)
setStore("minimized", true)
}
const restore = () => {
if (sending()) return
setStore("minimized", false)
focus(pickFocus())
}
return (
<DockPrompt
kind="question"
ref={(el) => (root = el)}
onKeyDown={nav}
header={
<>
<div data-slot="question-header-title">{summary()}</div>
<Show when={total() > 1}>
<div data-slot="question-progress">
<For each={questions()}>
{(_, i) => (
<button
type="button"
data-slot="question-progress-segment"
data-active={i() === store.tab}
data-answered={answered(i())}
disabled={sending()}
onClick={() => jump(i())}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
/>
)}
</For>
<div data-component="session-question-dock">
<DockPrompt
kind="question"
ref={(el) => (root = el)}
onKeyDown={nav}
header={
<>
<div data-slot="question-header-title">{summary()}</div>
<div data-slot="question-header-actions">
<Show when={total() > 1}>
<div data-slot="question-progress">
<For each={questions()}>
{(_, i) => (
<button
type="button"
data-slot="question-progress-segment"
data-active={i() === store.tab}
data-answered={answered(i())}
disabled={sending()}
onClick={() => jump(i())}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
/>
)}
</For>
</div>
</Show>
<button
type="button"
data-component="icon-button"
data-icon="chevron-down"
data-size="normal"
data-variant="ghost"
disabled={sending()}
style={{ transform: `rotate(${hidden() * 180}deg)` }}
onClick={store.minimized ? restore : minimize}
aria-label={language.t(store.minimized ? "session.question.restore" : "session.question.minimize")}
>
<Icon name="chevron-down" size="small" />
</button>
</div>
</Show>
</>
}
footer={
<>
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
{language.t("ui.common.dismiss")}
</Button>
<div data-slot="question-footer-actions">
<Show when={store.tab > 0}>
<Button variant="secondary" size="large" disabled={sending()} onClick={back}>
{language.t("ui.common.back")}
</Button>
</Show>
<Button
variant={last() ? "primary" : "secondary"}
size="large"
disabled={sending()}
onClick={next}
aria-keyshortcuts="Meta+Enter Control+Enter"
>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
</>
}
footer={
<>
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
{language.t("ui.common.dismiss")}
</Button>
</div>
</>
}
>
<div data-slot="question-text" class="overflow-auto">
{question()?.question}
</div>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => (
<Option
multi={multi()}
picked={picked(opt.label)}
label={opt.label}
description={opt.description}
disabled={sending()}
ref={(el) => (optsRef[i()] = el)}
onFocus={() => setStore("focus", i())}
onClick={() => selectOption(i())}
/>
)}
</For>
<div data-slot="question-footer-actions">
<Show when={store.tab > 0}>
<Button variant="secondary" size="large" disabled={sending()} onClick={back}>
{language.t("ui.common.back")}
</Button>
</Show>
<Button
variant={last() ? "primary" : "secondary"}
size="large"
disabled={sending()}
onClick={next}
aria-keyshortcuts="Meta+Enter Control+Enter"
>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
</Button>
</div>
</>
}
>
<div
data-slot="question-text"
style={{
display: store.minimized ? "-webkit-box" : undefined,
"-webkit-line-clamp": store.minimized ? "3" : undefined,
"-webkit-box-orient": store.minimized ? "vertical" : undefined,
overflow: store.minimized ? "hidden" : undefined,
}}
>
{question()?.question}
</div>
<Show when={!store.minimized}>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>
</Show>
<div
ref={(el) => (optionsRef = el)}
data-slot="question-options"
aria-hidden={store.minimized || optionsOff() ? "true" : undefined}
classList={{ "pointer-events-none": hidden() > 0.1 }}
style={{
"max-height": `${Math.max(0, store.optionsHeight * (1 - hidden()))}px`,
opacity: `${Math.max(0, Math.min(1, 1 - hidden()))}`,
visibility: optionsOff() ? "hidden" : "visible",
}}
>
<For each={options()}>
{(opt, i) => (
<Option
multi={multi()}
picked={picked(opt.label)}
label={opt.label}
description={opt.description}
disabled={sending()}
ref={(el) => (optsRef[i()] = el)}
onFocus={() => setStore("focus", i())}
onClick={() => selectOption(i())}
/>
)}
</For>
<Show
when={store.editing}
fallback={
<button
type="button"
ref={customRef}
<Show
when={store.editing}
fallback={
<button
type="button"
ref={customRef}
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
disabled={sending()}
onFocus={() => setStore("focus", options().length)}
onClick={customOpen}
>
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
<span data-slot="question-option-main">
<span data-slot="option-label">{customLabel()}</span>
<span data-slot="option-description">{input() || customPlaceholder()}</span>
</span>
</button>
}
>
<form
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
disabled={sending()}
onFocus={() => setStore("focus", options().length)}
onClick={customOpen}
onMouseDown={(e) => {
if (sending()) {
e.preventDefault()
return
}
if (e.target instanceof HTMLTextAreaElement) return
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
if (input instanceof HTMLTextAreaElement) input.focus()
}}
onSubmit={(e) => {
e.preventDefault()
commitCustom()
}}
>
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
<span data-slot="question-option-main">
<span data-slot="option-label">{customLabel()}</span>
<span data-slot="option-description">{input() || customPlaceholder()}</span>
</span>
</button>
}
>
<form
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
onMouseDown={(e) => {
if (sending()) {
e.preventDefault()
return
}
if (e.target instanceof HTMLTextAreaElement) return
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
if (input instanceof HTMLTextAreaElement) input.focus()
}}
onSubmit={(e) => {
e.preventDefault()
commitCustom()
}}
>
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
<span data-slot="question-option-main">
<span data-slot="option-label">{customLabel()}</span>
<textarea
ref={focusCustom}
data-slot="question-custom-input"
placeholder={customPlaceholder()}
value={input()}
rows={1}
disabled={sending()}
onKeyDown={(e) => {
if (e.key === "Escape") {
<textarea
ref={focusCustom}
data-slot="question-custom-input"
placeholder={customPlaceholder()}
value={input()}
rows={1}
disabled={sending()}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
focus(options().length)
return
}
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
setStore("editing", false)
focus(options().length)
return
}
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
resizeInput(e.currentTarget)
}}
/>
</span>
</form>
</Show>
</div>
</DockPrompt>
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
resizeInput(e.currentTarget)
}}
/>
</span>
</form>
</Show>
</div>
</DockPrompt>
</div>
)
}

View file

@ -31,9 +31,14 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Dialog } from "@opencode-ai/ui/dialog"
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
@ -671,6 +676,34 @@ export function MessageTimeline(props: {
if (!shareEnabled()) return
unshareMutation.mutate(id)
}
const copyShareUrl = () => {
const url = shareUrl()
if (!url) return
void navigator.clipboard
.writeText(url)
.then(() =>
showToast({
variant: "success",
icon: "circle-check",
title: language.t("session.share.copy.copied"),
description: url,
}),
)
.catch((err: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
}),
)
}
const selectShareUrlText: JSX.EventHandler<HTMLDivElement, MouseEvent> = (event) => {
const selection = window.getSelection()
if (!selection) return
const range = document.createRange()
range.selectNodeContents(event.currentTarget)
selection.removeAllRanges()
selection.addRange(range)
}
createEffect(
on(
@ -703,8 +736,9 @@ export function MessageTimeline(props: {
if (!sessionID() || parentID()) return
setTitle({ editing: true, draft: titleLabel() ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
if (!titleRef) return
titleRef.focus()
titleRef.select()
})
}
@ -856,6 +890,26 @@ export function MessageTimeline(props: {
dialog.close()
}
if (settings.general.newLayoutDesigns())
return (
<DialogV2 fit>
<DialogHeader hideClose>
<DialogTitleGroup
title={language.t("session.delete.title")}
description={language.t("session.delete.confirm", { name: name() })}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="danger" onClick={handleDelete}>
{language.t("session.delete.button")}
</ButtonV2>
</DialogFooter>
</DialogV2>
)
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
@ -960,6 +1014,7 @@ export function MessageTimeline(props: {
message={message()}
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
turnDurationMs={turnDurationMs(row().userMessageID)}
useV2Actions={settings.general.newLayoutDesigns()}
defaultOpen={defaultOpen()}
toolOpen={toolOpen[part().id] ?? defaultOpen()}
onToolOpenChange={(open) => setToolOpen(part().id, open)}
@ -1067,6 +1122,7 @@ export function MessageTimeline(props: {
message={message()}
parts={getMsgParts(userMessageRow().userMessageID)}
actions={props.actions}
useV2Actions={settings.general.newLayoutDesigns()}
/>
</div>
</div>
@ -1302,26 +1358,31 @@ export function MessageTimeline(props: {
"w-full": true,
"pb-4": true,
"pr-3": true,
"pl-4": settings.general.newLayoutDesigns(),
"pl-2": settings.general.newLayoutDesigns(),
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(),
}}
>
<div class="h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
<div class="flex items-center min-w-0 grow-1">
<div
classList={{
"flex items-center gap-1 min-w-0 flex-1": true,
"pr-3": !settings.general.newLayoutDesigns(),
}}
>
<div class="flex items-center min-w-0 flex-1 w-full">
<Show when={parentID()}>
<button
type="button"
data-slot="session-title-parent"
class="min-w-0 max-w-[40%] truncate text-14-medium text-text-weak transition-colors hover:text-text-base"
class="min-w-0 max-w-[40%] truncate px-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
onClick={navigateParent}
>
{parentTitle()}
</button>
<span
data-slot="session-title-separator"
class="px-2 text-14-medium text-text-weak"
class="-translate-y-[0.5px] px-1 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
@ -1333,8 +1394,13 @@ export function MessageTimeline(props: {
fallback={
<h1
data-slot="session-title-child"
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
onDblClick={openTitleEditor}
classList={{
"truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
"w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
settings.general.newLayoutDesigns(),
"grow-1 min-w-0": !settings.general.newLayoutDesigns(),
}}
onClick={openTitleEditor}
>
{childTitle()}
</h1>
@ -1347,8 +1413,17 @@ export function MessageTimeline(props: {
data-slot="session-title-child"
value={title.draft}
disabled={titleMutation.isPending}
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px] pl-1 -ml-1"
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
classList={{
"block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !settings.general.newLayoutDesigns(),
"field-sizing-content self-start rounded-[6px] px-2 py-1 ":
settings.general.newLayoutDesigns(),
}}
style={{
"--inline-input-shadow": settings.general.newLayoutDesigns()
? "none"
: "var(--shadow-xs-border-select)",
}}
onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
@ -1370,88 +1445,170 @@ export function MessageTimeline(props: {
</div>
<Show when={sessionID()} keyed>
{(id) => (
<div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" />
<div
classList={{
"shrink-0 flex items-center": true,
"gap-2": settings.general.newLayoutDesigns(),
"gap-3": !settings.general.newLayoutDesigns(),
}}
>
<SessionContextUsage
placement="bottom"
buttonAppearance={settings.general.newLayoutDesigns() ? "v2" : "default"}
/>
<Show when={!parentID()}>
<DropdownMenu
gutter={4}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => {
setTitle("menuOpen", open)
if (open) return
}}
>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"bg-surface-base-active": share.open || title.pendingShare,
}}
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen || share.open || title.pendingShare}
ref={(el: HTMLButtonElement) => {
more = el
}}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
if (title.pendingShare) {
event.preventDefault()
requestAnimationFrame(() => {
setShare({ open: true, dismiss: null })
setTitle("pendingShare", false)
})
}
<Show
when={settings.general.newLayoutDesigns()}
fallback={
<DropdownMenu
gutter={4}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => {
setTitle("menuOpen", open)
if (open) return
}}
>
<DropdownMenu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"bg-surface-base-active": share.open || title.pendingShare,
}}
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={shareEnabled()}>
<DropdownMenu.Item
onSelect={() => {
setTitle({ pendingShare: true, menuOpen: false })
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen || share.open || title.pendingShare}
ref={(el: HTMLButtonElement) => {
more = el
}}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
if (title.pendingShare) {
event.preventDefault()
requestAnimationFrame(() => {
setShare({ open: true, dismiss: null })
setTitle("pendingShare", false)
})
}
}}
>
<DropdownMenu.ItemLabel>
{language.t("session.share.action.share")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
<DropdownMenu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={shareEnabled()}>
<DropdownMenu.Item
onSelect={() => {
setTitle({ pendingShare: true, menuOpen: false })
}}
>
<DropdownMenu.ItemLabel>
{language.t("session.share.action.share")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
}
>
<MenuV2
gutter={6}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => {
setTitle("menuOpen", open)
if (open) return
}}
>
<MenuV2.Trigger
as={IconButtonV2}
icon={<IconV2 name="outline-dots" />}
variant="ghost-muted"
size="large"
state={share.open || title.pendingShare ? "pressed" : undefined}
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen || share.open || title.pendingShare}
ref={(el: HTMLButtonElement) => {
more = el
}}
/>
<MenuV2.Portal>
<MenuV2.Content
style={{ width: "120px", "min-width": "120px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
if (title.pendingShare) {
event.preventDefault()
requestAnimationFrame(() => {
setShare({ open: true, dismiss: null })
setTitle("pendingShare", false)
})
}
}}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<MenuV2.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
{language.t("common.rename")}
</MenuV2.Item>
<Show when={shareEnabled()}>
<MenuV2.Item
onSelect={() => {
setTitle({ pendingShare: true, menuOpen: false })
}}
>
{language.t("session.share.action.share")}...
</MenuV2.Item>
</Show>
<MenuV2.Item onSelect={() => void archiveSession(id)}>
{language.t("common.archive")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
{language.t("common.delete")}...
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</Show>
<KobaltePopover
open={share.open}
anchorRef={() => more}
placement="bottom-end"
gutter={4}
gutter={settings.general.newLayoutDesigns() ? 6 : 4}
modal={false}
onOpenChange={(open) => {
if (open) setShare("dismiss", null)
@ -1461,6 +1618,10 @@ export function MessageTimeline(props: {
<KobaltePopover.Portal>
<KobaltePopover.Content
data-component="popover-content"
classList={{
"flex w-80 max-w-none flex-col items-start gap-3 rounded-[10px] border-0 bg-v2-background-bg-layer-01 p-3 shadow-[var(--v2-elevation-floating)]":
settings.general.newLayoutDesigns(),
}}
style={{ "min-width": "320px" }}
onEscapeKeyDown={(event) => {
setShare({ dismiss: "escape", open: false })
@ -1478,24 +1639,90 @@ export function MessageTimeline(props: {
setShare("dismiss", null)
}}
>
<div class="flex flex-col p-3">
<div class="flex flex-col gap-1">
<div class="text-13-medium text-text-strong">
<Show
when={settings.general.newLayoutDesigns()}
fallback={
<div class="flex flex-col p-3">
<div class="flex flex-col gap-1">
<div class="text-13-medium text-text-strong">
{language.t("session.share.popover.title")}
</div>
<div class="text-12-regular text-text-weak">
{shareUrl()
? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")}
</div>
</div>
<div class="mt-3 flex flex-col gap-2">
<Show
when={shareUrl()}
fallback={
<Button
size="large"
variant="primary"
class="w-full"
onClick={shareSession}
disabled={shareMutation.isPending}
>
{shareMutation.isPending
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
}
>
<div class="flex flex-col gap-2">
<TextField
value={shareUrl() ?? ""}
readOnly
copyable
copyKind="link"
tabIndex={-1}
class="w-full"
/>
<div class="grid grid-cols-2 gap-2">
<Button
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={unshareMutation.isPending}
>
{unshareMutation.isPending
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={unshareMutation.isPending}
>
{language.t("session.share.action.view")}
</Button>
</div>
</div>
</Show>
</div>
</div>
}
>
<div class="flex w-full flex-col gap-1.5 px-0.5 pt-0.5">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base [font-variation-settings:'slnt'_0]">
{language.t("session.share.popover.title")}
</div>
<div class="text-12-regular text-text-weak">
<div class="select-none text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-variation-settings:'slnt'_0]">
{shareUrl()
? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")}
</div>
</div>
<div class="mt-3 flex flex-col gap-2">
<div class="flex w-full flex-col gap-2">
<Show
when={shareUrl()}
fallback={
<Button
size="large"
variant="primary"
<ButtonV2
variant="contrast"
class="w-full"
onClick={shareSession}
disabled={shareMutation.isPending}
@ -1503,48 +1730,57 @@ export function MessageTimeline(props: {
{shareMutation.isPending
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
</ButtonV2>
}
>
<div class="flex flex-col gap-2">
<TextField
value={shareUrl() ?? ""}
readOnly
copyable
copyKind="link"
tabIndex={-1}
class="w-full"
/>
<div class="grid grid-cols-2 gap-2">
<Button
size="large"
variant="secondary"
class={
settings.general.newLayoutDesigns()
? "w-full shadow-none border-[0.5px] border-border-weak-base"
: "w-full shadow-none border border-border-weak-base"
}
<div
class="flex h-8 w-full items-center gap-1.5 rounded-[6px] py-1 pl-2.5 pr-1.5 shadow-[var(--v2-elevation-button-neutral)]"
style={{
background:
"linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-button-neutral)",
}}
>
<div
class="min-w-0 flex-1 truncate select-text cursor-text text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-variation-settings:'slnt'_0]"
onClick={selectShareUrlText}
>
{shareUrl()}
</div>
<IconButtonV2
type="button"
size="small"
variant="ghost-muted"
icon={<IconV2 name="outline-copy" />}
aria-label={language.t("session.share.copy.copyLink")}
onClick={copyShareUrl}
/>
<IconButtonV2
type="button"
size="small"
variant="ghost-muted"
icon={<IconV2 name="outline-square-arrow" />}
aria-label={language.t("session.share.action.view")}
onClick={viewShare}
disabled={unshareMutation.isPending}
/>
</div>
<div class="flex w-full">
<ButtonV2
variant="outline"
class="w-full"
onClick={unshareSession}
disabled={unshareMutation.isPending}
>
{unshareMutation.isPending
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={unshareMutation.isPending}
>
{language.t("session.share.action.view")}
</Button>
</ButtonV2>
</div>
</div>
</Show>
</div>
</div>
</Show>
</KobaltePopover.Content>
</KobaltePopover.Portal>
</KobaltePopover>

View file

@ -16,6 +16,7 @@ type PersistedWithReady<T> = [
type PersistTarget = {
storage?: string
scope?: "window"
legacyStorageNames?: string[]
key: string
legacy?: string[]
@ -24,6 +25,7 @@ type PersistTarget = {
const LEGACY_STORAGE = "default.dat"
const GLOBAL_STORAGE = "opencode.global.dat"
const WINDOW_STORAGE = "opencode.window"
const LOCAL_PREFIX = "opencode."
const fallback = new Map<string, boolean>()
@ -347,6 +349,11 @@ function draftStorage(draftID: string) {
return `opencode.draft.${head}.${sum}.dat`
}
function windowStorage(windowID: string) {
const safe = (windowID || "browser").replace(/[^a-zA-Z0-9._-]/g, "-")
return `${WINDOW_STORAGE}.${safe}.dat`
}
function legacyWorkspaceStorage(dir: string) {
const storage = workspaceStorage(pathKey(dir))
const result = new Set<string>()
@ -467,6 +474,8 @@ export const PersistTesting = {
localStorageWithPrefix,
migrateLegacy,
normalize,
resolveTarget,
windowStorage,
workspaceStorage,
}
@ -474,6 +483,9 @@ export const Persist = {
global(key: string, legacy?: string[]): PersistTarget {
return { storage: GLOBAL_STORAGE, key, legacy }
},
window(key: string, legacy?: string[]): PersistTarget {
return { scope: "window", key, legacy }
},
draft(draftID: string, key: string, legacy?: string[]): PersistTarget {
return { storage: draftStorage(draftID), key: `draft:${key}`, legacy }
},
@ -503,6 +515,16 @@ export const Persist = {
},
}
function resolveTarget(target: PersistTarget, platform: Platform): PersistTarget {
if (target.scope !== "window") return target
if (platform.platform === "desktop" && !platform.windowID) return { ...target, storage: GLOBAL_STORAGE }
const windowID = platform.platform === "desktop" ? (platform.windowID ?? "browser") : "browser"
return {
...target,
storage: windowStorage(windowID),
}
}
export function removePersisted(
target: { storage?: string; legacyStorageNames?: string[]; key: string },
platform?: Platform,
@ -533,7 +555,7 @@ export function persisted<T>(
store: [Store<T>, SetStoreFunction<T>],
): PersistedWithReady<T> {
const platform = usePlatform()
const config: PersistTarget = typeof target === "string" ? { key: target } : target
const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform)
const defaults = snapshot(store[0])
const legacy = config.legacy ?? []

View file

@ -0,0 +1,57 @@
import { useMutation } from "@tanstack/solid-query"
import { createEffect } from "solid-js"
import type { Accessor } from "solid-js"
import {
addServerProbePlan,
createProbeFailureGate,
runAddableProbePlan,
type AddServerProbePlan,
type WslAddServerView,
} from "./settings-model"
import type { WslInstalledDistro, WslServersPlatform, WslServersState } from "./types"
export function useWslAddServerProbes(input: {
state: Accessor<WslServersState | undefined>
api: WslServersPlatform
view: Accessor<WslAddServerView>
adding: Accessor<boolean>
busy: Accessor<boolean>
selectedDistro: Accessor<string | null>
addableInstalledDistros: Accessor<WslInstalledDistro[]>
onError: (error: unknown) => void
}) {
const gate = createProbeFailureGate()
const probe = useMutation(() => ({
mutationFn: async (command: AddServerProbePlan) => {
if (command.kind === "addable") {
await runAddableProbePlan({ plan: command.plan, api: input.api })
return
}
if (command.plan.action === "probeRuntime") await input.api.probeRuntime()
if (command.plan.action === "refreshDistros") await input.api.refreshDistros()
},
onError: input.onError,
onSettled: (_result, error, command) => {
if (command) gate.settle(command.key, error)
},
}))
createEffect(() => {
if (probe.isPending) return
const command = addServerProbePlan({
state: input.state(),
view: input.view(),
adding: input.adding(),
busy: input.busy(),
selectedDistro: input.selectedDistro(),
addableInstalledDistros: input.addableInstalledDistros(),
})
if (!command || !gate.accepts(command.key)) return
probe.mutate(command)
})
return {
probingAddable: () => probe.isPending && probe.variables?.kind === "addable",
resetProbeFailure: () => gate.reset(),
}
}

View file

@ -1,20 +1,28 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@opencode-ai/ui/toast"
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2"
import { RadioGroupV2, RadioItemV2 } from "@opencode-ai/ui/v2/radio-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useWslAddServerProbes } from "./add-server-probes"
import { useWslServers } from "./context"
import { enterWslOpencodeStep } from "./settings-model"
import { addServerViewModel, type AddServerText } from "./settings-model"
import "./dialog-add-wsl-server.css"
type WslServerStep = "wsl" | "distro" | "opencode"
function isWslRuntimeMissing(error: string | null | undefined) {
if (!error) return true
return /WSL is not installed|not been installed|wsl(?:\.exe)? --install/i.test(error)
}
const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"]
function isHiddenDistro(name: string) {
return /^docker-desktop(?:-data)?$/i.test(name)
function translate(language: ReturnType<typeof useLanguage>, value: AddServerText) {
if (value.params) return language.t(value.key, value.params)
return language.t(value.key)
}
interface DialogWslServerProps {
@ -22,210 +30,272 @@ interface DialogWslServerProps {
}
export function DialogAddWslServer(props: DialogWslServerProps = {}) {
const language = useLanguage()
const controller = useWslAddServerController(props)
const model = controller.model
const primaryButton = () => model().primaryButton
const primaryButtonStyle = () => {
const width = primaryButton().width
if (!width) return undefined
return { width }
}
return (
<Show
when={!controller.wslServers.isPending && !controller.wslServers.isError}
fallback={
<Dialog fit class="settings-v2-wsl-dialog">
<Show
when={!controller.wslServers.isError}
fallback={<div class="settings-v2-wsl-loading">{controller.loadError()}</div>}
>
<div class="settings-v2-wsl-loading">
<LoaderV2 />
</div>
</Show>
</Dialog>
}
>
<Show
when={model().runtimeState === "ready"}
fallback={
<Show
when={model().runtimeState === "checking" || model().runtimeState === "loading"}
fallback={
<DialogWslSetup
state={model().runtimeState}
error={controller.runtimeError()}
installable={isWslRuntimeMissing(controller.runtimeError())}
busy={model().busy}
onInstall={controller.installWsl}
/>
}
>
<Dialog fit class="settings-v2-wsl-dialog">
<div class="settings-v2-wsl-loading">
<LoaderV2 />
</div>
</Dialog>
</Show>
}
>
<Dialog fit class="settings-v2-wsl-dialog">
<DialogHeader hideClose={true}>
<DialogTitle>
{controller.view() === "main" ? language.t("wsl.server.add") : language.t("wsl.onboarding.installDistro")}
</DialogTitle>
</DialogHeader>
<DividerV2 />
<Show
when={controller.view() === "main"}
fallback={
<>
<DialogBody class="settings-v2-wsl-dialog-body settings-v2-wsl-catalog-picker">
<TextInputV2
class="settings-v2-wsl-catalog-search"
appearance="large"
placeholder={language.t("wsl.onboarding.searchDistros")}
value={controller.catalogSearch()}
disabled={model().busy}
onInput={(event) => controller.setCatalogSearch(event.currentTarget.value)}
/>
<div class="settings-v2-wsl-catalog-list">
<RadioGroupV2
hideLabel
class="settings-v2-wsl-distro-group"
label={language.t("wsl.onboarding.installDistro")}
value={model().catalogTarget ?? undefined}
onChange={controller.setCatalogTarget}
disabled={model().busy}
>
<For each={model().filteredInstallableDistros}>
{(item) => (
<RadioItemV2
class="settings-v2-wsl-distro-row settings-v2-wsl-catalog-row"
value={item.name}
disabled={model().busy}
label={<span class="settings-v2-wsl-distro-label">{item.label}</span>}
/>
)}
</For>
</RadioGroupV2>
</div>
</DialogBody>
<DialogFooter>
<ButtonV2 variant="neutral" disabled={model().busy} onClick={controller.closeCatalog}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2
variant={model().installingCatalogDistro ? "loading" : "contrast"}
disabled={!model().installingCatalogDistro && (model().busy || !model().catalogTarget)}
style={{ width: "99px" }}
onClick={controller.installCatalogDistro}
>
<Show when={model().installingCatalogDistro} fallback={language.t("wsl.onboarding.installDistro")}>
<LoaderV2 />
</Show>
</ButtonV2>
</DialogFooter>
</>
}
>
<DialogBody class="settings-v2-wsl-dialog-body">
<div class="settings-v2-wsl-section-header">
<span class="settings-v2-wsl-section-title">{language.t("wsl.onboarding.installedDistros")}</span>
<ButtonV2
variant="ghost-muted"
size="small"
disabled={model().busy}
onClick={controller.refreshDistros}
>
{language.t("wsl.onboarding.checkAgain")}
</ButtonV2>
</div>
<Show
when={model().addableInstalledDistros.length > 0}
fallback={
<div class="settings-v2-wsl-distro-list">
<div class="settings-v2-wsl-distro-empty">
{model().visibleInstalledDistros.length
? language.t("wsl.onboarding.allDistrosAdded")
: language.t("wsl.onboarding.noDistros")}
</div>
</div>
}
>
<div class="settings-v2-wsl-distro-list">
<RadioGroupV2
hideLabel
class="settings-v2-wsl-distro-group"
label={language.t("wsl.onboarding.installedDistros")}
value={model().selectedDistro ?? undefined}
onChange={controller.setSelectedDistro}
disabled={model().busy}
>
<For each={model().addableInstalledDistros}>
{(item) => {
const status = () => model().distroStatuses[item.name] ?? null
return (
<RadioItemV2
class={`settings-v2-wsl-distro-row${item.version === 1 ? " settings-v2-wsl-distro-row--unsupported" : ""}`}
value={item.name}
disabled={item.version === 1 || model().busy}
label={<span class="settings-v2-wsl-distro-label">{item.name}</span>}
description={
<Show when={status()}>
{(value) => (
<span class="settings-v2-wsl-distro-status" data-tone={value().tone}>
{translate(language, value().label)}
</span>
)}
</Show>
}
/>
)
}}
</For>
</RadioGroupV2>
</div>
</Show>
<Show when={model().installableDistros.length > 0}>
<button
type="button"
class="settings-v2-wsl-catalog-card"
disabled={model().busy}
onClick={controller.openCatalog}
>
<span class="settings-v2-wsl-catalog-icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.5564 10.4443V13.5554H4.22309C3.24087 13.5554 2.44531 13.5554 2.44531 13.5554V10.4443M11.112 5.99989L8.00087 9.111L4.88976 5.99989M8.00087 9.111L8.00087 2.44434"
stroke="currentColor"
/>
</svg>
</span>
<span class="settings-v2-wsl-catalog-copy">
<span class="settings-v2-wsl-catalog-title">{language.t("wsl.onboarding.needAnotherDistro")}</span>
<span class="settings-v2-wsl-catalog-description">
{language.t("wsl.onboarding.needAnotherDistroHint")}
</span>
</span>
<span class="settings-v2-wsl-catalog-chevron" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 12L10 8L6 4" stroke="currentColor" />
</svg>
</span>
</button>
</Show>
</DialogBody>
<DialogFooter>
<ButtonV2 variant="neutral" disabled={controller.adding()} onClick={controller.close}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2
variant={primaryButton().loading ? "loading" : primaryButton().variant}
disabled={!primaryButton().loading && primaryButton().disabled}
style={primaryButtonStyle()}
onClick={controller.runPrimary}
>
<Show when={primaryButton().loading} fallback={translate(language, primaryButton().label)}>
<LoaderV2 />
</Show>
</ButtonV2>
</DialogFooter>
</Show>
</Dialog>
</Show>
</Show>
)
}
function useWslAddServerController(props: DialogWslServerProps) {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const wslServers = useWslServers()
const api = platform.wslServers!
const [store, setStore] = createStore({
step: undefined as WslServerStep | undefined,
view: "main" as "main" | "catalog",
selectedDistro: null as string | null,
installTarget: undefined as string | undefined,
catalogSearch: "",
catalogTarget: null as string | null,
adding: false,
})
const current = () => wslServers.data
let disposed = false
onCleanup(() => {
disposed = true
const viewModel = (probingAddable: boolean) =>
addServerViewModel({
state: current(),
view: store.view,
selectedDistro: store.selectedDistro,
catalogSearch: store.catalogSearch,
catalogTarget: store.catalogTarget,
adding: store.adding,
probingAddable,
})
const baseModel = createMemo(() => viewModel(false))
const probes = useWslAddServerProbes({
state: current,
api,
view: () => store.view,
adding: () => store.adding,
busy: () => baseModel().busy,
selectedDistro: () => baseModel().selectedDistro,
addableInstalledDistros: () => baseModel().addableInstalledDistros,
onError: (error) => requestError(language, error),
})
const busy = createMemo(() => !!current()?.job || store.adding)
const visibleInstalledDistros = createMemo(() =>
(current()?.installed ?? []).filter((item) => !isHiddenDistro(item.name)),
)
const visibleOnlineDistros = createMemo(() => (current()?.online ?? []).filter((item) => !isHiddenDistro(item.name)))
const defaultInstalledDistro = createMemo(() => visibleInstalledDistros().find((item) => item.isDefault) ?? null)
const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro)))
const addableInstalledDistros = createMemo(() => {
return visibleInstalledDistros().filter((item) => !existingServerDistros().has(item.name))
})
const selectedDistro = createMemo(() => {
if (store.selectedDistro && addableInstalledDistros().some((item) => item.name === store.selectedDistro)) {
return store.selectedDistro
}
const distro = defaultInstalledDistro()
if (distro && !existingServerDistros().has(distro.name)) return distro.name
return null
})
const selectedProbe = createMemo(() => {
const distro = selectedDistro()
if (!distro) return null
return current()?.distroProbes[distro] ?? null
})
const selectedInstalled = createMemo(() => {
const distro = selectedDistro()
if (!distro) return null
return (current()?.installed ?? []).find((item) => item.name === distro) ?? null
})
const opencodeCheck = createMemo(() => {
const distro = selectedDistro()
if (!distro) return null
return current()?.opencodeChecks[distro] ?? null
})
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
const distroReady = createMemo(() => {
const probe = selectedProbe()
if (!probe || !selectedDistro()) return false
if (selectedInstalled()?.version === 1) return false
return probe.canExecute && probe.hasBash && probe.hasCurl
})
const opencodeReady = createMemo(() => {
const check = opencodeCheck()
return !!check?.resolvedPath && !check.error
})
const distroWarningProbe = createMemo(() => {
const probe = selectedProbe()
if (!probe) return null
if (distroReady()) return null
return probe
})
const distroUnavailableMessage = createMemo(() => {
const probe = distroWarningProbe()
const distro = selectedDistro()
if (!probe || probe.canExecute || !distro) return null
if (!selectedInstalled()) return language.t("wsl.onboarding.distroNotInstalled", { distro })
return language.t("wsl.onboarding.openDistroOnce", { distro })
})
const distroMissingTools = createMemo(() => {
const probe = distroWarningProbe()
if (!probe?.canExecute) return null
if (probe.hasBash && probe.hasCurl) return null
return probe
})
const installableDistros = createMemo(() => {
const online = visibleOnlineDistros()
const installed = new Set(visibleInstalledDistros().map((item) => item.name))
const hasVersionedUbuntu = online.some((item) => /^Ubuntu-\d/.test(item.name))
return online
.filter((item) => !installed.has(item.name))
.filter((item) => !(item.name === "Ubuntu" && hasVersionedUbuntu))
})
const installTarget = createMemo(
() => installableDistros().find((item) => item.name === store.installTarget) ?? installableDistros()[0] ?? null,
)
const installingDistro = createMemo(() => current()?.job?.kind === "install-distro")
const installingOpencode = createMemo(() => {
const job = current()?.job
return job?.kind === "install-opencode" && job.distro === selectedDistro()
})
const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
const addDisabled = createMemo(() => {
const job = current()?.job
if (!job) return store.adding
return store.adding || job.kind !== "probe-opencode"
})
const recommendedStep = createMemo<WslServerStep>(() => {
if (!wslReady()) return "wsl"
if (!distroReady()) return "distro"
return "opencode"
})
// activeStep falls back to recommendedStep when the user hasn't picked one.
// Once the user clicks a step tab we respect their choice rather than snapping
// them back when a probe result updates recommendedStep.
const activeStep = createMemo(() => store.step ?? recommendedStep())
const model = createMemo(() => viewModel(probes.probingAddable()))
const autoProbe = createMemo(() => {
const state = current()
if (!state || busy()) return null
if (state.pendingRestart) return null
if (!state.runtime) return { key: "runtime", run: () => api.probeRuntime() }
if (!wslReady()) return null
if (!state.installed.length && !state.online.length) {
return { key: "distros", run: () => api.refreshDistros() }
}
const distro = selectedDistro()
if (distro && !state.distroProbes[distro]) {
return { key: `probe-distro:${distro}`, run: () => api.probeDistro(distro) }
}
if (!distro || !distroReady()) return null
if (!state.opencodeChecks[distro]) {
return { key: `probe-opencode:${distro}`, run: () => api.probeOpencode(distro) }
}
return null
})
let lastAutoProbe: string | null = null
createEffect(() => {
const probe = autoProbe()
if (!probe || probe.key === lastAutoProbe) return
const key = probe.key
lastAutoProbe = key
void (async () => {
try {
await probe.run()
} catch (err) {
if (disposed) return
// Allow the same probe to run again when reactive inputs next change
// (e.g. user reselects a distro). Without this the user would be stuck
// on a transient wsl.exe failure until they pick a different distro.
if (lastAutoProbe === key) lastAutoProbe = null
requestError(language, err)
}
})()
})
const wslMessage = createMemo(() => {
const state = current()
if (!state || state.job?.kind === "runtime") return language.t("wsl.onboarding.checkingRuntime")
if (state.pendingRestart) return language.t("wsl.onboarding.restartRequired")
if (state.runtime?.available) return state.runtime.version ?? language.t("wsl.onboarding.ready")
return state.runtime?.error ?? language.t("wsl.onboarding.required")
})
const distroMessage = createMemo(() => {
const state = current()
if (!state) return language.t("wsl.onboarding.checkingDistros")
const distro = selectedDistro()
if (state.job?.kind === "install-distro")
return language.t("wsl.onboarding.installingDistro", { distro: state.job.distro })
if (state.job?.kind === "probe-distro")
return language.t("wsl.onboarding.checkingDistro", { distro: state.job.distro })
if (state.job?.kind === "distros") return language.t("wsl.onboarding.listingDistros")
if (distroUnavailableMessage()) return distroUnavailableMessage()!
if (selectedProbe() && distroReady())
return language.t("wsl.onboarding.distroReady", { distro: selectedProbe()!.name })
if (distro) return language.t("wsl.onboarding.finishingDistro", { distro })
return language.t("wsl.onboarding.pickDistro")
})
const opencodeMessage = createMemo(() => {
const state = current()
if (!state) return language.t("wsl.onboarding.checkingOpencode")
const distro = selectedDistro()
if (state.job?.kind === "install-opencode") {
return distro
? language.t("wsl.onboarding.updatingOpencodeIn", { distro })
: language.t("wsl.onboarding.updatingOpencode")
}
if (state.job?.kind === "probe-opencode") {
return distro
? language.t("wsl.onboarding.checkingOpencodeIn", { distro })
: language.t("wsl.onboarding.checkingOpencode")
}
if (opencodeCheck()?.error) return opencodeCheck()!.error
if (opencodeCheck()?.matchesDesktop === false) {
return distro
? language.t("wsl.onboarding.updateOpencodeIn", { distro })
: language.t("wsl.onboarding.updateOpencode")
}
if (opencodeReady()) {
return distro
? language.t("wsl.onboarding.opencodeReadyIn", { distro })
: language.t("wsl.onboarding.opencodeReady")
}
return distro
? language.t("wsl.onboarding.installOpencodeIn", { distro })
: language.t("wsl.onboarding.chooseDistroFirst")
})
const openCatalog = () => {
const first = model().installableDistros[0]
setStore({
view: "catalog",
catalogSearch: "",
catalogTarget: first?.name ?? null,
})
}
const run = async (action: () => Promise<unknown>) => {
try {
@ -235,26 +305,43 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
}
}
const runSelectedDistro = (action: (distro: string) => Promise<unknown>) => {
const distro = selectedDistro()
if (!distro) return
void run(() => action(distro))
const refreshDistros = () => {
void run(async () => {
probes.resetProbeFailure()
await api.refreshDistros()
})
}
const selectDistro = (name: string) => {
setStore("selectedDistro", name)
setStore("step", undefined)
const installDistro = (name: string) => {
void run(async () => {
probes.resetProbeFailure()
await api.installDistro(name)
setStore("view", "main")
})
}
const openOpencodeStep = () => {
const distro = selectedDistro()
if (!distro) return
void run(() => enterWslOpencodeStep(distro, api.probeOpencode, (step) => setStore("step", step)))
const installCatalogDistro = () => {
if (model().installingCatalogDistro) return
const name = model().catalogTarget
if (!name) return
installDistro(name)
}
const finish = async () => {
const distro = selectedDistro()
if (!distro) return
const closeCatalog = () => {
probes.resetProbeFailure()
setStore({ view: "main", catalogSearch: "", catalogTarget: null })
}
const runPrimary = async () => {
const button = model().primaryButton
if (button.loading) return
const distro = model().selectedDistro
const action = button.action
if (!distro || !action) return
if (action === "install-opencode") {
await run(() => api.installOpencode(distro))
return
}
setStore("adding", true)
try {
await api.addServer(distro)
@ -270,346 +357,99 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
}
}
const steps = createMemo(() => {
const active = activeStep()
const activeIndex = STEPS.indexOf(active)
const recommendedIndex = STEPS.indexOf(recommendedStep())
return STEPS.map((step) => {
const index = STEPS.indexOf(step)
return {
step,
title:
step === "wsl"
? language.t("wsl.server.label")
: step === "distro"
? language.t("wsl.onboarding.step.distro")
: language.t("wsl.onboarding.step.opencode"),
state:
active === step
? "current"
: step === "wsl"
? wslReady()
? "done"
: "warning"
: step === "distro"
? distroReady()
? "done"
: index > activeIndex
? "locked"
: "warning"
: opencodeCheck()?.matchesDesktop === false
? "warning"
: opencodeReady()
? "done"
: index > activeIndex
? "locked"
: "warning",
locked: index > recommendedIndex,
}
})
})
const loadError = createMemo(() => {
const loadError = () => {
const error = wslServers.error
if (!error) return language.t("wsl.onboarding.loadFailed")
return error instanceof Error ? error.message : String(error)
})
}
return {
wslServers,
model,
loadError,
runtimeError: () => current()?.runtime?.error ?? null,
view: () => store.view,
catalogSearch: () => store.catalogSearch,
adding: () => store.adding,
setCatalogSearch: (value: string) => setStore("catalogSearch", value),
setCatalogTarget: (value: string) => setStore("catalogTarget", value),
setSelectedDistro: (value: string) => setStore("selectedDistro", value),
openCatalog,
closeCatalog,
refreshDistros,
installCatalogDistro,
installWsl: () => void run(() => api.installWsl()),
runPrimary: () => void runPrimary(),
close: () => dialog.close(),
}
}
function DialogWslSetup(props: {
state: string
error: string | null
installable: boolean
busy: boolean
onInstall: () => void
}) {
const language = useLanguage()
const dialog = useDialog()
const title = () =>
props.state === "pendingRestart"
? language.t("wsl.onboarding.restartRequired")
: props.installable
? language.t("wsl.onboarding.wslNotInstalled.title")
: language.t("wsl.onboarding.wslUnavailable.title")
const description = () => {
if (props.state === "pendingRestart") return language.t("wsl.onboarding.windowsRestartRequired")
if (!props.installable) return language.t("wsl.onboarding.wslUnavailable.description")
return language.t("wsl.onboarding.wslNotInstalled.description")
}
return (
<div class="px-5 pb-5 flex flex-col gap-4">
<Show
when={!wslServers.isPending}
fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{language.t("wsl.onboarding.loading")}</div>}
>
<Show
when={!wslServers.isError}
fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{loadError()}</div>}
>
<div class="flex gap-2 pb-1">
<For each={steps()}>
{(item) => (
<button
type="button"
class="basis-0 flex-1 min-w-0 rounded-md border px-3 py-2 text-left transition-colors"
classList={{
"border-border-strong-base bg-surface-base-hover": item.state === "current",
"border-icon-success-base/40 bg-surface-base": item.state === "done",
"border-border-weak-base bg-background-base opacity-60": item.state === "locked",
"border-icon-warning-base/40 bg-surface-base": item.state === "warning",
}}
disabled={item.locked}
onClick={() => setStore("step", item.step)}
>
<div class="text-13-medium text-text-strong">{item.title}</div>
</button>
)}
</For>
</div>
<Switch>
<Match when={activeStep() === "wsl"}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div class="text-14-medium text-text-strong">{language.t("wsl.server.label")}</div>
<Show when={current()?.runtime && !wslReady() && !current()?.pendingRestart}>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => api.installWsl())}
>
{language.t("wsl.onboarding.installWsl")}
</Button>
</Show>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{wslMessage()}</div>
<Show when={current()?.pendingRestart}>
<div class="rounded-md border border-border-weak-base px-3 py-3">
<div class="text-12-regular text-text-warning-base">
{language.t("wsl.onboarding.windowsRestartRequired")}
</div>
</div>
</Show>
<div class="flex items-center justify-end">
<Button
variant="secondary"
size="large"
disabled={busy() || !wslReady()}
onClick={() => setStore("step", "distro")}
>
{language.t("wsl.onboarding.next")}
</Button>
</div>
</div>
</Match>
<Match when={activeStep() === "distro"}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div class="text-14-medium text-text-strong">{language.t("wsl.onboarding.step.distro")}</div>
<Show when={selectedDistro()}>
<Button
variant="ghost"
size="small"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
>
{language.t("wsl.onboarding.refresh")}
</Button>
</Show>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{distroMessage()}</div>
<div class="flex flex-col gap-2">
<Show
when={addableInstalledDistros().length > 0}
fallback={
<div class="text-12-regular text-text-weak">
{visibleInstalledDistros().length
? language.t("wsl.onboarding.allDistrosAdded")
: current()?.runtime?.available
? language.t("wsl.onboarding.noDistros")
: language.t("wsl.onboarding.checkingDistros")}
</div>
}
>
<For each={addableInstalledDistros()}>
{(item) => (
<button
type="button"
class="rounded-md border border-border-weak-base px-3 py-2 text-left transition-colors"
classList={{ "bg-surface-raised-base": selectedDistro() === item.name }}
onClick={() => selectDistro(item.name)}
>
<div class="text-13-medium text-text-strong">{item.name}</div>
<Show when={item.isDefault}>
<div class="text-12-regular text-text-weak">{language.t("common.default")}</div>
</Show>
</button>
)}
</For>
</Show>
</div>
<Show when={installableDistros().length > 0}>
<div class="rounded-md border border-border-weak-base p-2 flex flex-col gap-2">
<div class="px-1 flex items-center justify-between gap-3">
<div class="text-12-medium text-text-weak">{language.t("wsl.onboarding.install")}</div>
<div class="flex items-center gap-2 shrink-0">
<Show when={installingDistro()}>
<Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
</Show>
<Button
variant="secondary"
size="small"
disabled={busy() || !installTarget()}
onClick={() => void run(() => api.installDistro(installTarget()!.name))}
>
{installingDistro()
? language.t("wsl.onboarding.installing")
: language.t("wsl.onboarding.install")}
</Button>
</div>
</div>
<div
role="radiogroup"
aria-label={language.t("wsl.onboarding.installDistro")}
class="max-h-52 overflow-y-auto rounded-md bg-background-base"
>
<For each={installableDistros()}>
{(item) => {
const selected = () => installTarget()?.name === item.name
return (
<button
type="button"
role="radio"
aria-checked={selected()}
disabled={busy()}
class="w-full px-3 py-2 flex items-center gap-3 text-left border-b border-border-weak-base last:border-b-0 transition-colors"
classList={{
"bg-surface-raised-base": selected(),
"hover:bg-surface-base": !selected(),
}}
onClick={() => setStore("installTarget", item.name)}
>
<div
class="mt-0.5 h-4 w-4 rounded-full border border-border-strong-base flex items-center justify-center shrink-0"
classList={{ "border-text-strong": selected() }}
>
<div class="h-2 w-2 rounded-full bg-text-strong" classList={{ hidden: !selected() }} />
</div>
<div class="min-w-0 flex-1 text-13-medium text-text-strong truncate">{item.label}</div>
</button>
)
}}
</For>
</div>
</div>
</Show>
<Show when={selectedInstalled()?.version === 1 || distroUnavailableMessage() || distroMissingTools()}>
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
<Show when={selectedInstalled()?.version === 1}>
<div class="text-12-regular text-text-warning-base">
{language.t("wsl.onboarding.wsl2Required")}
</div>
</Show>
<Show when={distroUnavailableMessage()}>
{(message) => <div class="text-12-regular text-text-warning-base">{message()}</div>}
</Show>
<Show when={distroMissingTools()}>
<div class="text-12-regular text-text-warning-base">
{language.t("wsl.onboarding.toolsRequired")}
</div>
</Show>
</div>
</Show>
<div class="flex items-center gap-2">
<Button
variant="secondary"
size="large"
disabled={busy() || !selectedInstalled()}
onClick={() => runSelectedDistro((distro) => api.openTerminal(distro))}
>
{language.t("wsl.onboarding.openTerminal")}
</Button>
<Button
variant="ghost"
size="large"
disabled={busy() || !selectedDistro()}
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
>
{language.t("wsl.onboarding.refresh")}
</Button>
</div>
<div class="flex items-center justify-end">
<Button
variant="secondary"
size="large"
disabled={busy() || !selectedDistro() || !distroReady()}
onClick={openOpencodeStep}
>
{language.t("wsl.onboarding.next")}
</Button>
</div>
</div>
</Match>
<Match when={activeStep() === "opencode"}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div class="text-14-medium text-text-strong">{language.t("wsl.onboarding.step.opencode")}</div>
<div class="flex items-center gap-2">
<Show when={selectedDistro()}>
<Button
variant="ghost"
size="large"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.probeOpencode(distro))}
>
{language.t("wsl.onboarding.refresh")}
</Button>
</Show>
<Show when={!opencodeReady() || opencodeCheck()?.matchesDesktop === false}>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.installOpencode(distro))}
>
<Show when={installingOpencode()}>
<Spinner class="size-4 shrink-0" />
</Show>
{opencodeCheck()?.resolvedPath
? language.t("wsl.onboarding.updateOpencode")
: language.t("wsl.onboarding.installOpencode")}
</Button>
</Show>
</div>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{opencodeMessage()}</div>
<Show when={opencodeCheck()?.matchesDesktop === false ? opencodeCheck() : null}>
{(check) => (
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
<div class="text-12-regular text-text-weak">
{language.t("wsl.onboarding.path", {
path: check().resolvedPath ?? language.t("wsl.onboarding.notFound"),
})}
</div>
<div class="text-12-regular text-text-weak">
{language.t("wsl.onboarding.version", {
version: check().version ?? language.t("wsl.onboarding.unknown"),
})}
<Show when={check().expectedVersion}>
{(expected) => (
<span>{` · ${language.t("wsl.onboarding.desktopVersion", { version: expected() })}`}</span>
)}
</Show>
</div>
<div class="text-12-regular text-text-warning-base">
{language.t("wsl.onboarding.versionMismatch")}
</div>
</div>
)}
</Show>
</div>
</Match>
</Switch>
<Show when={activeStep() === "opencode" && allReady() && selectedDistro()}>
<div class="flex items-center justify-end gap-2">
<Button variant="ghost" size="large" disabled={store.adding} onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" disabled={addDisabled()} onClick={() => void finish()}>
{store.adding ? language.t("wsl.onboarding.adding") : language.t("wsl.server.add")}
</Button>
</div>
<Dialog fit class="settings-v2-wsl-not-installed-dialog">
<div class="settings-v2-wsl-not-installed-content">
<div class="settings-v2-wsl-not-installed-message">
<svg
class="settings-v2-wsl-not-installed-icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<g clip-path="url(#settings-v2-wsl-warning-clip)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 -0.00244141L23.6926 20.2498H0.308594L12 -0.00244141ZM12.7954 6.32932C12.5844 6.11834 12.2982 5.99982 11.9999 5.99982C11.7015 5.99982 11.4154 6.11834 11.2044 6.32932C10.9934 6.5403 10.8749 6.82645 10.8749 7.12482V11.6248C10.8749 11.9232 10.9934 12.2093 11.2044 12.4203C11.4154 12.6313 11.7015 12.7498 11.9999 12.7498C12.2982 12.7498 12.5844 12.6313 12.7954 12.4203C13.0064 12.2093 13.1249 11.9232 13.1249 11.6248V7.12482C13.1249 6.82645 13.0064 6.5403 12.7954 6.32932ZM13.0605 17.5605C12.7792 17.8418 12.3977 17.9998 11.9999 17.9998C11.6021 17.9998 11.2205 17.8418 10.9392 17.5605C10.6579 17.2792 10.4999 16.8976 10.4999 16.4998C10.4999 16.102 10.6579 15.7205 10.9392 15.4392C11.2205 15.1579 11.6021 14.9998 11.9999 14.9998C12.3977 14.9998 12.7792 15.1579 13.0605 15.4392C13.3418 15.7205 13.4999 16.102 13.4999 16.4998C13.4999 16.8976 13.3418 17.2792 13.0605 17.5605Z"
fill="#DBDBDB"
/>
</g>
<defs>
<clipPath id="settings-v2-wsl-warning-clip">
<rect width="24" height="24" fill="white" />
</clipPath>
</defs>
</svg>
<h2 class="settings-v2-wsl-not-installed-title">{title()}</h2>
<p class="settings-v2-wsl-not-installed-description">{description()}</p>
<Show when={!props.installable && props.error}>
<p class="settings-v2-wsl-unavailable-error">{props.error}</p>
</Show>
</div>
<Show when={props.state === "unavailable" && props.installable}>
<ButtonV2 variant="neutral" disabled={props.busy} onClick={props.onInstall}>
{language.t("wsl.onboarding.installWsl")}
</ButtonV2>
</Show>
</Show>
</div>
<Show when={props.state !== "unavailable"}>
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
{language.t("common.close")}
</ButtonV2>
</Show>
</div>
</Dialog>
)
}

View file

@ -0,0 +1,392 @@
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-container"] {
width: 480px;
max-width: calc(100vw - 32px);
height: auto;
align-items: stretch;
}
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-content"] {
align-items: stretch;
width: 100%;
overflow: hidden;
}
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-header"] {
align-items: center;
padding: 24px 16px 0;
}
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-body"] {
display: flex;
width: 100%;
min-width: 0;
flex-direction: column;
align-items: stretch;
padding: 0;
gap: 0;
}
.settings-v2-wsl-dialog-body {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
min-width: 0;
align-self: stretch;
box-sizing: border-box;
padding: 16px 16px 1px;
}
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-footer"] {
padding: 0 16px 24px;
margin-top: 8px;
}
[data-component="dialog-v2"].settings-v2-wsl-not-installed-dialog [data-slot="dialog-container"] {
width: 480px;
max-width: calc(100vw - 32px);
min-height: 264px;
height: auto;
background: var(--v2-background-bg-layer-01);
box-shadow: var(--v2-elevation-overlay);
border-radius: 10px;
}
[data-component="dialog-v2"].settings-v2-wsl-not-installed-dialog [data-slot="dialog-content"] {
align-items: stretch;
min-height: 264px;
border-radius: 10px;
overflow: hidden;
}
[data-component="dialog-v2"].settings-v2-wsl-not-installed-dialog [data-slot="dialog-body"] {
display: flex;
flex: 1;
flex-direction: column;
align-items: stretch;
justify-content: center;
padding: 0;
}
.settings-v2-wsl-not-installed-content {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
padding: 56px 0;
gap: 24px;
text-align: center;
}
.settings-v2-wsl-not-installed-message {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
gap: 8px;
}
.settings-v2-wsl-not-installed-icon {
flex: none;
flex-shrink: 0;
}
.settings-v2-wsl-not-installed-title {
display: flex;
flex: none;
align-items: center;
justify-content: center;
margin: 0;
font-style: normal;
font-weight: 530;
font-size: 15px;
line-height: 20px;
letter-spacing: -0.13px;
font-variant-numeric: tabular-nums;
font-variation-settings: "slnt" 0;
color: var(--v2-text-text-base);
}
.settings-v2-wsl-not-installed-description {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 100%;
max-width: 300px;
margin: 0;
font-style: normal;
font-weight: 440;
font-size: 13px;
line-height: 20px;
text-align: center;
letter-spacing: -0.04px;
font-variant-numeric: tabular-nums;
font-variation-settings: "slnt" 0;
color: var(--v2-text-text-muted);
}
.settings-v2-wsl-unavailable-error {
width: 100%;
max-width: 360px;
margin: 4px 0 0;
font-style: normal;
font-weight: 440;
font-size: 12px;
line-height: 18px;
text-align: center;
letter-spacing: -0.04px;
font-variant-numeric: tabular-nums;
font-variation-settings: "slnt" 0;
color: var(--v2-text-text-faint);
overflow-wrap: anywhere;
}
.settings-v2-wsl-section-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
flex: none;
align-self: stretch;
gap: 12px;
height: 24px;
padding: 0 0 0 9px;
}
.settings-v2-wsl-section-title {
flex: none;
user-select: none;
font-style: normal;
font-weight: 440;
font-size: 13px;
line-height: 16px;
letter-spacing: -0.04px;
font-variation-settings: "slnt" 0;
color: var(--v2-text-text-base);
}
.settings-v2-wsl-distro-list {
display: flex;
flex-direction: column;
align-items: flex-start;
flex: none;
align-self: stretch;
width: 100%;
height: fit-content;
max-height: 220px;
padding: 0;
border-radius: 8px;
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-raised);
overflow-x: hidden;
overflow-y: auto;
}
.settings-v2-wsl-distro-group {
width: 100%;
}
.settings-v2-wsl-distro-group [data-slot="radio-v2-items"] {
width: 100%;
gap: 0;
}
.settings-v2-wsl-distro-row {
box-sizing: border-box;
width: 100%;
min-height: 62px;
padding: 14px 14px 14px 16px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-wsl-distro-row:last-child {
border-bottom: none;
}
.settings-v2-wsl-distro-row [data-slot="radio-v2-item-label"] {
position: relative;
display: flex;
flex: 1;
width: 100%;
min-width: 0;
align-self: stretch;
}
.settings-v2-wsl-distro-row [data-slot="radio-v2-item-label"]::before {
content: "";
position: absolute;
inset: -14px -14px -14px -44px;
}
.settings-v2-wsl-distro-row [data-slot="radio-v2-item-text"] {
width: 100%;
}
.settings-v2-wsl-distro-label {
font-style: normal;
font-weight: 440;
font-size: 13px;
line-height: 16px;
letter-spacing: -0.04px;
font-variant-numeric: tabular-nums;
font-variation-settings: "slnt" 0;
color: inherit;
}
.settings-v2-wsl-distro-status {
flex: none;
font-style: normal;
font-weight: 440;
font-size: 12px;
line-height: 12px;
letter-spacing: 0.05px;
font-variant-numeric: tabular-nums;
font-variation-settings: "slnt" 0;
}
.settings-v2-wsl-distro-status[data-tone="success"] {
color: var(--v2-state-fg-success);
}
.settings-v2-wsl-distro-status[data-tone="warning"] {
color: var(--v2-state-fg-warning);
}
.settings-v2-wsl-distro-status[data-tone="muted"] {
color: var(--v2-text-text-muted);
}
.settings-v2-wsl-distro-row:where([data-disabled]) .settings-v2-wsl-distro-status {
color: inherit;
}
.settings-v2-wsl-distro-empty {
padding: 16px;
font-size: 13px;
font-weight: 440;
line-height: 1.4;
color: var(--v2-text-text-muted);
}
.settings-v2-wsl-catalog-card {
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: flex-start;
flex: none;
align-self: stretch;
gap: 12px;
width: 100%;
min-height: 64px;
padding: 14px 14px 16px 16px;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 8px;
background: transparent;
cursor: pointer;
text-align: left;
}
.settings-v2-wsl-catalog-card:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
.settings-v2-wsl-catalog-card:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.settings-v2-wsl-catalog-icon {
display: flex;
flex: none;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: var(--v2-icon-icon-base);
}
.settings-v2-wsl-catalog-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 6px;
}
.settings-v2-wsl-catalog-title {
user-select: none;
font-style: normal;
font-weight: 440;
font-size: 13px;
line-height: 16px;
letter-spacing: -0.04px;
font-variation-settings: "slnt" 0;
color: var(--v2-text-text-base);
}
.settings-v2-wsl-catalog-description {
user-select: none;
font-style: normal;
font-weight: 440;
font-size: 12px;
line-height: 12px;
letter-spacing: 0.05px;
font-variation-settings: "slnt" 0;
color: var(--v2-text-text-muted);
}
.settings-v2-wsl-catalog-chevron {
display: flex;
flex: none;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: var(--v2-icon-icon-muted);
}
.settings-v2-wsl-catalog-list {
display: flex;
flex-direction: column;
align-items: flex-start;
flex: none;
align-self: stretch;
width: 100%;
height: fit-content;
max-height: 220px;
padding: 0;
border-radius: 8px;
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-strong);
overflow-x: hidden;
overflow-y: auto;
}
.settings-v2-wsl-catalog-picker {
gap: 12px;
}
.settings-v2-wsl-catalog-search {
width: 100%;
}
.settings-v2-wsl-catalog-row {
min-height: 48px;
}
.settings-v2-wsl-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 24px;
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
text-align: center;
}

View file

@ -1,5 +1,31 @@
import { describe, expect, test } from "bun:test"
import { enterWslOpencodeStep, wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
import {
addableProbePlan,
addServerProbePlan,
addServerViewModel,
autoProbePlan,
createProbeFailureGate,
runAddableProbePlan,
wslOpencodeAction,
wslRuntimeRetryable,
} from "./settings-model"
import type { WslServersState } from "./types"
const readyWslState = readyState()
function readyState(input: Partial<WslServersState> = {}): WslServersState {
return {
runtime: { available: true, version: "2.4.13.0", error: null },
installed: [],
online: [],
distroProbes: {},
opencodeChecks: {},
pendingRestart: false,
servers: [],
job: null,
...input,
}
}
describe("WSL server settings presentation", () => {
test("retries only settled unsuccessful runtimes", () => {
@ -45,13 +71,161 @@ describe("WSL server settings presentation", () => {
).toBeUndefined()
})
test("probes the selected distro before entering the OpenCode step", async () => {
const calls: string[] = []
await enterWslOpencodeStep(
"Debian",
async (distro) => calls.push(distro),
(step) => calls.push(step),
)
expect(calls).toEqual(["Debian", "opencode"])
test("plans addable distro probes with the selected distro first", () => {
const plan = addableProbePlan({
state: readyWslState,
view: "main",
adding: false,
selectedDistro: "Ubuntu",
addableInstalledDistros: [
{ name: "Debian", version: 2, isDefault: true },
{ name: "Ubuntu", version: 2, isDefault: false },
],
})
expect(plan?.key).toBe("distro:Ubuntu|distro:Debian")
expect(plan?.distros).toEqual(["Ubuntu", "Debian"])
})
test("plans bootstrap probes for missing runtime and initial distro lists", () => {
expect(autoProbePlan({ state: undefined, busy: false })).toBeUndefined()
expect(autoProbePlan({ state: { ...readyWslState, runtime: null }, busy: false })).toEqual({
key: "runtime",
action: "probeRuntime",
})
expect(autoProbePlan({ state: readyWslState, busy: false })).toEqual({
key: "distros",
action: "refreshDistros",
})
})
test("uses one command plan for bootstrap before addable distro probing", () => {
expect(
addServerProbePlan({
state: { ...readyWslState, runtime: null },
view: "main",
adding: false,
busy: false,
selectedDistro: null,
addableInstalledDistros: [{ name: "Debian", version: 2, isDefault: true }],
}),
).toEqual({ kind: "auto", key: "auto:runtime", plan: { key: "runtime", action: "probeRuntime" } })
expect(
addServerProbePlan({
state: {
...readyWslState,
installed: [{ name: "Debian", version: 2, isDefault: true }],
online: [{ name: "Ubuntu", label: "Ubuntu" }],
},
view: "main",
adding: false,
busy: false,
selectedDistro: "Debian",
addableInstalledDistros: [{ name: "Debian", version: 2, isDefault: true }],
}),
).toEqual({ kind: "addable", key: "addable:distro:Debian", plan: { key: "distro:Debian", distros: ["Debian"] } })
})
test("does not accept the same failed probe command until reset", () => {
const gate = createProbeFailureGate()
expect(gate.accepts("addable:distro:Debian")).toBe(true)
gate.settle("addable:distro:Debian", new Error("wsl failed"))
expect(gate.accepts("addable:distro:Debian")).toBe(false)
expect(gate.accepts("addable:distro:Ubuntu")).toBe(true)
gate.reset()
expect(gate.accepts("addable:distro:Debian")).toBe(true)
})
test("keeps default distro selection stable while probes resolve", () => {
const model = addServerViewModel({
state: {
...readyWslState,
installed: [
{ name: "Debian", version: 2, isDefault: true },
{ name: "Ubuntu", version: 2, isDefault: false },
],
online: [{ name: "Alpine", label: "Alpine Linux" }],
distroProbes: {
Ubuntu: { name: "Ubuntu", canExecute: true, hasBash: true, hasCurl: true, error: null },
},
},
view: "main",
selectedDistro: null,
catalogSearch: "",
catalogTarget: null,
adding: false,
probingAddable: false,
})
expect(model.selectedDistro).toBe("Debian")
})
test("keeps the dialog busy across serial addable probe job gaps", () => {
const model = addServerViewModel({
state: readyState({
installed: [{ name: "Debian", version: 2, isDefault: true }],
online: [{ name: "Ubuntu", label: "Ubuntu" }],
}),
view: "main",
selectedDistro: null,
catalogSearch: "",
catalogTarget: null,
adding: false,
probingAddable: true,
})
expect(model.busy).toBe(true)
})
test("does not report ready when OpenCode is present but cannot run", () => {
const model = addServerViewModel({
state: {
...readyWslState,
installed: [{ name: "Debian", version: 2, isDefault: true }],
online: [{ name: "Ubuntu", label: "Ubuntu" }],
distroProbes: {
Debian: { name: "Debian", canExecute: true, hasBash: true, hasCurl: true, error: null },
},
opencodeChecks: {
Debian: {
distro: "Debian",
resolvedPath: "/home/me/.opencode/bin/opencode",
version: null,
expectedVersion: "1.2.3",
matchesDesktop: null,
error: "opencode is installed but could not run",
},
},
},
view: "main",
selectedDistro: null,
catalogSearch: "",
catalogTarget: null,
adding: false,
probingAddable: false,
})
expect(model.distroStatuses.Debian).toEqual({
label: { key: "wsl.onboarding.installOpencode" },
tone: "warning",
})
expect(model.primaryButton.action).toBe("install-opencode")
})
test("delegates addable probe plans to one batch command", async () => {
const calls: string[][] = []
await runAddableProbePlan({
plan: { key: "distro:Debian|distro:Ubuntu", distros: ["Debian", "Ubuntu"] },
api: {
probeAddable: async (distros) => {
calls.push(distros)
},
},
})
expect(calls).toEqual([["Debian", "Ubuntu"]])
})
})

View file

@ -1,19 +1,341 @@
import type { WslOpencodeCheck, WslServerRuntime } from "./types"
import fuzzysort from "fuzzysort"
import type {
WslInstalledDistro,
WslOnlineDistro,
WslOpencodeCheck,
WslServersPlatform,
WslServerRuntime,
WslServersState,
} from "./types"
export type AddServerText = {
key: string
params?: Record<string, string>
}
export type DistroStatusTone = "success" | "warning" | "muted"
export type DistroStatus = {
label: AddServerText
tone: DistroStatusTone
}
export type AddServerPrimaryButton = {
variant: "neutral" | "contrast"
label: AddServerText
disabled: boolean
action: "install-opencode" | "add" | null
loading: boolean
width: string | null
}
export type AddServerRuntimeState = "loading" | "pendingRestart" | "checking" | "unavailable" | "ready"
export type AddableProbePlan = {
key: string
distros: string[]
}
export type AutoProbePlan = { key: "runtime"; action: "probeRuntime" } | { key: "distros"; action: "refreshDistros" }
export type AddServerProbePlan =
| { kind: "auto"; key: string; plan: AutoProbePlan }
| { kind: "addable"; key: string; plan: AddableProbePlan }
export type WslAddServerView = "main" | "catalog"
function isHiddenDistro(name: string) {
return /^docker-desktop(?:-data)?$/i.test(name)
}
export const wslRuntimeRetryable = (runtime: WslServerRuntime) =>
runtime.kind === "failed" || runtime.kind === "stopped"
export async function enterWslOpencodeStep(
distro: string,
probe: (distro: string) => Promise<unknown>,
select: (step: "opencode") => void,
) {
await probe(distro)
select("opencode")
}
export function wslOpencodeAction(check?: WslOpencodeCheck) {
if (!check) return
if (!check.resolvedPath) return "Install OpenCode"
if (check.matchesDesktop === false) return "Update OpenCode"
}
export function wslDistroReady(state: WslServersState | undefined, name: string) {
const installed = state?.installed.find((item) => item.name === name)
const probe = state?.distroProbes[name]
if (!probe || !installed) return false
if (installed.version === 1) return false
return probe.canExecute && probe.hasBash && probe.hasCurl
}
export function addServerViewModel(input: {
state: WslServersState | undefined
view: WslAddServerView
selectedDistro: string | null
catalogSearch: string
catalogTarget: string | null
adding: boolean
probingAddable: boolean
}) {
const state = input.state
const visibleInstalledDistros = (state?.installed ?? []).filter((item) => !isHiddenDistro(item.name))
const visibleOnlineDistros = (state?.online ?? []).filter((item) => !isHiddenDistro(item.name))
const existingServerDistros = new Set((state?.servers ?? []).map((item) => item.config.distro))
const addableInstalledDistros = visibleInstalledDistros.filter((item) => !existingServerDistros.has(item.name))
const selectedDistro = addServerSelectedDistro(input.selectedDistro, visibleInstalledDistros, addableInstalledDistros)
const opencodeCheck = selectedDistro ? (state?.opencodeChecks[selectedDistro] ?? null) : null
const installableDistros = addServerInstallableDistros(visibleInstalledDistros, visibleOnlineDistros)
const filteredInstallableDistros = addServerFilteredInstallableDistros(installableDistros, input.catalogSearch)
const catalogTarget = addServerCatalogTarget(input.catalogTarget, filteredInstallableDistros)
const busy = !!state?.job || input.adding || input.probingAddable
return {
busy,
runtimeState: addServerRuntimeState(state),
visibleInstalledDistros,
visibleOnlineDistros,
addableInstalledDistros,
selectedDistro,
opencodeCheck,
wslReady: !!state?.runtime?.available && !state?.pendingRestart,
distroStatuses: Object.fromEntries(
addableInstalledDistros.flatMap((item) => {
const status = addServerDistroStatus({ state, name: item.name, probingAddable: input.probingAddable })
if (!status) return []
return [[item.name, status]]
}),
) as Record<string, DistroStatus | undefined>,
primaryButton: addServerPrimaryButton({
state,
selectedDistro,
opencodeCheck,
adding: input.adding,
probingAddable: input.probingAddable,
}),
installableDistros,
filteredInstallableDistros,
catalogTarget,
installingCatalogDistro: state?.job?.kind === "install-distro",
}
}
function addServerSelectedDistro(
selected: string | null,
visibleInstalledDistros: WslInstalledDistro[],
addableInstalledDistros: WslInstalledDistro[],
) {
if (selected && addableInstalledDistros.some((item) => item.name === selected && item.version !== 1)) return selected
const defaultDistro = visibleInstalledDistros.find((item) => item.isDefault)
if (
defaultDistro &&
defaultDistro.version !== 1 &&
addableInstalledDistros.some((item) => item.name === defaultDistro.name)
) {
return defaultDistro.name
}
return addableInstalledDistros.find((item) => item.version !== 1)?.name ?? null
}
function addServerRuntimeState(state: WslServersState | undefined): AddServerRuntimeState {
if (!state) return "loading"
if (state.pendingRestart) return "pendingRestart"
if (!state.runtime) return "checking"
if (!state.runtime.available) return "unavailable"
return "ready"
}
function addServerDistroStatus(input: {
state: WslServersState | undefined
name: string
probingAddable: boolean
}): DistroStatus | undefined {
const installed = input.state?.installed.find((item) => item.name === input.name)
if (installed?.version === 1) return { label: { key: "wsl.onboarding.distroStatus.unsupported" }, tone: "muted" }
const job = input.state?.job
const probe = input.state?.distroProbes[input.name]
if (!probe) {
if (input.probingAddable || (job?.kind === "probe-addable" && job.distros.includes(input.name))) {
return checkingStatus()
}
return
}
if (!probe.canExecute) {
if (!installed) {
return { label: { key: "wsl.onboarding.distroNotInstalled", params: { distro: input.name } }, tone: "warning" }
}
return { label: { key: "wsl.onboarding.openDistroOnce", params: { distro: input.name } }, tone: "warning" }
}
if (!probe.hasBash || !probe.hasCurl) {
return { label: { key: "wsl.onboarding.distroStatus.missingTools" }, tone: "warning" }
}
const check = input.state?.opencodeChecks[input.name]
if (!check) {
if (input.probingAddable || (job?.kind === "probe-addable" && job.distros.includes(input.name))) {
return checkingStatus()
}
return
}
if (check.matchesDesktop === false) return { label: { key: "wsl.onboarding.updateOpencode" }, tone: "warning" }
if (!check.resolvedPath) return { label: { key: "wsl.onboarding.distroStatus.opencodeMissing" }, tone: "warning" }
if (check.error) return { label: { key: "wsl.onboarding.installOpencode" }, tone: "warning" }
return { label: { key: "wsl.onboarding.distroStatus.ready" }, tone: "success" }
}
function checkingStatus(): DistroStatus {
return { label: { key: "wsl.onboarding.distroStatus.checking" }, tone: "muted" }
}
function addServerPrimaryButton(input: {
state: WslServersState | undefined
selectedDistro: string | null
opencodeCheck: WslOpencodeCheck | null
adding: boolean
probingAddable: boolean
}): AddServerPrimaryButton {
const ready = !!input.selectedDistro && wslDistroReady(input.state, input.selectedDistro)
const probingSelected = input.probingAddable && !addServerSelectedDistroSettled(input.state, input.selectedDistro)
const probingOpencode =
probingSelected ||
(ready &&
(!input.opencodeCheck ||
(!!input.selectedDistro &&
input.state?.job?.kind === "probe-addable" &&
input.state.job.distros.includes(input.selectedDistro))))
const installingOpencode =
input.state?.job?.kind === "install-opencode" && input.state.job.distro === input.selectedDistro
if (!ready || probingOpencode) {
return {
variant: "contrast",
label: probingSelected ? { key: "wsl.onboarding.distroStatus.checking" } : { key: "wsl.server.add" },
disabled: true,
action: null,
loading: probingSelected,
width: null,
}
}
if (!addServerOpencodeReady(input.opencodeCheck)) {
const update = !!input.opencodeCheck?.resolvedPath && input.opencodeCheck.matchesDesktop === false
return {
variant: "neutral",
label: installingOpencode
? { key: "wsl.onboarding.updatingOpencode" }
: update
? { key: "wsl.onboarding.updateOpencode" }
: { key: "wsl.onboarding.installOpencode" },
disabled: !!input.state?.job || input.adding,
action: "install-opencode",
loading: installingOpencode,
width: update ? "138px" : "129px",
}
}
return {
variant: "contrast",
label: input.adding ? { key: "wsl.onboarding.adding" } : { key: "wsl.server.add" },
disabled: input.adding || !!input.state?.job,
action: "add",
loading: input.adding,
width: null,
}
}
function addServerOpencodeReady(check: WslOpencodeCheck | null) {
return !!check?.resolvedPath && check.matchesDesktop !== false && !check.error
}
function addServerSelectedDistroSettled(state: WslServersState | undefined, selectedDistro: string | null) {
if (!selectedDistro) return false
const installed = state?.installed.find((item) => item.name === selectedDistro)
if (installed?.version === 1) return false
if (!state?.distroProbes[selectedDistro]) return false
if (!wslDistroReady(state, selectedDistro)) return true
return !!state.opencodeChecks[selectedDistro]
}
function addServerInstallableDistros(installedDistros: WslInstalledDistro[], onlineDistros: WslOnlineDistro[]) {
const installed = new Set(installedDistros.map((item) => item.name))
const hasVersionedUbuntu = onlineDistros.some((item) => /^Ubuntu-\d/.test(item.name))
return onlineDistros
.filter((item) => !installed.has(item.name))
.filter((item) => item.name !== "Ubuntu" || !hasVersionedUbuntu)
}
function addServerFilteredInstallableDistros(installableDistros: WslOnlineDistro[], search: string) {
const query = search.trim()
if (!query) return installableDistros
return fuzzysort.go(query, installableDistros, { keys: ["label", "name"] }).map((item) => item.obj)
}
function addServerCatalogTarget(target: string | null, distros: WslOnlineDistro[]) {
if (target && distros.some((item) => item.name === target)) return target
return distros[0]?.name ?? null
}
export function addableProbePlan(input: {
state: WslServersState | undefined
view: WslAddServerView
adding: boolean
selectedDistro: string | null
addableInstalledDistros: WslInstalledDistro[]
}) {
const state = input.state
if (!state?.runtime?.available || state.pendingRestart || input.view !== "main" || input.adding) return
if (state.job) return
const ordered = input.selectedDistro
? [
...input.addableInstalledDistros.filter((item) => item.name === input.selectedDistro),
...input.addableInstalledDistros.filter((item) => item.name !== input.selectedDistro),
]
: input.addableInstalledDistros
const pending = ordered.flatMap((item) => {
if (item.version === 1) return []
if (!state.distroProbes[item.name]) return [`distro:${item.name}`]
if (wslDistroReady(state, item.name) && !state.opencodeChecks[item.name]) return [`opencode:${item.name}`]
return []
})
if (!pending.length) return
return {
key: pending.join("|"),
distros: ordered.filter((item) => item.version !== 1).map((item) => item.name),
} satisfies AddableProbePlan
}
export function autoProbePlan(input: { state: WslServersState | undefined; busy: boolean }) {
if (!input.state || input.busy || input.state.pendingRestart) return
if (!input.state.runtime) return { key: "runtime", action: "probeRuntime" } satisfies AutoProbePlan
if (!input.state.runtime.available) return
if (input.state.installed.length || input.state.online.length) return
return { key: "distros", action: "refreshDistros" } satisfies AutoProbePlan
}
export function addServerProbePlan(input: {
state: WslServersState | undefined
view: WslAddServerView
adding: boolean
busy: boolean
selectedDistro: string | null
addableInstalledDistros: WslInstalledDistro[]
}) {
const auto = autoProbePlan({ state: input.state, busy: input.busy })
if (auto) return { kind: "auto", key: `auto:${auto.key}`, plan: auto } satisfies AddServerProbePlan
const addable = addableProbePlan(input)
if (addable) return { kind: "addable", key: `addable:${addable.key}`, plan: addable } satisfies AddServerProbePlan
}
export function createProbeFailureGate() {
let failed: string | undefined
return {
accepts(key: string) {
return key !== failed
},
settle(key: string, error?: unknown) {
if (error) failed = key
},
reset() {
failed = undefined
},
}
}
export async function runAddableProbePlan(input: {
plan: AddableProbePlan
api: Pick<WslServersPlatform, "probeAddable">
}) {
await input.api.probeAddable(input.plan.distros)
}

View file

@ -1,8 +1,6 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
@ -30,17 +28,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
const dialog = useDialog()
const language = useLanguage()
const openAddWsl = () => {
dialog.push(() => (
<Dialog size="large" fit class="settings-v2-wsl-dialog">
<DialogHeader hideClose={true}>
<DialogTitle>{language.t("wsl.server.add")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody>
<DialogAddWslServer />
</DialogBody>
</Dialog>
))
dialog.push(() => <DialogAddWslServer />)
}
return (
<Show

View file

@ -53,8 +53,7 @@ export type WslJob =
| { kind: "distros"; startedAt: number }
| { kind: "install-wsl"; startedAt: number }
| { kind: "install-distro"; distro: string; startedAt: number }
| { kind: "probe-distro"; distro: string; startedAt: number }
| { kind: "probe-opencode"; distro: string; startedAt: number }
| { kind: "probe-addable"; distros: string[]; startedAt: number }
| { kind: "install-opencode"; distro: string; startedAt: number }
export type WslServersState = {
@ -77,8 +76,7 @@ export type WslServersPlatform = {
refreshDistros(): Promise<void>
installWsl(): Promise<void>
installDistro(name: string): Promise<void>
probeDistro(name: string): Promise<void>
probeOpencode(name: string): Promise<void>
probeAddable(distros: string[]): Promise<void>
installOpencode(name: string): Promise<void>
openTerminal(name: string): Promise<void>
addServer(distro: string): Promise<WslServerConfig>

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.17.11",
"version": "1.17.13",
"type": "module",
"license": "MIT",
"bin": {

View file

@ -1,5 +1,7 @@
import { NodeHttpServer } from "@effect/platform-node"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Global } from "@opencode-ai/core/global"
import { Context, Layer, Option, Schedule } from "effect"
@ -83,8 +85,7 @@ function bind(hostname: string, port: number, password: string) {
return Layer.build(
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
Layer.provide(Credential.defaultLayer),
Layer.provide(PermissionSaved.defaultLayer),
Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node]))),
),
).pipe(
Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))),

View file

@ -11,6 +11,10 @@ import { Daemon } from "./services/daemon"
import { Logging } from "@opencode-ai/core/observability/logging"
import { Updater } from "./services/updater"
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { AppProcess } from "@opencode-ai/core/process"
const LoggingLayer = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe(
Layer.provide(NodeFileSystem.layer),
@ -46,8 +50,9 @@ const Handlers = Runtime.handlers(Commands, {
Effect.logInfo("cli starting", { version: InstallationVersion, channel: InstallationChannel, local: InstallationLocal }).pipe(
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
Effect.annotateLogs({ role: "cli" }),
Effect.provide(Daemon.defaultLayer),
Effect.provide(Updater.defaultLayer),
Effect.provide(Daemon.layer),
Effect.provide(Updater.layer),
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
Effect.provide(LoggingLayer),
Effect.provide(NodeServices.layer),
Effect.scoped,

View file

@ -313,8 +313,6 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Global.defaultLayer))
function serviceURL(config: ServiceConfig) {
const hostname = config.hostname ?? "127.0.0.1"
const result = new URL(`http://${hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname}`)

View file

@ -1,5 +1,6 @@
import { ServerAuth } from "@opencode-ai/server/auth"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Effect, Schema, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { randomBytes } from "node:crypto"
@ -35,7 +36,7 @@ export const transport = Effect.fn("cli.standalone.transport")(
const ready = yield* Effect.tryPromise(() => decodeReady(output))
return { url: ready.url, headers: ServerAuth.headers({ password }), pid: proc.pid }
},
Effect.provide(CrossSpawnSpawner.defaultLayer),
Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)),
)
export * as Standalone from "./standalone"

View file

@ -153,6 +153,4 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppProcess.defaultLayer), Layer.provide(Global.defaultLayer))
export * as Updater from "./updater"

View file

@ -1,6 +1,7 @@
import { run } from "@opencode-ai/tui"
import { TuiConfig } from "@opencode-ai/tui/config"
import { Effect } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global"
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
import { OpenCode } from "@opencode-ai/client"
@ -44,5 +45,5 @@ export function runTui(transport: Transport, args: Args, reload?: () => Promise<
},
},
})
}).pipe(Effect.provide(Global.defaultLayer))
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
}

View file

@ -20,7 +20,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Api } from "@opencode-ai/server/api"
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
import { ClientApi, endpointNames, groupNames, omitEndpoints } from "../src/contract"
import { ClientApi, endpointNames, groupNames, promiseOmitEndpoints } from "../src/contract"
test("Core and Server reuse the authoritative Schema and Protocol values", () => {
expect(AgentV2.ID).toBe(Agent.ID)
@ -43,8 +43,8 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
})
test("client and Server contracts generate identically", () => {
const server = compile(Api, { groupNames, endpointNames, omitEndpoints })
const client = compile(ClientApi, { groupNames, endpointNames, omitEndpoints })
const server = compile(Api, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints })
const client = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints })
expect(emitPromise(client)).toEqual(emitPromise(server))
})

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.17.11",
"version": "1.17.13",
"type": "module",
"license": "MIT",
"scripts": {

View file

@ -248,7 +248,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "الاستثناءات التالية",
"go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع",
"go.banner.text": "MiniMax M3: حد استخدام أكبر 3 مرات لفترة محدودة",
"go.meta.description":
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.2 وGLM-5.1 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.",
"go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع",
@ -369,6 +368,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"لقد وصلت إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{membersUrl}}",
"zen.api.error.modelDisabled": "النموذج معطل",
"zen.api.error.regionNotAllowed":
"هذا النموذج مستضاف في الصين. إذا كنت ترغب في استخدام هذا النموذج، فعّله في إعداداتك: {{consoleGoUrl}}",
"zen.api.error.trialEnded":
"انتهى العرض المجاني لـ {{model}}. يمكنك مواصلة استخدام النموذج بالاشتراك في OpenCode Go - {{link}}",
@ -646,6 +647,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "استخدم رصيدك المتوفر بعد الوصول إلى حدود الاستخدام",
"workspace.lite.subscription.selectProvider":
'اختر "OpenCode Go" كمزود في إعدادات opencode الخاصة بك لاستخدام نماذج Go.',
"workspace.lite.providers.title": "المزودون",
"workspace.lite.providers.description": "تحكم في المزودين المستخدمين للتوجيه.",
"workspace.lite.providers.useChina": "تفعيل النماذج المستضافة في الصين",
"workspace.lite.black.message":
"أنت مشترك حاليًا في OpenCode Black أو في قائمة الانتظار. يرجى إلغاء الاشتراك أولاً إذا كنت ترغب في التبديل إلى Go.",
"workspace.lite.other.message":

View file

@ -252,7 +252,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "seguintes exceções",
"go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos",
"go.banner.text": "MiniMax M3 tem limite de uso 3x maior por tempo limitado",
"go.meta.description":
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
"go.hero.title": "Modelos de codificação de baixo custo para todos",
@ -377,6 +376,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"Você atingiu seu limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{membersUrl}}",
"zen.api.error.modelDisabled": "O modelo está desabilitado",
"zen.api.error.regionNotAllowed":
"Este modelo está hospedado na China. Se você quiser usar este modelo, ative-o nas suas configurações: {{consoleGoUrl}}",
"zen.api.error.trialEnded":
"A promoção gratuita do {{model}} terminou. Você pode continuar usando o modelo assinando o OpenCode Go - {{link}}",
@ -656,6 +657,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Use seu saldo disponível após atingir os limites de uso",
"workspace.lite.subscription.selectProvider":
'Selecione "OpenCode Go" como provedor na sua configuração do opencode para usar os modelos Go.',
"workspace.lite.providers.title": "Provedores",
"workspace.lite.providers.description": "Controle quais provedores são usados para roteamento.",
"workspace.lite.providers.useChina": "Ativar modelos hospedados na China",
"workspace.lite.black.message":
"Você está atualmente inscrito no OpenCode Black ou na lista de espera. Por favor, cancele a assinatura primeiro se desejar mudar para o Go.",
"workspace.lite.other.message":

View file

@ -250,7 +250,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "følgende undtagelser",
"go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle",
"go.banner.text": "MiniMax M3 får tredoblet brugsgrænse i en begrænset periode",
"go.meta.description":
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
"go.hero.title": "Kodningsmodeller til lav pris for alle",
@ -373,6 +372,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"Du har nået din månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{membersUrl}}",
"zen.api.error.modelDisabled": "Modellen er deaktiveret",
"zen.api.error.regionNotAllowed":
"Denne model hostes i Kina. Hvis du vil bruge denne model, skal du aktivere den i dine indstillinger: {{consoleGoUrl}}",
"zen.api.error.trialEnded":
"Den gratis kampagne for {{model}} er afsluttet. Du kan fortsætte med at bruge modellen ved at abonnere på OpenCode Go - {{link}}",
@ -652,6 +653,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Brug din tilgængelige saldo, når du har nået forbrugsgrænserne",
"workspace.lite.subscription.selectProvider":
'Vælg "OpenCode Go" som udbyder i din opencode-konfiguration for at bruge Go-modeller.',
"workspace.lite.providers.title": "Udbydere",
"workspace.lite.providers.description": "Styr, hvilke udbydere der bruges til routing.",
"workspace.lite.providers.useChina": "Aktivér modeller hostet i Kina",
"workspace.lite.black.message":
"Du abonnerer i øjeblikket på OpenCode Black eller er på venteliste. Afmeld venligst først, hvis du vil skifte til Go.",
"workspace.lite.other.message":

View file

@ -252,7 +252,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "folgenden Ausnahmen",
"go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle",
"go.banner.text": "MiniMax M3 erhält für begrenzte Zeit 3x Nutzungslimits",
"go.meta.description":
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.",
"go.hero.title": "Kostengünstige Coding-Modelle für alle",
@ -376,6 +375,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"Du hast dein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{membersUrl}}",
"zen.api.error.modelDisabled": "Modell ist deaktiviert",
"zen.api.error.regionNotAllowed":
"Dieses Modell wird in China gehostet. Wenn du dieses Modell verwenden möchtest, aktiviere es in deinen Einstellungen: {{consoleGoUrl}}",
"zen.api.error.trialEnded":
"Die kostenlose Aktion für {{model}} ist beendet. Du kannst das Modell weiterhin nutzen, indem du OpenCode Go abonnierst - {{link}}",
@ -655,6 +656,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Nutze dein verfügbares Guthaben, nachdem die Nutzungslimits erreicht sind",
"workspace.lite.subscription.selectProvider":
'Wähle "OpenCode Go" als Anbieter in deiner opencode-Konfiguration, um Go-Modelle zu verwenden.',
"workspace.lite.providers.title": "Anbieter",
"workspace.lite.providers.description": "Steuere, welche Anbieter für das Routing verwendet werden.",
"workspace.lite.providers.useChina": "In China gehostete Modelle aktivieren",
"workspace.lite.black.message":
"Du hast derzeit OpenCode Black abonniert oder stehst auf der Warteliste. Bitte kündige zuerst, wenn du zu Go wechseln möchtest.",
"workspace.lite.other.message":

View file

@ -248,7 +248,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "following exceptions",
"go.title": "OpenCode Go | Low cost coding models for everyone",
"go.banner.text": "MiniMax M3 gets 3× usage limits for a limited time",
"go.meta.description":
"Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.",
"go.hero.title": "Low cost coding models for everyone",
@ -370,6 +369,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"You have reached your monthly spending limit of ${{amount}}. Manage your limits here: {{membersUrl}}",
"zen.api.error.modelDisabled": "Model is disabled",
"zen.api.error.regionNotAllowed":
"This model is hosted in China. If you would like to use this model, enable it in your settings: {{consoleGoUrl}}",
"zen.api.error.trialEnded":
"Free promotion has ended for {{model}}. You can continue using the model by subscribing to OpenCode Go - {{link}}",
@ -649,6 +650,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Use your available balance after reaching the usage limits",
"workspace.lite.subscription.selectProvider":
'Select "OpenCode Go" as the provider in your opencode configuration to use Go models.',
"workspace.lite.providers.title": "Providers",
"workspace.lite.providers.description": "Control which providers are used for routing.",
"workspace.lite.providers.useChina": "Enable models hosted in China",
"workspace.lite.black.message":
"You're currently subscribed to OpenCode Black or on the waitlist. Please unsubscribe first if you'd like to switch to Go.",
"workspace.lite.other.message":

View file

@ -253,7 +253,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "siguientes excepciones",
"go.title": "OpenCode Go | Modelos de programación de bajo coste para todos",
"go.banner.text": "MiniMax M3 tiene límites de uso 3x mayores por tiempo limitado",
"go.meta.description":
"Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.",
"go.hero.title": "Modelos de programación de bajo coste para todos",
@ -377,6 +376,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"Has alcanzado tu límite de gasto mensual de ${{amount}}. Gestiona tus límites aquí: {{membersUrl}}",
"zen.api.error.modelDisabled": "El modelo está deshabilitado",
"zen.api.error.regionNotAllowed":
"Este modelo está alojado en China. Si quieres usar este modelo, actívalo en tu configuración: {{consoleGoUrl}}",
"zen.api.error.trialEnded":
"La promoción gratuita de {{model}} ha finalizado. Puedes seguir usando el modelo suscribiéndote a OpenCode Go - {{link}}",
@ -656,6 +657,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Usa tu saldo disponible después de alcanzar los límites de uso",
"workspace.lite.subscription.selectProvider":
'Selecciona "OpenCode Go" como proveedor en tu configuración de opencode para usar los modelos Go.',
"workspace.lite.providers.title": "Proveedores",
"workspace.lite.providers.description": "Controla qué proveedores se usan para el enrutamiento.",
"workspace.lite.providers.useChina": "Activar modelos alojados en China",
"workspace.lite.black.message":
"Actualmente estás suscrito a OpenCode Black o estás en la lista de espera. Por favor, cancela la suscripción primero si deseas cambiar a Go.",
"workspace.lite.other.message":

View file

@ -254,7 +254,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "exceptions suivantes",
"go.title": "OpenCode Go | Modèles de code à faible coût pour tous",
"go.banner.text": "MiniMax M3 bénéficie de limites dutilisation 3x supérieures pour une durée limitée",
"go.meta.description":
"Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash.",
"go.hero.title": "Modèles de code à faible coût pour tous",
@ -377,6 +376,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"Vous avez atteint votre limite de dépense mensuelle de {{amount}} $. Gérez vos limites ici : {{membersUrl}}",
"zen.api.error.modelDisabled": "Le modèle est désactivé",
"zen.api.error.regionNotAllowed":
"Ce modèle est hébergé en Chine. Si vous souhaitez utiliser ce modèle, activez-le dans vos paramètres : {{consoleGoUrl}}",
"zen.api.error.trialEnded":
"La promotion gratuite de {{model}} est terminée. Vous pouvez continuer à utiliser le modèle en vous abonnant à OpenCode Go - {{link}}",
@ -662,6 +663,9 @@ export const dict = {
"Utilisez votre solde disponible après avoir atteint les limites d'utilisation",
"workspace.lite.subscription.selectProvider":
'Sélectionnez "OpenCode Go" comme fournisseur dans votre configuration opencode pour utiliser les modèles Go.',
"workspace.lite.providers.title": "Fournisseurs",
"workspace.lite.providers.description": "Contrôlez les fournisseurs utilisés pour le routage.",
"workspace.lite.providers.useChina": "Activer les modèles hébergés en Chine",
"workspace.lite.black.message":
"Vous êtes actuellement abonné à OpenCode Black ou sur liste d'attente. Veuillez d'abord vous désabonner si vous souhaitez passer à Go.",
"workspace.lite.other.message":

View file

@ -250,7 +250,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "seguenti eccezioni",
"go.title": "OpenCode Go | Modelli di coding a basso costo per tutti",
"go.banner.text": "MiniMax M3 offre limiti di utilizzo 3x superiori per un periodo limitato",
"go.meta.description":
"Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
"go.hero.title": "Modelli di coding a basso costo per tutti",
@ -373,6 +372,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"Hai raggiunto il tuo limite di spesa mensile di ${{amount}}. Gestisci i tuoi limiti qui: {{membersUrl}}",
"zen.api.error.modelDisabled": "Il modello è disabilitato",
"zen.api.error.regionNotAllowed":
"Questo modello è ospitato in Cina. Se vuoi usare questo modello, abilitalo nelle tue impostazioni: {{consoleGoUrl}}",
"zen.api.error.trialEnded":
"La promozione gratuita di {{model}} è terminata. Puoi continuare a usare il modello abbonandoti a OpenCode Go - {{link}}",
@ -654,6 +655,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Usa il tuo saldo disponibile dopo aver raggiunto i limiti di utilizzo",
"workspace.lite.subscription.selectProvider":
'Seleziona "OpenCode Go" come provider nella tua configurazione opencode per utilizzare i modelli Go.',
"workspace.lite.providers.title": "Provider",
"workspace.lite.providers.description": "Controlla quali provider vengono usati per il routing.",
"workspace.lite.providers.useChina": "Abilita modelli ospitati in Cina",
"workspace.lite.black.message":
"Attualmente sei abbonato a OpenCode Black o sei in lista d'attesa. Annulla l'iscrizione prima se desideri passare a Go.",
"workspace.lite.other.message":

View file

@ -249,7 +249,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "以下の例外",
"go.title": "OpenCode Go | すべての人のための低価格なコーディングモデル",
"go.banner.text": "MiniMax M3の利用上限が期間限定で3倍に",
"go.meta.description":
"Goは最初の月$5、その後$10/月で、GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashに対して5時間のゆとりあるリクエスト上限があります。",
"go.hero.title": "すべての人のための低価格なコーディングモデル",
@ -374,6 +373,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"月額の利用上限 ${{amount}} に達しました。こちらから上限を管理してください: {{membersUrl}}",
"zen.api.error.modelDisabled": "モデルが無効です",
"zen.api.error.regionNotAllowed":
"このモデルは中国でホストされています。このモデルを使用したい場合は、設定で有効にしてください: {{consoleGoUrl}}",
"zen.api.error.trialEnded":
"{{model}} の無料プロモーションは終了しました。OpenCode Go を購読するとモデルを引き続き使用できます - {{link}}",
@ -654,6 +655,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "利用限度額に達したら利用可能な残高を使用する",
"workspace.lite.subscription.selectProvider":
"Go モデルを使用するには、opencode の設定で「OpenCode Go」をプロバイダーとして選択してください。",
"workspace.lite.providers.title": "プロバイダー",
"workspace.lite.providers.description": "ルーティングに使用するプロバイダーを管理します。",
"workspace.lite.providers.useChina": "中国でホストされているモデルを有効にする",
"workspace.lite.black.message":
"現在 OpenCode Black を購読中、またはウェイティングリストに登録されています。Go に切り替える場合は、先に登録を解除してください。",
"workspace.lite.other.message":

View file

@ -246,7 +246,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "다음 예외",
"go.title": "OpenCode Go | 모두를 위한 저비용 코딩 모델",
"go.banner.text": "MiniMax M3 사용 한도가 한시적으로 3배 확대됩니다",
"go.meta.description":
"Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash에 대해 넉넉한 5시간 요청 한도를 제공합니다.",
"go.hero.title": "모두를 위한 저비용 코딩 모델",
@ -368,6 +367,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"월간 지출 한도인 ${{amount}}에 도달했습니다. 한도 관리를 여기서 하세요: {{membersUrl}}",
"zen.api.error.modelDisabled": "모델이 비활성화되었습니다",
"zen.api.error.regionNotAllowed":
"이 모델은 중국에서 호스팅됩니다. 이 모델을 사용하려면 설정에서 활성화하세요: {{consoleGoUrl}}",
"zen.api.error.trialEnded":
"{{model}}의 무료 프로모션이 종료되었습니다. OpenCode Go를 구독하면 모델을 계속 사용할 수 있습니다 - {{link}}",
@ -646,6 +647,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "사용 한도 도달 후에는 보유 잔액 사용",
"workspace.lite.subscription.selectProvider":
'Go 모델을 사용하려면 opencode 설정에서 "OpenCode Go"를 공급자로 선택하세요.',
"workspace.lite.providers.title": "공급자",
"workspace.lite.providers.description": "라우팅에 사용할 공급자를 제어합니다.",
"workspace.lite.providers.useChina": "중국에서 호스팅되는 모델 활성화",
"workspace.lite.black.message":
"현재 OpenCode Black을 구독 중이거나 대기 명단에 등록되어 있습니다. Go로 전환하려면 먼저 구독을 취소해 주세요.",
"workspace.lite.other.message":

View file

@ -250,7 +250,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "følgende unntak",
"go.title": "OpenCode Go | Rimelige kodemodeller for alle",
"go.banner.text": "MiniMax M3 får 3x bruksgrense i en begrenset periode",
"go.meta.description":
"Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
"go.hero.title": "Rimelige kodemodeller for alle",
@ -374,6 +373,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"Du har nådd din månedlige utgiftsgrense på ${{amount}}. Administrer grensene dine her: {{membersUrl}}",
"zen.api.error.modelDisabled": "Modellen er deaktivert",
"zen.api.error.regionNotAllowed":
"Denne modellen hostes i Kina. Hvis du vil bruke denne modellen, aktiver den i innstillingene dine: {{consoleGoUrl}}",
"zen.api.error.trialEnded":
"Den gratis kampanjen for {{model}} er avsluttet. Du kan fortsette å bruke modellen ved å abonnere på OpenCode Go - {{link}}",
@ -653,6 +654,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Bruk din tilgjengelige saldo etter å ha nådd bruksgrensene",
"workspace.lite.subscription.selectProvider":
'Velg "OpenCode Go" som leverandør i opencode-konfigurasjonen din for å bruke Go-modeller.',
"workspace.lite.providers.title": "Leverandører",
"workspace.lite.providers.description": "Kontroller hvilke leverandører som brukes til ruting.",
"workspace.lite.providers.useChina": "Aktiver modeller hostet i Kina",
"workspace.lite.black.message":
"Du abonnerer for øyeblikket på OpenCode Black eller står på venteliste. Vennligst avslutt abonnementet først hvis du vil bytte til Go.",
"workspace.lite.other.message":

View file

@ -251,7 +251,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "następującymi wyjątkami",
"go.title": "OpenCode Go | Niskokosztowe modele do kodowania dla każdego",
"go.banner.text": "MiniMax M3 oferuje 3x wyższe limity użycia przez ograniczony czas",
"go.meta.description":
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash.",
"go.hero.title": "Niskokosztowe modele do kodowania dla każdego",
@ -375,6 +374,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"Osiągnąłeś swój miesięczny limit wydatków w wysokości ${{amount}}. Zarządzaj swoimi limitami tutaj: {{membersUrl}}",
"zen.api.error.modelDisabled": "Model jest wyłączony",
"zen.api.error.regionNotAllowed":
"Ten model jest hostowany w Chinach. Jeśli chcesz korzystać z tego modelu, włącz go w swoich ustawieniach: {{consoleGoUrl}}",
"zen.api.error.trialEnded":
"Bezpłatna promocja {{model}} dobiegła końca. Możesz dalej korzystać z modelu, subskrybując OpenCode Go - {{link}}",
@ -654,6 +655,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Użyj dostępnego salda po osiągnięciu limitów użycia",
"workspace.lite.subscription.selectProvider":
'Wybierz "OpenCode Go" jako dostawcę w konfiguracji opencode, aby używać modeli Go.',
"workspace.lite.providers.title": "Dostawcy",
"workspace.lite.providers.description": "Kontroluj, którzy dostawcy są używani do routingu.",
"workspace.lite.providers.useChina": "Włącz modele hostowane w Chinach",
"workspace.lite.black.message":
"Obecnie subskrybujesz OpenCode Black lub jesteś na liście oczekujących. Jeśli chcesz przejść na Go, najpierw anuluj subskrypcję.",
"workspace.lite.other.message":

View file

@ -254,7 +254,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "следующими исключениями",
"go.title": "OpenCode Go | Недорогие модели для кодинга для всех",
"go.banner.text": "MiniMax M3 получает 3x лимиты использования на ограниченное время",
"go.meta.description":
"Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash.",
"go.hero.title": "Недорогие модели для кодинга для всех",
@ -379,6 +378,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"Вы достигли ежемесячного лимита расходов в ${{amount}}. Управляйте лимитами здесь: {{membersUrl}}",
"zen.api.error.modelDisabled": "Модель отключена",
"zen.api.error.regionNotAllowed":
"Эта модель размещена в Китае. Если вы хотите использовать эту модель, включите её в настройках: {{consoleGoUrl}}",
"zen.api.error.trialEnded":
"Бесплатная акция для {{model}} завершена. Вы можете продолжить использование модели, подписавшись на OpenCode Go - {{link}}",
@ -660,6 +661,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Использовать доступный баланс после достижения лимитов",
"workspace.lite.subscription.selectProvider":
'Выберите "OpenCode Go" в качестве провайдера в настройках opencode для использования моделей Go.',
"workspace.lite.providers.title": "Провайдеры",
"workspace.lite.providers.description": "Управляйте провайдерами, используемыми для маршрутизации.",
"workspace.lite.providers.useChina": "Включить модели, размещенные в Китае",
"workspace.lite.black.message":
"Вы подписаны на OpenCode Black или находитесь в списке ожидания. Пожалуйста, сначала отмените подписку, если хотите перейти на Go.",
"workspace.lite.other.message":

View file

@ -249,7 +249,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "ข้อยกเว้นดังนี้",
"go.title": "OpenCode Go | โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
"go.banner.text": "MiniMax M3 เพิ่มโควตาการใช้งานเป็น 3 เท่าในช่วงเวลาจำกัด",
"go.meta.description":
"Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash",
"go.hero.title": "โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
@ -370,6 +369,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"คุณถึงขีดจำกัดการใช้จ่ายรายเดือนที่ ${{amount}} แล้ว จัดการขีดจำกัดของคุณที่นี่: {{membersUrl}}",
"zen.api.error.modelDisabled": "โมเดลถูกปิดใช้งาน",
"zen.api.error.regionNotAllowed":
"โมเดลนี้โฮสต์อยู่ในประเทศจีน หากคุณต้องการใช้โมเดลนี้ ให้เปิดใช้งานในการตั้งค่าของคุณ: {{consoleGoUrl}}",
"zen.api.error.trialEnded":
"โปรโมชันฟรีสำหรับ {{model}} สิ้นสุดแล้ว คุณสามารถใช้โมเดลต่อได้โดยสมัครสมาชิก OpenCode Go - {{link}}",
@ -649,6 +650,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "ใช้ยอดคงเหลือของคุณหลังจากถึงขีดจำกัดการใช้งาน",
"workspace.lite.subscription.selectProvider":
'เลือก "OpenCode Go" เป็นผู้ให้บริการในการตั้งค่า opencode ของคุณเพื่อใช้โมเดล Go',
"workspace.lite.providers.title": "ผู้ให้บริการ",
"workspace.lite.providers.description": "ควบคุมผู้ให้บริการที่ใช้สำหรับการกำหนดเส้นทาง",
"workspace.lite.providers.useChina": "เปิดใช้โมเดลที่โฮสต์ในจีน",
"workspace.lite.black.message":
"ขณะนี้คุณสมัครสมาชิก OpenCode Black หรืออยู่ในรายการรอ โปรดยกเลิกการสมัครก่อนหากต้องการเปลี่ยนไปใช้ Go",
"workspace.lite.other.message":

View file

@ -252,7 +252,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "aşağıdaki istisnalar",
"go.title": "OpenCode Go | Herkes için düşük maliyetli kodlama modelleri",
"go.banner.text": "MiniMax M3 sınırlı bir süre için 3x kullanım limiti sunuyor",
"go.meta.description":
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash için cömert 5 saatlik istek limitleri sunar.",
"go.hero.title": "Herkes için düşük maliyetli kodlama modelleri",
@ -377,6 +376,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"Aylık ${{amount}} harcama limitinize ulaştınız. Limitlerinizi buradan yönetin: {{membersUrl}}",
"zen.api.error.modelDisabled": "Model devre dışı",
"zen.api.error.regionNotAllowed":
"Bu model Çin'de barındırılıyor. Bu modeli kullanmak istiyorsanız ayarlarınızdan etkinleştirin: {{consoleGoUrl}}",
"zen.api.error.trialEnded":
"{{model}} için ücretsiz promosyon sona erdi. OpenCode Go'ya abone olarak modeli kullanmaya devam edebilirsiniz - {{link}}",
@ -656,6 +657,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "Kullanım limitlerine ulaştıktan sonra mevcut bakiyenizi kullanın",
"workspace.lite.subscription.selectProvider":
'Go modellerini kullanmak için opencode yapılandırmanızda "OpenCode Go"\'yu sağlayıcı olarak seçin.',
"workspace.lite.providers.title": "Sağlayıcılar",
"workspace.lite.providers.description": "Yönlendirme için hangi sağlayıcıların kullanılacağını kontrol edin.",
"workspace.lite.providers.useChina": "Çin'de barındırılan modelleri etkinleştir",
"workspace.lite.black.message":
"Şu anda OpenCode Black abonesisiniz veya bekleme listesindesiniz. Go'ya geçmek istiyorsanız lütfen önce aboneliğinizi iptal edin.",
"workspace.lite.other.message":

View file

@ -251,7 +251,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "такими винятками",
"go.title": "OpenCode Go | Недорогі моделі кодування для всіх",
"go.banner.text": "MiniMax M3 отримує 3x ліміти використання протягом обмеженого часу",
"go.meta.description":
"Go починається від $5 за перший місяць, потім $10/місяць, з generous 5-годинними лімітами запитів для GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash.",
"go.hero.title": "Недорогі моделі кодування для всіх",
@ -374,6 +373,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"Ви досягли місячного ліміту витрат ${{amount}}. Керуйте лімітами: {{membersUrl}}",
"zen.api.error.modelDisabled": "Модель вимкнено",
"zen.api.error.regionNotAllowed":
"Ця модель розміщена в Китаї. Якщо ви хочете використовувати цю модель, увімкніть її в налаштуваннях: {{consoleGoUrl}}",
"zen.api.error.trialEnded":
"Безкоштовна акція для {{model}} закінчилася. Ви можете продовжити використання, підписавшись на OpenCode Go — {{link}}",
@ -652,6 +653,9 @@ export const dict = {
"workspace.lite.subscription.resetsIn": "Скидається через",
"workspace.lite.subscription.useBalance": "Використовуйте доступний баланс після досягнення лімітів",
"workspace.lite.subscription.selectProvider": 'Виберіть "OpenCode Go" як провайдера в конфігурації opencode.',
"workspace.lite.providers.title": "Провайдери",
"workspace.lite.providers.description": "Керуйте провайдерами, які використовуються для маршрутизації.",
"workspace.lite.providers.useChina": "Увімкнути моделі, розміщені в Китаї",
"workspace.lite.black.message":
"Ви вже підписані на OpenCode Black або в списку очікування. Спочатку скасуйте підписку, якщо хочете перейти на Go.",
"workspace.lite.other.message": "Інший учасник цього робочого простору вже підписаний на OpenCode Go.",

View file

@ -240,7 +240,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "以下例外情况除外",
"go.title": "OpenCode Go | 人人可用的低成本编程模型",
"go.banner.text": "MiniMax M3 限时享受 3 倍使用额度",
"go.meta.description":
"Go 首月 $5之后 $10/月,提供对 GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小时充裕请求额度。",
"go.hero.title": "人人可用的低成本编程模型",
@ -356,6 +355,7 @@ export const dict = {
"您的工作区已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{billingUrl}}",
"zen.api.error.userMonthlyLimitReached": "您已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{membersUrl}}",
"zen.api.error.modelDisabled": "模型已禁用",
"zen.api.error.regionNotAllowed": "该模型部署在中国。如果你想使用该模型,请在设置中启用它:{{consoleGoUrl}}",
"zen.api.error.trialEnded": "{{model}} 的限免活动已结束。您可以订阅 OpenCode Go 继续使用该模型 - {{link}}",
"black.meta.title": "OpenCode Black | 访问全球顶尖编程模型",
@ -631,6 +631,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "达到使用限额后使用您的可用余额",
"workspace.lite.subscription.selectProvider":
"在你的 opencode 配置中选择「OpenCode Go」作为提供商即可使用 Go 模型。",
"workspace.lite.providers.title": "提供商",
"workspace.lite.providers.description": "控制用于路由的提供商。",
"workspace.lite.providers.useChina": "启用部署在中国的模型",
"workspace.lite.black.message": "您当前已订阅 OpenCode Black 或在候补名单中。如需切换到 Go请先取消订阅。",
"workspace.lite.other.message": "此工作区中的另一位成员已经订阅了 OpenCode Go。每个工作区只有一名成员可以订阅。",
"workspace.lite.promo.description":

View file

@ -240,7 +240,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "以下例外情況",
"go.title": "OpenCode Go | 低成本全民編碼模型",
"go.banner.text": "MiniMax M3 限時享有 3 倍使用額度",
"go.meta.description":
"Go 首月 $5之後 $10/月,提供對 GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小時充裕請求額度。",
"go.hero.title": "低成本全民編碼模型",
@ -356,6 +355,7 @@ export const dict = {
"你的工作區已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{billingUrl}}",
"zen.api.error.userMonthlyLimitReached": "你已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{membersUrl}}",
"zen.api.error.modelDisabled": "模型已停用",
"zen.api.error.regionNotAllowed": "此模型部署於中國。如果你想使用此模型,請在設定中啟用它:{{consoleGoUrl}}",
"zen.api.error.trialEnded": "{{model}} 的限免活动已結束。您可以訂閱 OpenCode Go 繼續使用該模型 - {{link}}",
"black.meta.title": "OpenCode Black | 存取全球最佳編碼模型",
@ -631,6 +631,9 @@ export const dict = {
"workspace.lite.subscription.useBalance": "達到使用限制後使用您的可用餘額",
"workspace.lite.subscription.selectProvider":
"在您的 opencode 設定中選擇「OpenCode Go」作為提供商即可使用 Go 模型。",
"workspace.lite.providers.title": "提供商",
"workspace.lite.providers.description": "控制用於路由的提供商。",
"workspace.lite.providers.useChina": "啟用部署在中國的模型",
"workspace.lite.black.message": "您目前已訂閱 OpenCode Black 或在候補名單中。若要切換至 Go請先取消訂閱。",
"workspace.lite.other.message": "此工作區中的另一位成員已訂閱 OpenCode Go。每個工作區只能有一位成員訂閱。",
"workspace.lite.promo.description":

View file

@ -0,0 +1,5 @@
export function countryFromRequest(request: Request | undefined) {
if (!request) return undefined
const cloudflareRequest = request as Request & { cf?: { country?: string } }
return cloudflareRequest.cf?.country ?? request.headers.get("cf-ipcountry") ?? undefined
}

View file

@ -327,37 +327,6 @@ body {
}
}
[data-component="desktop-app-banner"] {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 32px;
[data-slot="badge"] {
background: var(--color-background-strong);
color: var(--color-text-inverted);
font-weight: 500;
padding: 4px 8px;
line-height: 1;
flex-shrink: 0;
}
[data-slot="content"] {
display: flex;
align-items: center;
gap: 1ch;
}
[data-slot="text"] {
color: var(--color-text-strong);
line-height: 1.4;
@media (max-width: 30.625rem) {
display: none;
}
}
}
[data-slot="hero-copy"] {
img {
margin-bottom: 24px;
@ -663,10 +632,6 @@ body {
fill: var(--color-text-strong);
}
[data-bar][data-kind="promo"] {
fill: color-mix(in srgb, var(--bar-go) 50%, transparent);
}
[data-val] {
fill: var(--color-text-strong);
font-size: 13px;

View file

@ -64,10 +64,10 @@ function LimitsGraph(props: { href: string }) {
{ id: "glm-5.2", name: "GLM-5.2", req: 880, d: "100ms" },
{ id: "qwen3.7-max", name: "Qwen3.7 Max", req: 950, d: "110ms" },
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code", req: 1150, d: "150ms" },
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "210ms" },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "240ms" },
{ id: "qwen3.7-plus", name: "Qwen3.7 Plus", req: 4300, d: "250ms" },
{ id: "minimax-m3", name: "MiniMax M3 (3x usage)", req: 9600, baseReq: 3200, d: "280ms" },
{ id: "minimax-m3", name: "MiniMax M3", req: 3200, d: "210ms" },
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "240ms" },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "270ms" },
{ id: "qwen3.7-plus", name: "Qwen3.7 Plus", req: 4300, d: "300ms" },
{ id: "mimo-v2.5", name: "MiMo-V2.5", req: 30100, d: "340ms" },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", req: 31650, d: "340ms" },
]
@ -157,24 +157,12 @@ function LimitsGraph(props: { href: string }) {
<rect
x={left}
y={gy(i()) - bh / 2}
width={Math.max(0, x(ratio(m.baseReq ?? m.req)) - left)}
width={Math.max(0, x(ratio(m.req)) - left)}
height={bh}
data-bar
data-kind="go"
data-model={m.id}
data-segment={m.baseReq ? "base" : undefined}
/>
{m.baseReq && (
<rect
x={x(ratio(m.baseReq)) + 2}
y={gy(i()) - bh / 2}
width={Math.max(0, x(ratio(m.req)) - x(ratio(m.baseReq)) - 2)}
height={bh}
data-bar
data-kind="promo"
data-model={m.id}
/>
)}
</g>
)}
</For>
@ -264,12 +252,6 @@ export default function Home() {
<div data-component="content">
<section data-component="hero">
<div data-component="desktop-app-banner">
<span data-slot="badge">{i18n.t("home.banner.badge")}</span>
<div data-slot="content">
<span data-slot="text">{i18n.t("go.banner.text")}</span>
</div>
</div>
<div data-slot="hero-copy">
<img data-slot="zen logo light" src={goLogoLight} alt="" />
<img data-slot="zen logo dark" src={goLogoDark} alt="" />

View file

@ -3,7 +3,7 @@ import { z } from "zod"
import { Resource } from "@opencode-ai/console-resource"
import { safeEqual } from "@opencode-ai/console-core/util/crypto.js"
const DISCORD_ALERT_ROLE_ID = "1511795723262365887"
const DISCORD_ALERT_ROLE_ID = "1520924666359713863"
const basePayload = z.object({
name: z.string().optional(),

View file

@ -75,6 +75,40 @@
}
}
[data-slot="providers-section"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-top: var(--space-6);
padding-top: var(--space-6);
border-top: 1px solid var(--color-border-muted);
[data-slot="providers-header"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
h3 {
color: var(--color-text);
font-size: var(--font-size-lg);
font-weight: 600;
line-height: 1.2;
margin: 0;
}
p {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
line-height: 1.5;
margin: 0;
}
}
[data-slot="setting-row"] {
margin-top: 0;
}
}
[data-slot="toggle-label"] {
position: relative;
display: inline-block;

View file

@ -5,7 +5,9 @@ import { Modal } from "~/component/modal"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { Workspace } from "@opencode-ai/console-core/workspace.js"
import { Subscription } from "@opencode-ai/console-core/subscription.js"
import { LiteData } from "@opencode-ai/console-core/lite.js"
import { withActor } from "~/context/auth.withActor"
@ -16,6 +18,8 @@ import { useLanguage } from "~/context/language"
import { formError } from "~/lib/form-error"
import { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time"
import { createReferralFromCookie } from "~/lib/referral-invite"
import { getRequestEvent } from "solid-js/web"
import { countryFromRequest } from "~/lib/request-country"
import { IconAlipay, IconUpi } from "~/component/icon"
@ -34,9 +38,11 @@ export const queryLiteSubscription = query(async (workspaceID: string) => {
timeMonthlyUpdated: LiteTable.timeMonthlyUpdated,
timeCreated: LiteTable.timeCreated,
lite: BillingTable.lite,
region: WorkspaceTable.region,
})
.from(BillingTable)
.innerJoin(LiteTable, eq(LiteTable.workspaceID, BillingTable.workspaceID))
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, BillingTable.workspaceID))
.where(and(eq(LiteTable.workspaceID, Actor.workspace()), isNull(LiteTable.timeDeleted)))
.then((r) => r[0]),
)
@ -48,6 +54,8 @@ export const queryLiteSubscription = query(async (workspaceID: string) => {
return {
mine,
useBalance: row.lite?.useBalance ?? false,
region:
row.region ?? (await Workspace.setDefaultRegion({ country: countryFromRequest(getRequestEvent()?.request) })),
rollingUsage: Subscription.analyzeRollingUsage({
limit: limits.rollingLimit,
window: limits.rollingWindow,
@ -128,6 +136,24 @@ const setLiteUseBalance = action(async (form: FormData) => {
)
}, "setLiteUseBalance")
const setGoProviderRouting = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
const useChinaProviders = (form.get("useChinaProviders") as string | null) === "true"
return json(
await withActor(
() =>
Workspace.update({ region: useChinaProviders ? ["us", "eu", "sg"] : ["us", "eu", "sg", "cn"] })
.then(() => ({ error: undefined }))
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
{ revalidate: queryLiteSubscription.key },
)
}, "go.providerRouting.set")
function LiteUsageItem(props: { label: string; usage: { usagePercent: number; resetInSec: number } }) {
const i18n = useI18n()
@ -159,6 +185,7 @@ export function LiteSection(props: { lite: LiteSubscription | undefined }) {
const checkoutAction = useAction(createLiteCheckoutUrl)
const checkoutSubmission = useSubmission(createLiteCheckoutUrl)
const useBalanceSubmission = useSubmission(setLiteUseBalance)
const providerRoutingSubmission = useSubmission(setGoProviderRouting)
const [store, setStore] = createStore({
loading: undefined as undefined | "session" | "checkout" | "alipay" | "upi",
showModal: false,
@ -232,6 +259,28 @@ export function LiteSection(props: { lite: LiteSubscription | undefined }) {
<span></span>
</label>
</form>
{/*
<div data-slot="providers-section">
<div data-slot="providers-header">
<h3>{i18n.t("workspace.lite.providers.title")}</h3>
<p>{i18n.t("workspace.lite.providers.description")}</p>
</div>
<form action={setGoProviderRouting} method="post" data-slot="setting-row">
<p>{i18n.t("workspace.lite.providers.useChina")}</p>
<input type="hidden" name="workspaceID" value={params.id} />
<input type="hidden" name="useChinaProviders" value={sub().region.includes("cn") ? "true" : "false"} />
<label data-slot="toggle-label">
<input
type="checkbox"
checked={sub().region.includes("cn")}
disabled={providerRoutingSubmission.pending}
onChange={(e) => e.currentTarget.form?.requestSubmit()}
/>
<span></span>
</label>
</form>
</div>
*/}
</section>
)}
</Show>

View file

@ -43,6 +43,7 @@ const updateWorkspace = action(async (form: FormData) => {
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
{ revalidate: getWorkspaceInfo.key },
)
}, "workspace.update")

View file

@ -3,6 +3,7 @@ export class CreditsError extends Error {}
export class MonthlyLimitError extends Error {}
export class UserLimitError extends Error {}
export class ModelError extends Error {}
export class RegionError extends Error {}
class LimitError extends Error {
retryAfter?: number

View file

@ -21,6 +21,7 @@ import {
MonthlyLimitError,
UserLimitError,
ModelError,
RegionError,
RateLimitError,
FreeUsageLimitError,
GoUsageLimitError,
@ -49,6 +50,8 @@ import { createModelTpmLimiter } from "./modelTpmLimiter"
import { createModelTpsLimiter } from "./modelTpsLimiter"
import { createProviderBudgetTracker } from "./providerBudgetTracker"
import { accumulateUsage, HOT_WORKSPACES } from "./usageBatcher"
import { Workspace } from "@opencode-ai/console-core/workspace.js"
import { countryFromRequest } from "~/lib/request-country"
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
type RetryOptions = {
@ -125,6 +128,24 @@ export async function handler(
: createKeyRateLimiter(modelInfo.id, modelInfo.rateLimit, zenApiKey, input.request)
await rateLimiter?.check()
const authInfo = await authenticate(modelInfo, zenApiKey)
const allowedRegions = authInfo?.region
? authInfo.region
: await (async () => {
if (!authInfo) return
return Actor.provide("system", { workspaceID: authInfo.workspaceID }, () =>
Workspace.setDefaultRegion({ country: countryFromRequest(input.request) }),
)
})()
/*
if (true) {
if (!allowedRegions?.includes("unavailable"))
throw new RegionError(
t("zen.api.error.regionNotAllowed", {
consoleGoUrl: `https://opencode.ai/workspace/${authInfo.workspaceID}/go`,
}),
)
}
*/
const stickyId = sessionId ? sessionId : (authInfo?.workspaceID ?? ip)
const stickyTracker = createStickyTracker(modelInfo.id, modelInfo.stickyProvider, stickyId)
const stickyProvider = await stickyTracker?.get()
@ -137,7 +158,7 @@ export async function handler(
const providerBudgetTracker = createProviderBudgetTracker(
modelInfo.providers.map((provider) => ({ ...zenData.providers[provider.id], ...provider })),
)
const providerBudgetUsage = await providerBudgetTracker?.check()
const providerBudget = await providerBudgetTracker?.check()
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
const providerInfo = selectProvider(
@ -151,7 +172,7 @@ export async function handler(
stickyProvider,
modelTpmLimits,
modelTpsLimits,
providerBudgetUsage,
providerBudget,
)
validateModelSettings(billingSource, authInfo)
updateProviderKey(authInfo, providerInfo)
@ -201,7 +222,10 @@ export async function handler(
if (v === "$model") return headers.set(k, model)
if (v === "$request") return headers.set(k, requestId)
if (v === "$project") return headers.set(k, projectId)
if (v === "$workspace" && authInfo?.workspaceID) return headers.set(k, authInfo.workspaceID)
if (v === "$workspace") {
if (authInfo?.workspaceID) headers.set(k, authInfo.workspaceID)
return
}
headers.set(k, v)
})
headers.delete("host")
@ -213,6 +237,9 @@ export async function handler(
return headers
})(),
body: reqBody,
// Propagate caller disconnects to the upstream provider request so
// abandoned Console requests do not leave orphaned inference work open.
signal: input.request.signal,
})
if (providerInfo.id.startsWith("console.")) {
@ -281,7 +308,7 @@ export async function handler(
const costInfo = calculateCost(modelInfo, usageInfo)
await trialLimiter?.track(usageInfo)
await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
await providerBudgetTracker?.track(providerInfo.id, costInfo.totalCostInCent)
await providerBudgetTracker?.track(providerInfo.id, providerInfo.budgetPriority, costInfo.totalCostInCent)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo)
json.cost = calculateOccurredCost(billingSource, costInfo)
@ -308,9 +335,10 @@ export async function handler(
const streamConverter = createStreamPartConverter(providerInfo.format, opts.format)
const usageParser = providerInfo.createUsageParser()
const binaryDecoder = providerInfo.createBinaryStreamDecoder()
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
const stream = new ReadableStream({
start(c) {
const reader = res.body?.getReader()
reader = res.body?.getReader()
const decoder = new TextDecoder()
const encoder = new TextEncoder()
@ -342,7 +370,11 @@ export async function handler(
timestampLastByte,
usageInfo,
)
await providerBudgetTracker?.track(providerInfo.id, costInfo.totalCostInCent)
await providerBudgetTracker?.track(
providerInfo.id,
providerInfo.budgetPriority,
costInfo.totalCostInCent,
)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo)
const cost = calculateOccurredCost(billingSource, costInfo)
@ -392,6 +424,11 @@ export async function handler(
return pump()
},
cancel() {
// When the downstream caller stops reading, release the upstream
// response body instead of keeping the provider/inference stream alive.
return reader?.cancel()
},
})
return new Response(stream, {
status: resStatus,
@ -399,6 +436,15 @@ export async function handler(
headers: resHeaders,
})
} catch (error: any) {
// The caller disconnected before we finished. Because the outbound provider
// request shares input.request.signal, an aborted caller surfaces here as an
// AbortError. There is no client left to receive a body, so skip the error
// metric and 500 and return a quiet client-closed response.
if (input.request.signal.aborted || error?.name === "AbortError") {
logger.debug("REQUEST ABORTED BY CALLER")
return new Response(null, { status: 499 })
}
logger.metric({
"error.type": error.constructor.name,
"error.message": error.message,
@ -412,6 +458,15 @@ export async function handler(
} catch {}
}
if (error instanceof RegionError)
return new Response(
JSON.stringify({
type: "error",
error: { type: error.constructor.name, message: error.message },
}),
{ status: 403 },
)
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
if (
error instanceof AuthError ||
@ -509,7 +564,12 @@ export async function handler(
stickyProviderId: string | undefined,
modelTpmLimits: Record<string, number> | undefined,
modelTpsLimits: Record<string, { qualify: number; unqualify: number }> | undefined,
providerBudgetUsage: Record<string, number> | undefined,
providerBudget:
| {
qualify: (providerId: string, priority: number) => boolean
prefer: (providerId: string, priority: number) => boolean
}
| undefined,
) {
const modelProvider = (() => {
// Byok is top priority b/c if user set their own API key, we should use it
@ -527,67 +587,69 @@ export async function handler(
}))
}
if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
let topPriority = Infinity
const providers = allProviders
.filter((provider) => provider.weight !== 0)
.filter((provider) => !retry.excludeProviders.includes(provider.id))
.filter((provider) => {
if (provider.budgetMode !== "fill") return true
const budget = zenData.providers[provider.id]?.budget
if (budget === undefined) return false
return (providerBudgetUsage?.[provider.id] ?? 0) < centsToMicroCents(budget * 100)
})
.filter((provider) => {
if (!provider.tpmLimit) return true
const usage = modelTpmLimits?.[`${provider.id}/${provider.model}`] ?? 0
return usage < provider.tpmLimit * 1_000_000
})
.filter((provider) => {
if (!provider.tpsGoal) return true
const tps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? {
qualify: 0,
unqualify: 0,
}
const isLowTps = tps.qualify + tps.unqualify > 10 && tps.qualify < tps.unqualify
return !isLowTps
})
.map((provider) => {
topPriority = Math.min(topPriority, provider.priority)
return provider
})
.filter((p) => p.priority <= topPriority)
.flatMap((provider) => Array<typeof provider>(provider.weight).fill(provider))
// Use fallback provider if max retries reached
const fallbackProvider = allProviders.find((provider) => provider.id === modelInfo.fallbackProvider)
if (retry.retryCount === MAX_FAILOVER_RETRIES) return fallbackProvider
// Use the last 4 characters of session ID to select a provider
let h = 0
const l = stickyId.length
for (let i = l - 4; i < l; i++) {
h = (h * 31 + stickyId.charCodeAt(i)) | 0 // 32-bit int
}
const index = (h >>> 0) % providers.length // make unsigned + range 0..length-1
const provider = providers[index || 0]
let topPriority = Infinity
const providers = allProviders
.filter((provider) => provider.weight !== 0)
.filter((provider) => !retry.excludeProviders.includes(provider.id))
.filter((provider) => {
if (provider.budgetPriority === undefined) return true
if (!providerBudget) return true
return providerBudget.qualify(provider.id, provider.budgetPriority)
})
.filter((provider) => {
if (!provider.tpmLimit) return true
const usage = modelTpmLimits?.[`${provider.id}/${provider.model}`] ?? 0
return usage < provider.tpmLimit * 1_000_000
})
.filter((provider) => {
if (!provider.tpsGoal) return true
const tps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? {
qualify: 0,
unqualify: 0,
}
const isLowTps = tps.qualify + tps.unqualify > 10 && tps.qualify < tps.unqualify
return !isLowTps
})
.map((provider) => {
topPriority = Math.min(topPriority, provider.priority)
return provider
})
.filter((p) => p.priority <= topPriority)
.flatMap((provider) => Array<typeof provider>(provider.weight).fill(provider))
// sticky provider does not exist => use selected provider
if (!stickyProviderId) return provider
const stickProvider = allProviders.find((provider) => provider.id === stickyProviderId)
if (!stickProvider) return provider
// Use the last 4 characters of session ID to select a provider
let h = 0
const l = stickyId.length
for (let i = l - 4; i < l; i++) {
h = (h * 31 + stickyId.charCodeAt(i)) | 0 // 32-bit int
}
const index = (h >>> 0) % providers.length // make unsigned + range 0..length-1
const provider = providers[index || 0] ?? fallbackProvider
// stick provider exists + selected provider is API type => use sticky provider
if (!provider.tpsGoal) return stickProvider
// sticky provider does not exist => use selected provider
if (!stickyProviderId) return provider
const stickProvider = allProviders.find((provider) => provider.id === stickyProviderId)
if (!stickProvider) return provider
// stick provier exists + selected provider is GPU type + GPU not idle => use selected provider
const preferBudgetProvider =
provider.budgetPriority !== undefined && providerBudget?.prefer(provider.id, provider.budgetPriority)
const preferTpsProvider = (() => {
if (!provider.tpsGoal) return false
const tps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? {
qualify: 0,
unqualify: 0,
}
if (tps.qualify <= tps.unqualify * 3) return stickProvider
return tps.qualify > tps.unqualify * 3
})()
return provider
}
if (!preferBudgetProvider && !preferTpsProvider) return stickProvider
// fallback provider
return allProviders.find((provider) => provider.id === modelInfo.fallbackProvider)
return provider
})()
if (!modelProvider) throw new ModelError(t("zen.api.error.noProviderAvailable"))
@ -624,7 +686,10 @@ export async function handler(
tx
.select({
apiKey: KeyTable.id,
workspaceID: KeyTable.workspaceID,
workspace: {
id: WorkspaceTable.id,
region: WorkspaceTable.region,
},
billing: {
balance: BillingTable.balance,
paymentMethodID: BillingTable.paymentMethodID,
@ -702,13 +767,13 @@ export async function handler(
if (
modelInfo.id.startsWith("alpha-") &&
Resource.App.stage === "production" &&
!ADMIN_WORKSPACES.includes(data.workspaceID)
!ADMIN_WORKSPACES.includes(data.workspace.id)
)
throw new AuthError(t("zen.api.error.modelNotSupported", { model: modelInfo.id }))
logger.metric({
api_key: data.apiKey,
workspace: data.workspaceID,
workspace: data.workspace.id,
user_id: data.user.id,
...(() => {
if (data.billing.subscription)
@ -725,13 +790,14 @@ export async function handler(
return {
apiKeyId: data.apiKey,
workspaceID: data.workspaceID,
workspaceID: data.workspace.id,
region: data.workspace.region,
billing: data.billing,
user: data.user,
black: data.black,
lite: data.lite,
provider: data.provider,
isFree: ADMIN_WORKSPACES.includes(data.workspaceID),
isFree: ADMIN_WORKSPACES.includes(data.workspace.id),
isDisabled: !!data.timeDisabled,
}
}

View file

@ -20,7 +20,7 @@ export function createModelTpsLimiter(providers: { id: string; model: string; tp
)
const now = Date.now()
const currInterval = toInterval(new Date(now))
const prevInterval = toInterval(new Date(now - 60 * 1000))
const prevInterval = toInterval(new Date(now - 60_000))
return {
check: async () => {

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