Compare commits

..

4 Commits

Author SHA1 Message Date
Kit Langton
ba44d37ac0 chore(lockfile): update bun lock for platform-node 2026-03-16 19:51:11 -04:00
Kit Langton
e2d4e60eab refactor(skill): effectify discovery loading 2026-03-16 19:38:13 -04:00
Kit Langton
e21d892cff refactor(skill): effectify discovery service 2026-03-16 18:57:00 -04:00
Kit Langton
527956e3ec refactor(skill): effectify SkillService as scoped service
Convert Skill namespace to SkillService class with Effect.fn methods.
Scanning runs eagerly in layer constructor — needs lazy init to avoid
breaking unrelated tests (same pattern as FileService).
2026-03-16 16:18:44 -04:00
452 changed files with 10206 additions and 17478 deletions

4
.github/VOUCHED.td vendored
View File

@@ -10,9 +10,6 @@
adamdotdevin
-agusbasari29 AI PR slop
ariane-emory
-atharvau AI review spamming literally every PR
-danieljoshuanazareth
-danieljoshuanazareth
edemaine
-florianleibert
fwang
@@ -20,7 +17,6 @@ iamdavidhill
jayair
kitlangton
kommander
-opencode2026
r44vc0rp
rekram1-node
-spider-yamet clawdbot/llm psychosis, spam pinging the team

View File

@@ -11,7 +11,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
deploy:
runs-on: ubuntu-latest
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v3

View File

@@ -56,7 +56,7 @@ jobs:
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
# Extract hash from build log with portability
HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
if [ -z "$HASH" ]; then
echo "::error::Failed to compute hash for ${SYSTEM}"

View File

@@ -50,17 +50,20 @@ jobs:
e2e:
name: e2e (${{ matrix.settings.name }})
needs: unit
strategy:
fail-fast: false
matrix:
settings:
- name: linux
host: blacksmith-4vcpu-ubuntu-2404
playwright: bunx playwright install --with-deps
- name: windows
host: blacksmith-4vcpu-windows-2025
playwright: bunx playwright install
runs-on: ${{ matrix.settings.host }}
env:
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
PLAYWRIGHT_BROWSERS_PATH: 0
defaults:
run:
shell: bash
@@ -73,28 +76,9 @@ jobs:
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Read Playwright version
id: playwright-version
run: |
version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])')
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.playwright-browsers
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
- name: Install Playwright system dependencies
if: runner.os == 'Linux'
working-directory: packages/app
run: bunx playwright install-deps chromium
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: packages/app
run: bunx playwright install chromium
run: ${{ matrix.settings.playwright }}
- name: Run app e2e tests
run: bun --cwd packages/app test:e2e:local

View File

@@ -33,6 +33,6 @@ jobs:
with:
issue-id: ${{ github.event.issue.number }}
comment-id: ${{ github.event.comment.id }}
roles: admin,maintain,write
roles: admin,maintain
env:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}

View File

@@ -1,6 +1,4 @@
node_modules
plans
package.json
plans/
bun.lock
.gitignore
package-lock.json
package.json
package-lock.json

View File

@@ -1,7 +1,7 @@
---
description: Translate content for a specified locale while preserving technical terms
mode: subagent
model: opencode/gpt-5.4
model: opencode/gemini-3-pro
---
You are a professional translator and localization specialist.

View File

@@ -1,5 +0,0 @@
go through each PR merged since the last tag
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md
once that is done, read UPCOMING_CHANGELOG.md and group it into sections for better readability. make sure all PR references are preserved

View File

@@ -1,5 +1,7 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-pr-search.txt"
async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, {
...options,
@@ -22,16 +24,7 @@ interface PR {
}
export default tool({
description: `Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
description: DESCRIPTION,
args: {
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
limit: tool.schema.number().describe("Maximum number of results to return").default(10),

View File

@@ -0,0 +1,10 @@
Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.

View File

@@ -1,5 +1,7 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-triage.txt"
const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"],
@@ -38,12 +40,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
}
export default tool({
description: `Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
description: DESCRIPTION,
args: {
assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]])

View File

@@ -0,0 +1,6 @@
Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.

View File

@@ -137,4 +137,4 @@ OpenCode 内置两种 Agent可用 `Tab` 键快速切换:
---
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)

View File

@@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent您可以使用 `Tab` 鍵快速切換。
---
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)

148
bun.lock
View File

@@ -26,7 +26,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.3.1",
"version": "1.2.27",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -41,14 +41,12 @@
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
"@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "catalog:",
"effect": "4.0.0-beta.31",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:",
@@ -79,7 +77,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.3.1",
"version": "1.2.27",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -113,7 +111,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.3.1",
"version": "1.2.27",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -130,7 +128,7 @@
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "22.0.2",
"@types/bun": "catalog:",
"@types/bun": "1.3.0",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "catalog:",
@@ -140,7 +138,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.3.1",
"version": "1.2.27",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -164,7 +162,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.3.1",
"version": "1.2.27",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -188,7 +186,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.3.1",
"version": "1.2.27",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -221,7 +219,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.3.1",
"version": "1.2.27",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -229,7 +227,7 @@
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
"effect": "catalog:",
"effect": "4.0.0-beta.31",
"electron-log": "^5",
"electron-store": "^10",
"electron-updater": "^6",
@@ -252,7 +250,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.3.1",
"version": "1.2.27",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -281,7 +279,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.3.1",
"version": "1.2.27",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -297,7 +295,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.3.1",
"version": "1.2.27",
"bin": {
"opencode": "./bin/opencode",
},
@@ -326,7 +324,9 @@
"@ai-sdk/xai": "2.0.51",
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@effect/platform-node": "4.0.0-beta.31",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
@@ -338,8 +338,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.90",
"@opentui/solid": "0.1.90",
"@opentui/core": "0.1.87",
"@opentui/solid": "0.1.87",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -352,13 +352,11 @@
"bun-pty": "0.4.8",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"cross-spawn": "^7.0.6",
"decimal.js": "10.5.0",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "5.3.2",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -369,8 +367,6 @@
"mime-types": "3.0.2",
"minimatch": "10.0.3",
"open": "10.1.2",
"opencode-gitlab-auth": "2.0.0",
"opencode-poe-auth": "0.0.1",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"remeda": "catalog:",
@@ -405,15 +401,14 @@
"@tsconfig/bun": "catalog:",
"@types/babel__core": "7.20.5",
"@types/bun": "catalog:",
"@types/cross-spawn": "6.0.6",
"@types/mime-types": "3.0.1",
"@types/semver": "^7.5.8",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
@@ -422,7 +417,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.3.1",
"version": "1.2.27",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -446,7 +441,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.3.1",
"version": "1.2.27",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -457,7 +452,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.3.1",
"version": "1.2.27",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -492,7 +487,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.3.1",
"version": "1.2.27",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -538,7 +533,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.3.1",
"version": "1.2.27",
"dependencies": {
"zod": "catalog:",
},
@@ -549,7 +544,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.3.1",
"version": "1.2.27",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -589,8 +584,6 @@
],
"patchedDependencies": {
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
},
"overrides": {
@@ -599,7 +592,6 @@
},
"catalog": {
"@cloudflare/workers-types": "4.20251008.0",
"@effect/platform-node": "4.0.0-beta.35",
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
"@octokit/rest": "22.0.0",
@@ -613,7 +605,7 @@
"@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.11",
"@types/bun": "1.3.9",
"@types/luxon": "3.7.1",
"@types/node": "22.13.9",
"@types/semver": "7.7.1",
@@ -621,9 +613,9 @@
"ai": "5.0.124",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.35",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "4.0.0-beta.31",
"fuzzysort": "3.1.0",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -981,9 +973,9 @@
"@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.35", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.35", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.35", "ioredis": "^5.7.0" } }, "sha512-HPc2xZASl9F9y/xJ01bQgFD6Jf9XP4Fcv/BlVTvG0Yr/uN63lwKZYr/VXor5K5krHfBDeCBD8y7/SICPYZoq3A=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.31", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.31", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.31", "ioredis": "^5.7.0" } }, "sha512-KmVZwGsQRBMZZYPJwpL2vj6sxjBzfXhyA8RgsH5/cmckDTsZpVTyqODQ/FFzmCnMWuYjZoJGPghTDrVVDn/6ZA=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.35", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.35" } }, "sha512-9bPqNV988itKJ7MQoJuzmR014DB9EZRDOnhJt/+iJlb8qLoR9HnCzNJb9gfBdYhFmVYc8DMsQxG81rdJzpv9tg=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.33", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.33" } }, "sha512-jaJnvYz1IiPZyN//fCJsvwnmujJS5KD8noCVVLhb4ZGCWKhQpt0x2iuax6HFzMlPEQSfl04GLU+PVKh0nkzPyA=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
@@ -1113,6 +1105,10 @@
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.6.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": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="],
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
@@ -1449,21 +1445,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.90", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.90", "@opentui/core-darwin-x64": "0.1.90", "@opentui/core-linux-arm64": "0.1.90", "@opentui/core-linux-x64": "0.1.90", "@opentui/core-win32-arm64": "0.1.90", "@opentui/core-win32-x64": "0.1.90", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Os2dviqWVETU3kaK36lbSvdcI93GAWhw0xb9ng/d0DWYuM9scRmAhLHiOayp61saWv/BR8OJXeuQYHvrp5rd6A=="],
"@opentui/core": ["@opentui/core@0.1.87", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.87", "@opentui/core-darwin-x64": "0.1.87", "@opentui/core-linux-arm64": "0.1.87", "@opentui/core-linux-x64": "0.1.87", "@opentui/core-win32-arm64": "0.1.87", "@opentui/core-win32-x64": "0.1.87", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dhsmMv0IqKftwG7J/pBrLBj2armsYIg5R3LBvciRQI/6X89GufP4l1u0+QTACAx6iR4SYJJNVNQ2tdX8LM9rMw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.90", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XFrm2zCg1SlHPQ5A2HX/I4dCrmTjYaCJIIpo3QuPIvZBGH3aBMdWDJh2tXw7AB5Mmh8X1K4hDkP5nlK9x0Ewow=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.87", "", { "os": "darwin", "cpu": "arm64" }, "sha512-G8oq85diOfkU6n0T1CxCle7oDmpKxwhcdhZ9khBMU5IrfLx9ZDuCM3F6MsiRQWdvPPCq2oomNbd64bYkPamYgw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.90", "", { "os": "darwin", "cpu": "x64" }, "sha512-vbDpUsnlZ+0CeVKyBBXE+l2+X1XoVncMxMOhXTiMtud2/Cwu+Vfs/g3LC/6Zv08yaytA+9g7Z8sdf0QCqFyQ4w=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.87", "", { "os": "darwin", "cpu": "x64" }, "sha512-MYTFQfOHm6qO7YaY4GHK9u/oJlXY6djaaxl5I+k4p2mk3vvuFIl/AP1ypITwBFjyV5gyp7PRWFp4nGfY9oN8bw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.90", "", { "os": "linux", "cpu": "arm64" }, "sha512-OTbvBTP5mVQ4uwKyuz6b59ElG+D0i1Ln+q6cVhNkLgeRLySIn1uXEzUFQGlnVgb8lFDANsn3yQmdv+R+Cpw0og=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.87", "", { "os": "linux", "cpu": "arm64" }, "sha512-he8o1h5M6oskRJ7wE+xKJgmWnv5ZwN6gB3M/Z+SeHtOMPa5cZmi3TefTjG54llEgFfx0F9RcqHof7TJ/GNxRkw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.90", "", { "os": "linux", "cpu": "x64" }, "sha512-2PJi/LLlO7tGk9Ful/n+6iBdg1RFrA9ibU7wVneE6Z1P0LCYeu7bpwMzea1TXL0eAQWPHsjTs9aPlqPxln0EJw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.87", "", { "os": "linux", "cpu": "x64" }, "sha512-aiUwjPlH4yDcB8/6YDKSmMkaoGAAltL0Xo0AzXyAtJXWK5tkCSaYjEVwzJ/rYRkr4Magnad+Mjth4AQUWdR2AA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.90", "", { "os": "win32", "cpu": "arm64" }, "sha512-+sTRaOb7gCMZ6iLuuG4y9kzyweJzBDcIJN0Xh49ikFWTwVECDXEVtXahNGlw57avm2yYUoNzmpBjK/LV7zBj9A=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.87", "", { "os": "win32", "cpu": "arm64" }, "sha512-cmP0pOyREjWGniHqbDmaMY7U+1AyagrD8VseJbU0cGpNgVpG2/gbrJUGdfdLB0SNb+mzLdx6SOjdxtrElwRCQA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.90", "", { "os": "win32", "cpu": "x64" }, "sha512-aVFyErckWp4oW9NJ/ZDKBUAlTlfVUiRXGP63JXFOoeqI7EYaM8uBt6rgZAJuUdFWCN2Q66WRS8Y2mk+0BJwVBg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.87", "", { "os": "win32", "cpu": "x64" }, "sha512-N2GErAAP8iODf2RPp86pilPaVKiD6G4pkpZL5nLGbKsl0bndrVTpSqZcn8+/nQwFZDPD/AsiRTYNOfWOblhzOw=="],
"@opentui/solid": ["@opentui/solid@0.1.90", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.90", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-zEHDpJOTGS707ts5j4diqoWuFLSqV6yARKl1H0FJkwWOotu+rxCyksL+C0gX0jJUonAw2cjlZ2NNtZY8g78zkg=="],
"@opentui/solid": ["@opentui/solid@0.1.87", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.87", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-lRT9t30l8+FtgOjjWJcdb2MT6hP8/RKqwGgYwTI7fXrOqdhxxwdP2SM+rH2l3suHeASheiTdlvPAo230iUcsvg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1891,8 +1887,6 @@
"@solid-primitives/storage": ["@solid-primitives/storage@4.3.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "@tauri-apps/plugin-store": "*", "solid-js": "^1.6.12" }, "optionalPeers": ["@tauri-apps/plugin-store"] }, "sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw=="],
"@solid-primitives/timer": ["@solid-primitives/timer@1.4.4", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ayjyb3+v1hyU92vuLUN0tVHq2mmTCPGxSDLGJMsDydRqx9ZfJIc9xj6cxK4XvdY3pif3ps2mIv52pjgToybEpQ=="],
"@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Za2JebEiDyfamjmDwRaESYqBBYOlgYGzB8kHYH0QrkXyLf2qNADlKdGN+z3vWSLCTDcKxChS43Kssjuc0OZhng=="],
"@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="],
@@ -1971,14 +1965,10 @@
"@tanstack/directive-functions-plugin": ["@tanstack/directive-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/router-utils": "1.133.19", "babel-dead-code-elimination": "^1.0.10", "pathe": "^2.0.3", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "vite": ">=6.0.0 || >=7.0.0" } }, "sha512-J3oawV8uBRBbPoLgMdyHt+LxzTNuWRKNJJuCLWsm/yq6v0IQSvIVCgfD2+liIiSnDPxGZ8ExduPXy8IzS70eXw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.91.2", "", {}, "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw=="],
"@tanstack/router-utils": ["@tanstack/router-utils@1.133.19", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.5", "@babel/preset-typescript": "^7.27.1", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-WEp5D2gPxvlLDRXwD/fV7RXjYtqaqJNXKB/L6OyZEbT+9BG/Ib2d7oG9GSUZNNMGPGYAlhBUOi3xutySsk6rxA=="],
"@tanstack/server-functions-plugin": ["@tanstack/server-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/directive-functions-plugin": "1.134.5", "babel-dead-code-elimination": "^1.0.9", "tiny-invariant": "^1.3.3" } }, "sha512-2sWxq70T+dOEUlE3sHlXjEPhaFZfdPYlWTSkHchWXrFGw2YOAa+hzD6L9wHMjGDQezYd03ue8tQlHG+9Jzbzgw=="],
"@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
"@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
"@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="],
@@ -2061,7 +2051,7 @@
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
@@ -2069,8 +2059,6 @@
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/cross-spawn": ["@types/cross-spawn@6.0.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
@@ -2457,7 +2445,7 @@
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
@@ -2743,9 +2731,9 @@
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
"drizzle-kit": ["drizzle-kit@1.0.0-beta.19-d95b7a4", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-M0sqc+42TYBod6kEZ3AsW6+JWe3+76gR1aDFbHH5DmuLKEwewmbzlhBG6qnvV6YA1cIIbkuam3dC7r6PREOCXw=="],
"drizzle-kit": ["drizzle-kit@1.0.0-beta.16-ea816b6", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GiJQqCNPZP8Kk+i7/sFa3rtXbq26tLDNi3LbMx9aoLuwF2ofk8CS7cySUGdI+r4J3q0a568quC8FZeaFTCw4IA=="],
"drizzle-orm": ["drizzle-orm@1.0.0-beta.19-d95b7a4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-bZZKKeoRKrMVU6zKTscjrSH0+WNb1WEi3N0Jl4wEyQ7aQpTgHzdYY6IJQ1P0M74HuSJVeX4UpkFB/S6dtqLEJg=="],
"drizzle-orm": ["drizzle-orm@1.0.0-beta.16-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="],
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
@@ -2759,7 +2747,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.35", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-64j8dgJmoEMeq6Y3WLYcZIRqPZ5E/lqnULCf6QW5te3hQ/sa13UodWLGwBEviEqBoq72U8lArhVX+T7ntzhJGQ=="],
"effect": ["effect@4.0.0-beta.31", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-w3QwJnlaLtWWiUSzhCXUTIisnULPsxLzpO6uqaBFjXybKx6FvCqsLJT6v4dV7G9eA9jeTtG6Gv7kF+jGe3HxzA=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -3027,8 +3015,6 @@
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="],
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
@@ -3037,8 +3023,6 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@5.3.2", "", { "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": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-EiAipDMa4Ngsxp4MMaua5YHWsHhc9kGXKmBxulJg1Gueb+5IZmMwxaVtgWTGWZITxC3tzKEeRt/3U4McE2vTIA=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -3791,10 +3775,6 @@
"opencode": ["opencode@workspace:packages/opencode"],
"opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-jmZOOvYIurRScQCtdBqIW5HbP1JbmIiq7UtI7NGgn2vjke46g9d4NVPBg5/ZmFFVIBwZcgyFgJ7b8kGEOR9ujA=="],
"opencode-poe-auth": ["opencode-poe-auth@0.0.1", "", { "dependencies": { "open": "^10.0.0", "poe-oauth": "*" }, "peerDependencies": { "@opencode-ai/plugin": "*" } }, "sha512-cXqTlS6AXHzo1oBdosnxbT47ZJEZ9WXn050X8Re6wZ1vaNnTpB/l2fMQt90evT7RBK0fB8UjXQUDMKyd7bbiqg=="],
"opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="],
"openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="],
@@ -3927,8 +3907,6 @@
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
"poe-oauth": ["poe-oauth@0.0.3", "", {}, "sha512-KgxDylcuq/mov8URSplrBGjrIjkQwjN/Ml8BhqaGsAvHzYN3yhuROdv1sDRfwqncg7TT8XzJvMeJAWmv/4NDLw=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
@@ -4125,8 +4103,6 @@
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
"restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="],
@@ -4259,7 +4235,7 @@
"socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="],
"socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="],
"socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="],
"socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="],
@@ -5039,8 +5015,6 @@
"@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@effect/platform-node/undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="],
"@effect/platform-node-shared/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
@@ -5073,6 +5047,10 @@
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
"@gitlab/gitlab-ai-provider/openai": ["openai@6.27.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ=="],
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
"@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@@ -5211,6 +5189,8 @@
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.9", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.1" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.8" }, "optionalPeers": ["solid-js"] }, "sha512-pCnxWrciluXCeli/dj5PIEHgbNzim3evtTn12snjqqg8QZWJNMjH1AWIp4iG/tbVjqQ72aBEymMSagvmgxubXw=="],
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
@@ -5399,8 +5379,6 @@
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"db0/drizzle-orm": ["drizzle-orm@1.0.0-beta.16-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="],
"defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
"dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
@@ -5467,10 +5445,6 @@
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"gitlab-ai-provider/openai": ["openai@6.32.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-j3k+BjydAf8yQlcOI7WUQMQTbbF5GEIMAE2iZYCOzwwB3S2pCheaWYp+XZRNAch4jWVc52PMDGRRjutao3lLCg=="],
"gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -5547,10 +5521,6 @@
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"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=="],
"opencode-poe-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=="],
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
@@ -6301,10 +6271,6 @@
"node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"opencode-poe-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1773909469,
"narHash": "sha256-vglVrLfHjFIzIdV9A27Ugul6rh3I1qHbbitGW7dk420=",
"lastModified": 1772091128,
"narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7149c06513f335be57f26fcbbbe34afda923882b",
"rev": "3f0336406035444b4a24b942788334af5f906259",
"type": "github"
},
"original": {

View File

@@ -122,7 +122,6 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
properties: {
product: zenLiteProduct.id,
price: zenLitePrice.id,
priceInr: 92900,
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
},
})
@@ -202,10 +201,6 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
handler: "packages/console/function/src/log-processor.ts",
link: [new sst.Secret("HONEYCOMB_API_KEY")],
@@ -224,9 +219,6 @@ new sst.cloudflare.x.SolidStart("Console", {
EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
SALESFORCE_CLIENT_ID,
SALESFORCE_CLIENT_SECRET,
SALESFORCE_INSTANCE_URL,
ZEN_BLACK_PRICE,
ZEN_LITE_PRICE,
new sst.Secret("ZEN_LIMITS"),

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-SXuq4e2Nl7qpTeTo9aOJqVhYdDdUPHAGUbRvKGN7bjo=",
"aarch64-linux": "sha256-2i+Uhz4GjL/eHd2L9/N/igEeY4wwhtZ9qwR9xXnQKyc=",
"aarch64-darwin": "sha256-s+v8PPzZYCm5Oa0pgUmBuGK9B3D5rFMcwLd9s1nBnkg=",
"x86_64-darwin": "sha256-YDAY3KM1d01Yh8/YqVRZC1dMKBDegiPtUCPg3rsZ1oo="
"x86_64-linux": "sha256-WJgo6UclmtQOEubnKMZybdIEhZ1uRTucF61yojjd+l0=",
"aarch64-linux": "sha256-QfZ/g7EZFpe6ndR3dG8WvVfMj5Kyd/R/4kkTJfGJxL4=",
"aarch64-darwin": "sha256-ezr/R70XJr9eN5l3mgb7HzLF6QsofNEKUOtuxbfli80=",
"x86_64-darwin": "sha256-MbsBGS415uEU/n1RQ/5H5pqh+udLY3+oimJ+eS5uJVI="
}
}

View File

@@ -4,12 +4,11 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.11",
"packageManager": "bun@1.3.10",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"typecheck": "bun turbo typecheck",
"prepare": "husky",
@@ -25,8 +24,7 @@
"packages/slack"
],
"catalog": {
"@effect/platform-node": "4.0.0-beta.35",
"@types/bun": "1.3.11",
"@types/bun": "1.3.9",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"ulid": "3.0.1",
@@ -43,9 +41,9 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.35",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "4.0.0-beta.31",
"ai": "5.0.124",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -112,8 +110,6 @@
},
"patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch"
}
}

View File

@@ -174,8 +174,6 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
- In terminal tests, type through the browser. Do not write to the PTY through the SDK.
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
- After opening the terminal, use `waitTerminalFocusIdle(...)` before the next keyboard action when prompt focus or keyboard routing matters.
- This avoids racing terminal mount, focus handoff, and prompt readiness when the next step types or sends shortcuts.
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
### Wait on state
@@ -184,9 +182,6 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
- Avoid race-prone flows that assume work is finished after an action
- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers
- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state
- Prefer semantic app state over transient DOM visibility when behavior depends on active selection, focus ownership, or async retry loops
- Do not treat a visible element as proof that the app will route the next action to it
- When fixing a flake, validate with `--repeat-each` and multiple workers when practical
### Add hooks
@@ -194,16 +189,11 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts`
- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony
- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI
- Add minimal test-only probes for semantic state like the active list item or selected command when DOM intermediates are unstable
- Prefer probing committed app state over asserting on transient highlight, visibility, or animation states
### Prefer helpers
- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise
- Use direct locators when the interaction is simple and a helper would not add clarity
- Prefer helpers that both perform an action and verify the app consumed it
- Avoid composing helpers redundantly when one already includes the other or already waits for the resulting state
- If a helper already covers the required wait or verification, use it directly instead of layering extra clicks, keypresses, or assertions
## Writing New Tests

View File

@@ -1,4 +1,3 @@
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { expect, type Locator, type Page } from "@playwright/test"
import fs from "node:fs/promises"
import os from "node:os"
@@ -9,7 +8,6 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import {
dropdownMenuTriggerSelector,
dropdownMenuContentSelector,
projectSwitchSelector,
projectMenuTriggerSelector,
projectCloseMenuSelector,
projectWorkspacesToggleSelector,
@@ -18,22 +16,11 @@ import {
listItemSelector,
listItemKeySelector,
listItemKeyStartsWithSelector,
promptSelector,
terminalSelector,
workspaceItemSelector,
workspaceMenuTriggerSelector,
} from "./selectors"
const phase = new WeakMap<Page, "test" | "cleanup">()
export function setHealthPhase(page: Page, value: "test" | "cleanup") {
phase.set(page, value)
}
export function healthPhase(page: Page) {
return phase.get(page) ?? "test"
}
export async function defocus(page: Page) {
await page
.evaluate(() => {
@@ -74,15 +61,6 @@ async function terminalReady(page: Page, term?: Locator) {
}, id)
}
async function terminalFocusIdle(page: Page, term?: Locator) {
const next = term ?? page.locator(terminalSelector).first()
const id = await terminalID(next)
return page.evaluate((id) => {
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
return (state?.focusing ?? 0) === 0
}, id)
}
async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
const next = input.term ?? page.locator(terminalSelector).first()
const id = await terminalID(next)
@@ -95,29 +73,6 @@ async function terminalHas(page: Page, input: { term?: Locator; token: string })
)
}
async function promptSlashActive(page: Page, id: string) {
return page.evaluate((id) => {
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
if (state?.popover !== "slash") return false
if (!state.slash.ids.includes(id)) return false
return state.slash.active === id
}, id)
}
async function promptSlashSelects(page: Page) {
return page.evaluate(() => {
return (window as E2EWindow).__opencode_e2e?.prompt?.current?.selects ?? 0
})
}
async function promptSlashSelected(page: Page, input: { id: string; count: number }) {
return page.evaluate((input) => {
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
if (!state) return false
return state.selected === input.id && state.selects >= input.count
}, input)
}
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const timeout = input?.timeout ?? 10_000
@@ -126,43 +81,6 @@ export async function waitTerminalReady(page: Page, input?: { term?: Locator; ti
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
}
export async function waitTerminalFocusIdle(page: Page, input?: { term?: Locator; timeout?: number }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const timeout = input?.timeout ?? 10_000
await waitTerminalReady(page, { term, timeout })
await expect.poll(() => terminalFocusIdle(page, term), { timeout }).toBe(true)
}
export async function showPromptSlash(
page: Page,
input: { id: string; text: string; prompt?: Locator; timeout?: number },
) {
const prompt = input.prompt ?? page.locator(promptSelector)
const timeout = input.timeout ?? 10_000
await expect
.poll(
async () => {
await prompt.click().catch(() => false)
await prompt.fill(input.text).catch(() => false)
return promptSlashActive(page, input.id).catch(() => false)
},
{ timeout },
)
.toBe(true)
}
export async function runPromptSlash(
page: Page,
input: { id: string; text: string; prompt?: Locator; timeout?: number },
) {
const prompt = input.prompt ?? page.locator(promptSelector)
const timeout = input.timeout ?? 10_000
const count = await promptSlashSelects(page)
await showPromptSlash(page, input)
await prompt.press("Enter")
await expect.poll(() => promptSlashSelected(page, { id: input.id, count: count + 1 }), { timeout }).toBe(true)
}
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
const term = input.term ?? page.locator(terminalSelector).first()
const timeout = input.timeout ?? 10_000
@@ -175,9 +93,9 @@ export async function runTerminal(page: Page, input: { cmd: string; token: strin
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
}
export async function openPalette(page: Page, key = "K") {
export async function openPalette(page: Page) {
await defocus(page)
await page.keyboard.press(`${modKey}+${key}`)
await page.keyboard.press(`${modKey}+P`)
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
@@ -207,49 +125,9 @@ export async function closeDialog(page: Page, dialog: Locator) {
}
export async function isSidebarClosed(page: Page) {
const button = await waitSidebarButton(page, "isSidebarClosed")
return (await button.getAttribute("aria-expanded")) !== "true"
}
async function errorBoundaryText(page: Page) {
const title = page.getByRole("heading", { name: /something went wrong/i }).first()
if (!(await title.isVisible().catch(() => false))) return
const description = await page
.getByText(/an error occurred while loading the application\./i)
.first()
.textContent()
.catch(() => "")
const detail = await page
.getByRole("textbox", { name: /error details/i })
.first()
.inputValue()
.catch(async () =>
(
(await page
.getByRole("textbox", { name: /error details/i })
.first()
.textContent()
.catch(() => "")) ?? ""
).trim(),
)
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
}
export async function assertHealthy(page: Page, context: string) {
const text = await errorBoundaryText(page)
if (!text) return
console.log(`[e2e:error-boundary][${context}]\n${text}`)
throw new Error(`Error boundary during ${context}\n${text}`)
}
async function waitSidebarButton(page: Page, context: string) {
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const boundary = page.getByRole("heading", { name: /something went wrong/i }).first()
await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 })
await assertHealthy(page, context)
return button
await expect(button).toBeVisible()
return (await button.getAttribute("aria-expanded")) !== "true"
}
export async function toggleSidebar(page: Page) {
@@ -260,7 +138,7 @@ export async function toggleSidebar(page: Page) {
export async function openSidebar(page: Page) {
if (!(await isSidebarClosed(page))) return
const button = await waitSidebarButton(page, "openSidebar")
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await button.click()
const opened = await expect(button)
@@ -277,7 +155,7 @@ export async function openSidebar(page: Page) {
export async function closeSidebar(page: Page) {
if (await isSidebarClosed(page)) return
const button = await waitSidebarButton(page, "closeSidebar")
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await button.click()
const closed = await expect(button)
@@ -292,7 +170,6 @@ export async function closeSidebar(page: Page) {
}
export async function openSettings(page: Page) {
await assertHealthy(page, "openSettings")
await defocus(page)
const dialog = page.getByRole("dialog")
@@ -305,8 +182,6 @@ export async function openSettings(page: Page) {
if (opened) return dialog
await assertHealthy(page, "openSettings")
await page.getByRole("button", { name: "Settings" }).first().click()
await expect(dialog).toBeVisible()
return dialog
@@ -368,12 +243,10 @@ export async function seedProjects(page: Page, input: { directory: string; extra
export async function createTestProject() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
const id = `e2e-${path.basename(root)}`
await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`)
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
execSync("git init", { cwd: root, stdio: "ignore" })
await fs.writeFile(path.join(root, ".git", "opencode"), id)
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
execSync("git add -A", { cwd: root, stdio: "ignore" })
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
@@ -395,24 +268,12 @@ export function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
}
async function probeSession(page: Page) {
return page
.evaluate(() => {
const win = window as E2EWindow
const current = win.__opencode_e2e?.model?.current
if (!current) return null
return { dir: current.dir, sessionID: current.sessionID }
})
.catch(() => null as { dir?: string; sessionID?: string } | null)
}
export async function waitSlug(page: Page, skip: string[] = []) {
let prev = ""
let next = ""
await expect
.poll(
async () => {
await assertHealthy(page, "waitSlug")
() => {
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (skip.includes(slug)) return ""
@@ -430,94 +291,6 @@ export async function waitSlug(page: Page, skip: string[] = []) {
return next
}
export async function resolveSlug(slug: string) {
const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
const resolved = await resolveDirectory(directory)
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
}
export async function waitDir(page: Page, directory: string) {
const target = await resolveDirectory(directory)
await expect
.poll(
async () => {
await assertHealthy(page, "waitDir")
const slug = slugFromUrl(page.url())
if (!slug) return ""
return resolveSlug(slug)
.then((item) => item.directory)
.catch(() => "")
},
{ timeout: 45_000 },
)
.toBe(target)
return { directory: target, slug: base64Encode(target) }
}
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
const target = await resolveDirectory(input.directory)
await expect
.poll(
async () => {
await assertHealthy(page, "waitSession")
const slug = slugFromUrl(page.url())
if (!slug) return false
const resolved = await resolveSlug(slug).catch(() => undefined)
if (!resolved || resolved.directory !== target) return false
if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
const state = await probeSession(page)
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
if (state?.dir) {
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
if (dir !== target) return false
}
return page
.locator(promptSelector)
.first()
.isVisible()
.catch(() => false)
},
{ timeout: 45_000 },
)
.toBe(true)
return { directory: target, slug: base64Encode(target) }
}
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
const sdk = createSdk(directory)
const target = await resolveDirectory(directory)
await expect
.poll(
async () => {
const data = await sdk.session
.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined)
if (!data?.directory) return ""
return resolveDirectory(data.directory).catch(() => data.directory)
},
{ timeout },
)
.toBe(target)
await expect
.poll(
async () => {
const items = await sdk.session
.messages({ sessionID, limit: 20 })
.then((x) => x.data ?? [])
.catch(() => [])
return items.some((item) => item.info.role === "user")
},
{ timeout },
)
.toBe(true)
}
export function sessionIDFromUrl(url: string) {
const match = /\/session\/([^/?#]+)/.exec(url)
return match?.[1]
@@ -929,14 +702,8 @@ export async function openStatusPopover(page: Page) {
}
export async function openProjectMenu(page: Page, projectSlug: string) {
await openSidebar(page)
const item = page.locator(projectSwitchSelector(projectSlug)).first()
await expect(item).toBeVisible()
await item.hover()
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
await expect(trigger).toHaveCount(1)
await expect(trigger).toBeVisible()
const menu = page
.locator(dropdownMenuContentSelector)
@@ -945,7 +712,7 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
const clicked = await trigger
.click({ force: true, timeout: 1500 })
.click({ timeout: 1500 })
.then(() => true)
.catch(() => false)

View File

@@ -3,11 +3,8 @@ import { serverNamePattern } from "../utils"
test("home renders and shows core entrypoints", async ({ page }) => {
await page.goto("/")
const nav = page.locator('[data-component="sidebar-nav-desktop"]')
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
await expect(nav.getByText("No projects open")).toBeVisible()
await expect(nav.getByText("Open a project to get started")).toBeVisible()
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
})

View File

@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { closeDialog, openPalette } from "../actions"
import { openPalette } from "../actions"
test("search palette opens and closes", async ({ page, gotoSession }) => {
await gotoSession()
@@ -9,12 +9,3 @@ test("search palette opens and closes", async ({ page, gotoSession }) => {
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})
test("search palette also opens with cmd+p", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openPalette(page, "P")
await closeDialog(page, dialog)
await expect(dialog).toHaveCount(0)
})

View File

@@ -1,16 +1,7 @@
import { test as base, expect, type Page } from "@playwright/test"
import type { E2EWindow } from "../src/testing/terminal"
import {
healthPhase,
cleanupSession,
cleanupTestProject,
createTestProject,
setHealthPhase,
seedProjects,
sessionIDFromUrl,
waitSlug,
waitSession,
} from "./actions"
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
import { promptSelector } from "./selectors"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
export const settingsKey = "settings.v3"
@@ -36,29 +27,6 @@ type WorkerFixtures = {
}
export const test = base.extend<TestFixtures, WorkerFixtures>({
page: async ({ page }, use) => {
let boundary: string | undefined
setHealthPhase(page, "test")
const consoleHandler = (msg: { text(): string }) => {
const text = msg.text()
if (!text.includes("[e2e:error-boundary]")) return
if (healthPhase(page) === "cleanup") {
console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
return
}
boundary ||= text
console.log(text)
}
const pageErrorHandler = (err: Error) => {
console.log(`[e2e:pageerror] ${err.stack || err.message}`)
}
page.on("console", consoleHandler)
page.on("pageerror", pageErrorHandler)
await use(page)
page.off("console", consoleHandler)
page.off("pageerror", pageErrorHandler)
if (boundary) throw new Error(boundary)
},
directory: [
async ({}, use) => {
const directory = await getWorktree()
@@ -80,20 +48,21 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID))
await waitSession(page, { directory, sessionID })
await expect(page.locator(promptSelector)).toBeVisible()
}
await use(gotoSession)
},
withProject: async ({ page }, use) => {
await use(async (callback, options) => {
const root = await createTestProject()
const slug = dirSlug(root)
const sessions = new Map<string, string>()
const dirs = new Set<string>()
await seedStorage(page, { directory: root, extra: options?.extra })
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(root, sessionID))
await waitSession(page, { directory: root, sessionID })
await expect(page.locator(promptSelector)).toBeVisible()
const current = sessionIDFromUrl(page.url())
if (current) trackSession(current)
}
@@ -108,16 +77,13 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
try {
await gotoSession()
const slug = await waitSlug(page)
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
} finally {
setHealthPhase(page, "cleanup")
await Promise.allSettled(
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
)
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
await cleanupTestProject(root)
setHealthPhase(page, "test")
}
})
},
@@ -132,9 +98,6 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin
model: {
enabled: true,
},
prompt: {
enabled: true,
},
terminal: {
enabled: true,
terminals: {},

View File

@@ -1,19 +1,41 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import {
defocus,
createTestProject,
cleanupTestProject,
openSidebar,
sessionIDFromUrl,
setWorkspacesEnabled,
waitSession,
waitSessionSaved,
waitSlug,
} from "../actions"
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { dirSlug, resolveDirectory } from "../utils"
async function workspaces(page: Page, directory: string, enabled: boolean) {
await page.evaluate(
({ directory, enabled }: { directory: string; enabled: boolean }) => {
const key = "opencode.global.dat:layout"
const raw = localStorage.getItem(key)
const data = raw ? JSON.parse(raw) : {}
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
const current =
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
? sidebar.workspaces
: {}
const next = { ...current }
if (enabled) next[directory] = true
if (!enabled) delete next[directory]
localStorage.setItem(
key,
JSON.stringify({
...data,
sidebar: {
...sidebar,
workspaces: next,
},
}),
)
},
{ directory, enabled },
)
}
test("can switch between projects from sidebar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
@@ -54,7 +76,9 @@ test("switching back to a project opens the latest workspace session", async ({
await withProject(
async ({ directory, slug, trackSession, trackDirectory }) => {
await defocus(page)
await setWorkspacesEnabled(page, slug, true)
await workspaces(page, directory, true)
await page.reload()
await expect(page.locator(promptSelector)).toBeVisible()
await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
@@ -76,7 +100,11 @@ test("switching back to a project opens the latest workspace session", async ({
await expect(btn).toBeVisible()
await btn.click({ force: true })
await waitSession(page, { directory: space })
// A new workspace can be discovered via a transient slug before the route and sidebar
// settle to the canonical workspace path on Windows, so interact with either and assert
// against the resolved workspace slug.
await waitSlug(page)
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
// Create a session by sending a prompt
const prompt = page.locator(promptSelector)
@@ -90,7 +118,6 @@ test("switching back to a project opens the latest workspace session", async ({
const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
trackSession(created, space)
await waitSessionSaved(space, created)
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
@@ -98,14 +125,14 @@ test("switching back to a project opens the latest workspace session", async ({
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click({ force: true })
await waitSession(page, { directory: other })
await otherButton.click()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const rootButton = page.locator(projectSwitchSelector(slug)).first()
await expect(rootButton).toBeVisible()
await rootButton.click({ force: true })
await rootButton.click()
await waitSession(page, { directory: space, sessionID: created })
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
},
{ extra: [other] },

View File

@@ -1,94 +1,109 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import {
openSidebar,
resolveSlug,
sessionIDFromUrl,
setWorkspacesEnabled,
waitDir,
waitSession,
waitSessionSaved,
waitSlug,
} from "../actions"
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils"
function item(space: { slug: string; raw: string }) {
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
}
function button(space: { slug: string; raw: string }) {
return `${workspaceNewSessionSelector(space.slug)}, ${workspaceNewSessionSelector(space.raw)}`
}
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
async function waitWorkspaceReady(page: Page, slug: string) {
await openSidebar(page)
await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 })
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
}
async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
await waitDir(page, next.directory)
const slug = await waitSlug(page, [root, ...seen])
const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
return { slug, directory }
}
async function openWorkspaceNewSession(page: Page, slug: string) {
await waitWorkspaceReady(page, slug)
const item = page.locator(workspaceItemSelector(slug)).first()
await item.hover()
const button = page.locator(workspaceNewSessionSelector(slug)).first()
await expect(button).toBeVisible()
await button.click({ force: true })
const next = await waitSlug(page)
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
return next
}
async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) {
await waitWorkspaceReady(page, space)
const row = page.locator(item(space)).first()
await row.hover()
const next = page.locator(button(space)).first()
await expect(next).toBeVisible()
await next.click({ force: true })
await waitSession(page, { directory: space.directory })
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("")
}
async function createSessionFromWorkspace(
page: Page,
space: { slug: string; raw: string; directory: string },
text: string,
) {
await openWorkspaceNewSession(page, space)
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
const next = await openWorkspaceNewSession(page, slug)
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await expect(prompt).toBeEditable()
await prompt.click()
await expect(prompt).toBeFocused()
await prompt.fill(text)
await page.keyboard.press("Enter")
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
await prompt.press("Enter")
await expect.poll(() => slugFromUrl(page.url())).toBe(next)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
return { sessionID, slug: next }
}
await waitSessionSaved(space.directory, sessionID)
await createSdk(space.directory)
.session.abort({ sessionID })
async function sessionDirectory(directory: string, sessionID: string) {
const info = await createSdk(directory)
.session.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined)
return sessionID
if (!info) return ""
return info.directory
}
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
await openSidebar(page)
await setWorkspacesEnabled(page, root, true)
const first = await createWorkspace(page, root, [])
trackDirectory(first.directory)
await waitWorkspaceReady(page, first)
await waitWorkspaceReady(page, first.slug)
const second = await createWorkspace(page, root, [first.slug])
trackDirectory(second.directory)
await waitWorkspaceReady(page, second)
await waitWorkspaceReady(page, second.slug)
trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
trackSession(firstSession.sessionID, first.directory)
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
trackSession(secondSession.sessionID, second.directory)
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
trackSession(thirdSession.sessionID, first.directory)
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
})
})

View File

@@ -1,7 +1,7 @@
import { base64Decode } from "@opencode-ai/util/encode"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
@@ -13,10 +13,8 @@ import {
confirmDialog,
openSidebar,
openWorkspaceMenu,
resolveSlug,
setWorkspacesEnabled,
slugFromUrl,
waitDir,
waitSlug,
} from "../actions"
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
@@ -29,15 +27,15 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
await setWorkspacesEnabled(page, rootSlug, true)
await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [rootSlug]))
await waitDir(page, next.directory)
const slug = await waitSlug(page, [rootSlug])
const dir = base64Decode(slug)
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(next.slug)).first()
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
@@ -49,7 +47,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
)
.toBe(true)
return { rootSlug, slug: next.slug, directory: next.directory }
return { rootSlug, slug, directory: dir }
}
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
@@ -81,15 +79,15 @@ test("can create a workspace", async ({ page, withProject }) => {
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [slug]))
await waitDir(page, next.directory)
const workspaceSlug = await waitSlug(page, [slug])
const workspaceDir = base64Decode(workspaceSlug)
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(next.slug)).first()
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
try {
await item.hover({ timeout: 500 })
return true
@@ -101,9 +99,9 @@ test("can create a workspace", async ({ page, withProject }) => {
)
.toBe(true)
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
await cleanupTestProject(next.directory)
await cleanupTestProject(workspaceDir)
})
})
@@ -121,7 +119,7 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
const activeDir = base64Decode(slugFromUrl(page.url()))
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
await openSidebar(page)
@@ -333,9 +331,9 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
for (const _ of [0, 1]) {
const prev = slugFromUrl(page.url())
await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
await waitDir(page, next.directory)
workspaces.push(next)
const slug = await waitSlug(page, [rootSlug, prev])
const dir = base64Decode(slug)
workspaces.push({ slug, directory: dir })
await openSidebar(page)
}

View File

@@ -108,10 +108,7 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
await page.keyboard.type(draft)
await wait(page, draft)
// Clear the draft before navigating history (ArrowUp only works when prompt is empty)
await prompt.fill("")
await wait(page, "")
await edge(page, "start")
await page.keyboard.press("ArrowUp")
await wait(page, second)
@@ -122,7 +119,7 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
await wait(page, second)
await page.keyboard.press("ArrowDown")
await wait(page, "")
await wait(page, draft)
})
})

View File

@@ -7,18 +7,12 @@ test("shift+enter inserts a newline without submitting", async ({ page, gotoSess
await expect(page).toHaveURL(/\/session\/?$/)
const prompt = page.locator(promptSelector)
await prompt.focus()
await expect(prompt).toBeFocused()
await prompt.pressSequentially("line one")
await expect(prompt).toBeFocused()
await prompt.press("Shift+Enter")
await expect(page).toHaveURL(/\/session\/?$/)
await expect(prompt).toBeFocused()
await prompt.pressSequentially("line two")
await prompt.click()
await page.keyboard.type("line one")
await page.keyboard.press("Shift+Enter")
await page.keyboard.type("line two")
await expect(page).toHaveURL(/\/session\/?$/)
await expect.poll(() => prompt.evaluate((el) => el.innerText)).toBe("line one\nline two")
await expect(prompt).toContainText("line one")
await expect(prompt).toContainText("line two")
})

View File

@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { runPromptSlash, waitTerminalFocusIdle } from "../actions"
import { waitTerminalReady } from "../actions"
import { promptSelector, terminalSelector } from "../selectors"
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
@@ -7,12 +7,29 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
const prompt = page.locator(promptSelector)
const terminal = page.locator(terminalSelector)
const slash = page.locator('[data-slash-id="terminal.toggle"]').first()
await expect(terminal).not.toBeVisible()
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
await waitTerminalFocusIdle(page, { term: terminal })
await prompt.fill("/terminal")
await expect(slash).toBeVisible()
await page.keyboard.press("Enter")
await waitTerminalReady(page, { term: terminal })
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
// Terminal panel retries focus (immediate, RAF, 120ms, 240ms) after opening,
// which can steal focus from the prompt and prevent fill() from triggering
// the slash popover. Re-attempt click+fill until all retries are exhausted
// and the popover appears.
await expect
.poll(
async () => {
await prompt.click().catch(() => false)
await prompt.fill("/terminal").catch(() => false)
return slash.isVisible().catch(() => false)
},
{ timeout: 10_000 },
)
.toBe(true)
await page.keyboard.press("Enter")
await expect(terminal).not.toBeVisible()
})

View File

@@ -1,14 +1,7 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Locator, Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import {
openSidebar,
resolveSlug,
sessionIDFromUrl,
setWorkspacesEnabled,
waitSession,
waitSessionIdle,
waitSlug,
} from "../actions"
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions"
import {
promptAgentSelector,
promptModelSelector,
@@ -37,6 +30,8 @@ const text = async (locator: Locator) => ((await locator.textContent()) ?? "").t
const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
const dirKey = (state: Probe | null) => state?.dir ?? ""
async function probe(page: Page): Promise<Probe | null> {
return page.evaluate(() => {
const win = window as Window & {
@@ -50,6 +45,21 @@ async function probe(page: Page): Promise<Probe | null> {
})
}
async function currentDir(page: Page) {
let hit = ""
await expect
.poll(
async () => {
const next = dirKey(await probe(page))
if (next) hit = next
return next
},
{ timeout: 30_000 },
)
.not.toBe("")
return hit
}
async function read(page: Page): Promise<Footer> {
return {
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
@@ -178,7 +188,8 @@ async function chooseOtherModel(page: Page): Promise<Footer> {
async function goto(page: Page, directory: string, sessionID?: string) {
await page.goto(sessionPath(directory, sessionID))
await waitSession(page, { directory, sessionID })
await expect(page.locator(promptSelector)).toBeVisible()
await expect.poll(async () => dirKey(await probe(page)), { timeout: 30_000 }).toBe(directory)
}
async function submit(page: Page, value: string) {
@@ -213,9 +224,10 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
await waitSession(page, { directory: next.directory })
return next
const slug = await waitSlug(page, [root, ...seen])
const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
return { slug, directory }
}
async function waitWorkspace(page: Page, slug: string) {
@@ -245,8 +257,10 @@ async function newWorkspaceSession(page: Page, slug: string) {
await expect(button).toBeVisible()
await button.click({ force: true })
const next = await resolveSlug(await waitSlug(page))
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
const next = await waitSlug(page)
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
return currentDir(page)
}
test("session model and variant restore per session without leaking into new sessions", async ({
@@ -265,7 +279,7 @@ test("session model and variant restore per session without leaking into new ses
await waitUser(directory, first)
await page.reload()
await waitSession(page, { directory, sessionID: first })
await expect(page.locator(promptSelector)).toBeVisible()
await waitFooter(page, firstState)
await gotoSession()

View File

@@ -123,215 +123,6 @@ async function spot(page: Parameters<typeof test>[0]["page"], file: string) {
}, file)
}
async function comment(page: Parameters<typeof test>[0]["page"], file: string, note: string) {
const row = page.locator(`[data-file="${file}"]`).first()
await expect(row).toBeVisible()
const line = row.locator('diffs-container [data-line="2"]').first()
await expect(line).toBeVisible()
await line.hover()
const add = row.getByRole("button", { name: /^Comment$/ }).first()
await expect(add).toBeVisible()
await add.click()
const area = row.locator('[data-slot="line-comment-textarea"]').first()
await expect(area).toBeVisible()
await area.fill(note)
const submit = row.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
await expect(submit).toBeEnabled()
await submit.click()
await expect(row.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
await expect(row.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
}
async function overflow(page: Parameters<typeof test>[0]["page"], file: string) {
const row = page.locator(`[data-file="${file}"]`).first()
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
const pop = row.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
const tools = row.locator('[data-slot="line-comment-tools"]').first()
const [width, viewBox, popBox, toolsBox] = await Promise.all([
view.evaluate((el) => el.scrollWidth - el.clientWidth),
view.boundingBox(),
pop.boundingBox(),
tools.boundingBox(),
])
if (!viewBox || !popBox || !toolsBox) return null
return {
width,
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
}
}
async function openReviewFile(page: Parameters<typeof test>[0]["page"], file: string) {
const row = page.locator(`[data-file="${file}"]`).first()
await expect(row).toBeVisible()
await row.hover()
const open = row.getByRole("button", { name: /^Open file$/i }).first()
await expect(open).toBeVisible()
await open.click()
const tab = page.getByRole("tab", { name: file }).first()
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
return viewer
}
async function fileComment(page: Parameters<typeof test>[0]["page"], note: string) {
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
const line = viewer.locator('diffs-container [data-line="2"]').first()
await expect(line).toBeVisible()
await line.hover()
const add = viewer.getByRole("button", { name: /^Comment$/ }).first()
await expect(add).toBeVisible()
await add.click()
const area = viewer.locator('[data-slot="line-comment-textarea"]').first()
await expect(area).toBeVisible()
await area.fill(note)
const submit = viewer.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
await expect(submit).toBeEnabled()
await submit.click()
await expect(viewer.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
await expect(viewer.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
}
async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
const view = page.locator('[role="tabpanel"] .scroll-view__viewport').first()
const pop = viewer.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
const tools = viewer.locator('[data-slot="line-comment-tools"]').first()
const [width, viewBox, popBox, toolsBox] = await Promise.all([
view.evaluate((el) => el.scrollWidth - el.clientWidth),
view.boundingBox(),
pop.boundingBox(),
tools.boundingBox(),
])
if (!viewBox || !popBox || !toolsBox) return null
return {
width,
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
}
}
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
test.setTimeout(180_000)
const tag = `review-comment-${Date.now()}`
const file = `review-comment-${tag}.txt`
const note = `comment ${tag}`
await page.setViewportSize({ width: 1280, height: 900 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(1)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
await expand(page)
await waitMark(page, file, tag)
await comment(page, file, note)
await expect
.poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
})
})
})
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
test.setTimeout(180_000)
const tag = `review-file-comment-${Date.now()}`
const file = `review-file-comment-${tag}.txt`
const note = `comment ${tag}`
await page.setViewportSize({ width: 1280, height: 900 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(1)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
await expand(page)
await waitMark(page, file, tag)
await openReviewFile(page, file)
await fileComment(page, note)
await expect
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
await expect
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
})
})
})
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
test.setTimeout(180_000)

View File

@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { openSettings, closeDialog, waitTerminalFocusIdle, withSession } from "../actions"
import { openSettings, closeDialog, waitTerminalReady, withSession } from "../actions"
import { keybindButtonSelector, terminalSelector } from "../selectors"
import { modKey } from "../utils"
@@ -241,7 +241,7 @@ test("changing file open keybind works", async ({ page, gotoSession }) => {
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("K")
expect(initialKeybind).toContain("P")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
@@ -302,7 +302,7 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
await expect(terminal).not.toBeVisible()
await page.keyboard.press(`${modKey}+Y`)
await waitTerminalFocusIdle(page, { term: terminal })
await waitTerminalReady(page, { term: terminal })
await page.keyboard.press(`${modKey}+Y`)
await expect(terminal).not.toBeVisible()

View File

@@ -1,16 +1,6 @@
import { test, expect } from "../fixtures"
import {
defocus,
cleanupSession,
cleanupTestProject,
closeSidebar,
createTestProject,
hoverSessionItem,
openSidebar,
waitSession,
} from "../actions"
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
import { projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils"
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now()
@@ -47,72 +37,3 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
await cleanupSession({ sdk, sessionID: two.id })
}
})
test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const slug = dirSlug(other)
try {
await withProject(
async () => {
await openSidebar(page)
const project = page.locator(projectSwitchSelector(slug)).first()
const card = page.locator('[data-component="hover-card-content"]')
await expect(project).toBeVisible()
await project.hover()
await expect(card.getByText(/recent sessions/i)).toBeVisible()
await page.mouse.down()
await expect(card).toHaveCount(0)
await page.mouse.up()
await waitSession(page, { directory: other })
await expect(card).toHaveCount(0)
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const slug = dirSlug(other)
try {
await withProject(
async () => {
await openSidebar(page)
await defocus(page)
const project = page.locator(projectSwitchSelector(slug)).first()
await expect(project).toBeVisible()
let hit = false
for (let i = 0; i < 20; i++) {
hit = await project.evaluate((el) => {
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
})
if (hit) break
await page.keyboard.press("Tab")
}
expect(hit).toBe(true)
await page.keyboard.press("Enter")
await waitSession(page, { directory: other })
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})

View File

@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { waitTerminalFocusIdle, waitTerminalReady } from "../actions"
import { waitTerminalReady } from "../actions"
import { promptSelector, terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils"
@@ -14,7 +14,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await page.keyboard.press(terminalToggleKey)
}
await waitTerminalFocusIdle(page, { term: terminals.first() })
await waitTerminalReady(page, { term: terminals.first() })
await expect(terminals).toHaveCount(1)
// Ghostty captures a lot of keybinds when focused; move focus back

View File

@@ -1,7 +1,7 @@
import type { Page } from "@playwright/test"
import { runTerminal, waitTerminalReady } from "../actions"
import { test, expect } from "../fixtures"
import { dropdownMenuContentSelector, terminalSelector } from "../selectors"
import { terminalSelector } from "../selectors"
import { terminalToggleKey, workspacePersistKey } from "../utils"
type State = {
@@ -130,39 +130,3 @@ test("closing the active terminal tab falls back to the previous tab", async ({
.toEqual({ count: 1, first: true })
})
})
test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => {
await withProject(async ({ directory, gotoSession }) => {
const key = workspacePersistKey(directory, "terminal")
const rename = `E2E term ${Date.now()}`
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
await gotoSession()
await open(page)
await expect(tab).toContainText(/Terminal 1/)
await tab.click({ button: "right" })
const menu = page.locator(dropdownMenuContentSelector).first()
await expect(menu).toBeVisible()
await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
await expect(menu).toHaveCount(0)
const input = page.locator('#terminal-panel input[type="text"]').first()
await expect(input).toBeVisible()
await input.fill(rename)
await input.press("Enter")
await expect(input).toHaveCount(0)
await expect(tab).toContainText(rename)
await expect
.poll(
async () => {
const state = await store(page, key)
return state?.all[0]?.title
},
{ timeout: 5_000 },
)
.toBe(rename)
})
})

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.3.1",
"version": "1.2.27",
"description": "",
"type": "module",
"exports": {
@@ -51,14 +51,12 @@
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
"@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "catalog:",
"effect": "4.0.0-beta.31",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:",

View File

@@ -6,10 +6,9 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { File } from "@opencode-ai/ui/file"
import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { type Duration, Effect } from "effect"
import {
type Component,
@@ -32,7 +31,7 @@ import { FileProvider } from "@/context/file"
import { GlobalSDKProvider } from "@/context/global-sdk"
import { GlobalSyncProvider } from "@/context/global-sync"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LanguageProvider, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification"
@@ -47,13 +46,21 @@ import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
const HomeRoute = lazy(() => import("@/pages/home"))
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const Loading = () => <div class="size-full" />
const HomeRoute = () => (
<Suspense fallback={<Loading />}>
<Home />
</Suspense>
)
const SessionRoute = () => (
<SessionProviders>
<Session />
<Suspense fallback={<Loading />}>
<Session />
</Suspense>
</SessionProviders>
)
@@ -82,11 +89,6 @@ function MarkedProviderWithNativeParser(props: ParentProps) {
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
}
function QueryProvider(props: ParentProps) {
const client = new QueryClient()
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
}
function AppShellProviders(props: ParentProps) {
return (
<SettingsProvider>
@@ -122,15 +124,13 @@ function SessionProviders(props: ParentProps) {
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
return (
<AppShellProviders>
<Suspense fallback={<Loading />}>
{props.appChildren}
{props.children}
</Suspense>
{props.appChildren}
{props.children}
</AppShellProviders>
)
}
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
export function AppBaseProviders(props: ParentProps) {
return (
<MetaProvider>
<Font />
@@ -139,16 +139,14 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
void window.api?.setTitlebar?.({ mode })
}}
>
<LanguageProvider locale={props.locale}>
<LanguageProvider>
<UiI18nBridge>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<QueryProvider>
<DialogProvider>
<MarkedProviderWithNativeParser>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProviderWithNativeParser>
</DialogProvider>
</QueryProvider>
<DialogProvider>
<MarkedProviderWithNativeParser>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProviderWithNativeParser>
</DialogProvider>
</ErrorBoundary>
</UiI18nBridge>
</LanguageProvider>
@@ -267,15 +265,6 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key:
)
}
function ServerKey(props: ParentProps) {
const server = useServer()
return (
<Show when={server.key} keyed>
{props.children}
</Show>
)
}
export function AppInterface(props: {
children?: JSX.Element
defaultServer: ServerConnection.Key
@@ -286,22 +275,20 @@ export function AppInterface(props: {
return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={SessionIndexRoute} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
</GlobalSyncProvider>
</GlobalSDKProvider>
</ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={SessionIndexRoute} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
</GlobalSyncProvider>
</GlobalSDKProvider>
</ConnectionGate>
</ServerProvider>
)

View File

@@ -1,4 +1,4 @@
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
@@ -9,18 +9,21 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
import { useLanguage } from "@/context/language"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { DialogSelectModel } from "./dialog-select-model"
import { DialogSelectProvider } from "./dialog-select-provider"
export function DialogConnectProvider(props: { provider: string }) {
const dialog = useDialog()
const globalSync = useGlobalSync()
const globalSDK = useGlobalSDK()
const platform = usePlatform()
const language = useLanguage()
const alive = { value: true }
@@ -34,36 +37,25 @@ export function DialogConnectProvider(props: { provider: string }) {
})
const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
const fallback = createMemo<ProviderAuthMethod[]>(() => [
{
type: "api" as const,
label: language.t("provider.connect.method.apiKey"),
},
])
const [auth] = createResource(
() => props.provider,
async () => {
const cached = globalSync.data.provider_auth[props.provider]
if (cached) return cached
const res = await globalSDK.client.provider.auth()
if (!alive.value) return fallback()
globalSync.set("provider_auth", res.data ?? {})
return res.data?.[props.provider] ?? fallback()
},
const methods = createMemo(
() =>
globalSync.data.provider_auth[props.provider] ?? [
{
type: "api",
label: language.t("provider.connect.method.apiKey"),
},
],
)
const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback())
const [store, setStore] = createStore({
methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization,
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
state: "pending" as undefined | "pending" | "complete" | "error",
error: undefined as string | undefined,
})
type Action =
| { type: "method.select"; index: number }
| { type: "method.reset" }
| { type: "auth.prompt" }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
| { type: "auth.error"; error: string }
@@ -85,11 +77,6 @@ export function DialogConnectProvider(props: { provider: string }) {
draft.error = undefined
return
}
if (action.type === "auth.prompt") {
draft.state = "prompt"
draft.error = undefined
return
}
if (action.type === "auth.pending") {
draft.state = "pending"
draft.error = undefined
@@ -133,7 +120,7 @@ export function DialogConnectProvider(props: { provider: string }) {
return fallback
}
async function selectMethod(index: number, inputs?: Record<string, string>) {
async function selectMethod(index: number) {
if (timer.current !== undefined) {
clearTimeout(timer.current)
timer.current = undefined
@@ -143,10 +130,6 @@ export function DialogConnectProvider(props: { provider: string }) {
dispatch({ type: "method.select", index })
if (method.type === "oauth") {
if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.pending" })
const start = Date.now()
await globalSDK.client.provider.oauth
@@ -154,7 +137,6 @@ export function DialogConnectProvider(props: { provider: string }) {
{
providerID: props.provider,
method: index,
inputs,
},
{ throwOnError: true },
)
@@ -181,126 +163,6 @@ export function DialogConnectProvider(props: { provider: string }) {
}
}
function OAuthPromptsView() {
const [formStore, setFormStore] = createStore({
value: {} as Record<string, string>,
index: 0,
})
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
const value = method()
if (value?.type !== "oauth") return []
return value.prompts ?? []
})
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true
const actual = value[prompt.when.key]
if (actual === undefined) return false
return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value
}
const current = createMemo(() => {
const all = prompts()
const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value))
if (index === -1) return
return {
index,
prompt: all[index],
}
})
const valid = createMemo(() => {
const item = current()
if (!item || item.prompt.type !== "text") return false
const value = formStore.value[item.prompt.key] ?? ""
return value.trim().length > 0
})
async function next(index: number, value: Record<string, string>) {
if (store.methodIndex === undefined) return
const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value))
if (next !== -1) {
setFormStore("index", next)
return
}
await selectMethod(store.methodIndex, value)
}
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const item = current()
if (!item || item.prompt.type !== "text") return
if (!valid()) return
await next(item.index, formStore.value)
}
const item = () => current()
const text = createMemo(() => {
const prompt = item()?.prompt
if (!prompt || prompt.type !== "text") return
return prompt
})
const select = createMemo(() => {
const prompt = item()?.prompt
if (!prompt || prompt.type !== "select") return
return prompt
})
return (
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<Switch>
<Match when={item()?.prompt.type === "text"}>
<TextField
type="text"
label={text()?.message ?? ""}
placeholder={text()?.placeholder}
value={text() ? (formStore.value[text()!.key] ?? "") : ""}
onChange={(value) => {
const prompt = text()
if (!prompt) return
setFormStore("value", prompt.key, value)
}}
/>
<Button class="w-auto" type="submit" size="large" variant="primary" disabled={!valid()}>
{language.t("common.continue")}
</Button>
</Match>
<Match when={item()?.prompt.type === "select"}>
<div class="w-full flex flex-col gap-1.5">
<div class="text-14-regular text-text-base">{select()?.message}</div>
<div>
<List
items={select()?.options ?? []}
key={(x) => x.value}
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
onSelect={(value) => {
if (!value) return
const prompt = select()
if (!prompt) return
const nextValue = {
...formStore.value,
[prompt.key]: value.value,
}
setFormStore("value", prompt.key, value.value)
void next(item()!.index, nextValue)
}}
>
{(option) => (
<div class="w-full flex items-center gap-x-2">
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
</div>
<span>{option.label}</span>
<span class="text-14-regular text-text-weak">{option.hint}</span>
</div>
)}
</List>
</div>
</div>
</Match>
</Switch>
</form>
)
}
let listRef: ListRef | undefined
function handleKey(e: KeyboardEvent) {
if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
@@ -310,12 +172,8 @@ export function DialogConnectProvider(props: { provider: string }) {
listRef?.onKeyDown(e)
}
let auto = false
createEffect(() => {
if (auto) return
if (loading()) return
onMount(() => {
if (methods().length === 1) {
auto = true
selectMethod(0)
}
})
@@ -443,7 +301,7 @@ export function DialogConnectProvider(props: { provider: string }) {
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.continue")}
{language.t("common.submit")}
</Button>
</form>
</div>
@@ -456,6 +314,12 @@ export function DialogConnectProvider(props: { provider: string }) {
error: undefined as string | undefined,
})
onMount(() => {
if (store.authorization?.method === "code" && store.authorization?.url) {
platform.openLink(store.authorization.url)
}
})
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
@@ -504,7 +368,7 @@ export function DialogConnectProvider(props: { provider: string }) {
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.continue")}
{language.t("common.submit")}
</Button>
</form>
</div>
@@ -522,6 +386,10 @@ export function DialogConnectProvider(props: { provider: string }) {
onMount(() => {
void (async () => {
if (store.authorization?.url) {
platform.openLink(store.authorization.url)
}
const result = await globalSDK.client.provider.oauth
.callback({
providerID: props.provider,
@@ -591,14 +459,6 @@ export function DialogConnectProvider(props: { provider: string }) {
<div class="px-2.5 pb-10 flex flex-col gap-6">
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
<Switch>
<Match when={loading()}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={store.methodIndex === undefined}>
<MethodSelection />
</Match>
@@ -610,9 +470,6 @@ export function DialogConnectProvider(props: { provider: string }) {
</div>
</div>
</Match>
<Match when={store.state === "prompt"}>
<OAuthPromptsView />
</Match>
<Match when={store.state === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">

View File

@@ -34,6 +34,7 @@ export type FormState = {
apiKey: string
models: ModelRow[]
headers: HeaderRow[]
saving: boolean
err: {
providerID?: string
name?: string

View File

@@ -16,6 +16,7 @@ describe("validateCustomProvider", () => {
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
{ row: "h1", key: "", value: "", err: {} },
],
saving: false,
err: {},
},
t,
@@ -59,6 +60,7 @@ describe("validateCustomProvider", () => {
{ row: "h0", key: "Authorization", value: "one", err: {} },
{ row: "h1", key: "authorization", value: "two", err: {} },
],
saving: false,
err: {},
},
t,

View File

@@ -3,7 +3,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useMutation } from "@tanstack/solid-query"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { batch, For } from "solid-js"
@@ -32,6 +31,7 @@ export function DialogCustomProvider(props: Props) {
apiKey: "",
models: [modelRow()],
headers: [headerRow()],
saving: false,
err: {},
})
@@ -116,49 +116,48 @@ export function DialogCustomProvider(props: Props) {
return output.result
}
const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
const disabledProviders = globalSync.data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
const save = async (e: SubmitEvent) => {
e.preventDefault()
if (form.saving) return
if (result.key) {
await globalSDK.client.auth.set({
const result = validate()
if (!result) return
setForm("saving", true)
const disabledProviders = globalSync.data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
const auth = result.key
? globalSDK.client.auth.set({
providerID: result.providerID,
auth: {
type: "api",
key: result.key,
},
})
}
: Promise.resolve()
await globalSync.updateConfig({
provider: { [result.providerID]: result.config },
disabled_providers: nextDisabled,
auth
.then(() =>
globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }),
)
.then(() => {
dialog.close()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
})
})
return result
},
onSuccess: (result) => {
dialog.close()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
.finally(() => {
setForm("saving", false)
})
},
onError: (err) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
},
}))
const save = (e: SubmitEvent) => {
e.preventDefault()
if (saveMutation.isPending) return
const result = validate()
if (!result) return
saveMutation.mutate(result)
}
return (
@@ -313,14 +312,8 @@ export function DialogCustomProvider(props: Props) {
</Button>
</div>
<Button
class="w-auto self-start"
type="submit"
size="large"
variant="primary"
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
<Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
{form.saving ? language.t("common.saving") : language.t("common.submit")}
</Button>
</form>
</div>

View File

@@ -2,7 +2,6 @@ import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { Icon } from "@opencode-ai/ui/icon"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
@@ -29,6 +28,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
color: props.project.icon?.color || "pink",
iconUrl: props.project.icon?.override || "",
startup: props.project.commands?.start ?? "",
saving: false,
dragOver: false,
iconHover: false,
})
@@ -71,37 +71,38 @@ export function DialogEditProject(props: { project: LocalProject }) {
setStore("iconUrl", "")
}
const saveMutation = useMutation(() => ({
mutationFn: async () => {
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await globalSDK.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color, override: store.iconUrl },
commands: { start },
})
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
dialog.close()
return
}
globalSync.project.meta(props.project.worktree, {
name,
icon: { color: store.color, override: store.iconUrl || undefined },
commands: { start: start || undefined },
})
dialog.close()
},
}))
function handleSubmit(e: SubmitEvent) {
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
if (saveMutation.isPending) return
saveMutation.mutate()
await Promise.resolve()
.then(async () => {
setStore("saving", true)
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await globalSDK.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color, override: store.iconUrl },
commands: { start },
})
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
dialog.close()
return
}
globalSync.project.meta(props.project.worktree, {
name,
icon: { color: store.color, override: store.iconUrl || undefined },
commands: { start: start || undefined },
})
dialog.close()
})
.finally(() => {
setStore("saving", false)
})
}
return (
@@ -245,8 +246,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
<Button type="submit" variant="primary" size="large" disabled={store.saving}>
{store.saving ? language.t("common.saving") : language.t("common.save")}
</Button>
</div>
</form>

View File

@@ -1,5 +1,4 @@
import { useMutation } from "@tanstack/solid-query"
import { Component, createMemo, Show } from "solid-js"
import { Component, createMemo, createSignal, Show } from "solid-js"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { Dialog } from "@opencode-ai/ui/dialog"
@@ -18,6 +17,7 @@ export const DialogSelectMcp: Component = () => {
const sync = useSync()
const sdk = useSDK()
const language = useLanguage()
const [loading, setLoading] = createSignal<string | null>(null)
const items = createMemo(() =>
Object.entries(sync.data.mcp ?? {})
@@ -25,8 +25,10 @@ export const DialogSelectMcp: Component = () => {
.sort((a, b) => a.name.localeCompare(b.name)),
)
const toggle = useMutation(() => ({
mutationFn: async (name: string) => {
const toggle = async (name: string) => {
if (loading()) return
setLoading(name)
try {
const status = sync.data.mcp[name]
if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name })
@@ -36,8 +38,10 @@ export const DialogSelectMcp: Component = () => {
const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data)
},
}))
} finally {
setLoading(null)
}
}
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
const totalCount = createMemo(() => items().length)
@@ -55,8 +59,7 @@ export const DialogSelectMcp: Component = () => {
filterKeys={["name", "status"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
onSelect={(x) => {
if (!x || toggle.isPending) return
toggle.mutate(x.name)
if (x) toggle(x.name)
}}
>
{(i) => {
@@ -80,7 +83,7 @@ export const DialogSelectMcp: Component = () => {
<Show when={statusLabel()}>
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
</Show>
<Show when={toggle.isPending && toggle.variables === i.name}>
<Show when={loading() === i.name}>
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
</Show>
</div>
@@ -89,14 +92,7 @@ export const DialogSelectMcp: Component = () => {
</Show>
</div>
<div onClick={(e) => e.stopPropagation()}>
<Switch
checked={enabled()}
disabled={toggle.isPending && toggle.variables === i.name}
onChange={() => {
if (toggle.isPending) return
toggle.mutate(i.name)
}}
/>
<Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
</div>
</div>
)

View File

@@ -6,7 +6,6 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
@@ -187,6 +186,7 @@ export function DialogSelectServer() {
name: "",
username: DEFAULT_USERNAME,
password: "",
adding: false,
error: "",
showForm: false,
status: undefined as boolean | undefined,
@@ -198,6 +198,7 @@ export function DialogSelectServer() {
username: "",
password: "",
error: "",
busy: false,
status: undefined as boolean | undefined,
},
})
@@ -208,6 +209,7 @@ export function DialogSelectServer() {
name: "",
username: DEFAULT_USERNAME,
password: "",
adding: false,
error: "",
showForm: false,
status: undefined,
@@ -222,78 +224,10 @@ export function DialogSelectServer() {
password: "",
error: "",
status: undefined,
busy: false,
})
}
const addMutation = useMutation(() => ({
mutationFn: async (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
return
}
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.password) conn.http.password = store.addServer.password
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
await select(conn, true)
},
}))
const editMutation = useMutation(() => ({
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
if (input.original.type !== "http") return
const normalized = normalizeServerUrl(input.value)
if (!normalized) {
resetEdit()
return
}
const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = input.original.displayName
if (
normalized === input.original.http.url &&
name === existingName &&
username === input.original.http.username &&
password === input.original.http.password
) {
resetEdit()
return
}
const conn: ServerConnection.Http = {
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === input.original.http.url) {
server.add(conn)
} else {
replaceServer(input.original, conn)
}
resetEdit()
},
}))
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
const active = server.key
const newConn = server.add(next)
@@ -357,12 +291,12 @@ export function DialogSelectServer() {
navigate("/")
return
}
server.setActive(ServerConnection.key(conn))
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
}
const handleAddChange = (value: string) => {
if (addMutation.isPending) return
if (store.addServer.adding) return
setStore("addServer", { url: value, error: "" })
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
@@ -370,12 +304,12 @@ export function DialogSelectServer() {
}
const handleAddNameChange = (value: string) => {
if (addMutation.isPending) return
if (store.addServer.adding) return
setStore("addServer", { name: value, error: "" })
}
const handleAddUsernameChange = (value: string) => {
if (addMutation.isPending) return
if (store.addServer.adding) return
setStore("addServer", { username: value, error: "" })
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
@@ -383,7 +317,7 @@ export function DialogSelectServer() {
}
const handleAddPasswordChange = (value: string) => {
if (addMutation.isPending) return
if (store.addServer.adding) return
setStore("addServer", { password: value, error: "" })
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
setStore("addServer", { status: next }),
@@ -391,7 +325,7 @@ export function DialogSelectServer() {
}
const handleEditChange = (value: string) => {
if (editMutation.isPending) return
if (store.editServer.busy) return
setStore("editServer", { value, error: "" })
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
@@ -399,12 +333,12 @@ export function DialogSelectServer() {
}
const handleEditNameChange = (value: string) => {
if (editMutation.isPending) return
if (store.editServer.busy) return
setStore("editServer", { name: value, error: "" })
}
const handleEditUsernameChange = (value: string) => {
if (editMutation.isPending) return
if (store.editServer.busy) return
setStore("editServer", { username: value, error: "" })
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
@@ -412,13 +346,85 @@ export function DialogSelectServer() {
}
const handleEditPasswordChange = (value: string) => {
if (editMutation.isPending) return
if (store.editServer.busy) return
setStore("editServer", { password: value, error: "" })
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
setStore("editServer", { status: next }),
)
}
async function handleAdd(value: string) {
if (store.addServer.adding) return
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
return
}
setStore("addServer", { adding: true, error: "" })
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.password) conn.http.password = store.addServer.password
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
const result = await checkServerHealth(conn.http)
setStore("addServer", { adding: false })
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
await select(conn, true)
}
async function handleEdit(original: ServerConnection.Any, value: string) {
if (store.editServer.busy || original.type !== "http") return
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetEdit()
return
}
const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = original.displayName
if (
normalized === original.http.url &&
name === existingName &&
username === original.http.username &&
password === original.http.password
) {
resetEdit()
return
}
setStore("editServer", { busy: true, error: "" })
const conn: ServerConnection.Http = {
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http)
setStore("editServer", { busy: false })
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === original.http.url) {
server.add(conn)
} else {
replaceServer(original, conn)
}
resetEdit()
}
const mode = createMemo<"list" | "add" | "edit">(() => {
if (store.editServer.id) return "edit"
if (store.addServer.showForm) return "add"
@@ -458,26 +464,23 @@ export function DialogSelectServer() {
password: conn.http.password ?? "",
error: "",
status: store.status[ServerConnection.key(conn)]?.healthy,
busy: false,
})
}
const submitForm = () => {
if (mode() === "add") {
if (addMutation.isPending) return
setStore("addServer", { error: "" })
addMutation.mutate(store.addServer.url)
void handleAdd(store.addServer.url)
return
}
const original = editing()
if (!original) return
if (editMutation.isPending) return
setStore("editServer", { error: "" })
editMutation.mutate({ original, value: store.editServer.value })
void handleEdit(original, store.editServer.value)
}
const isFormMode = createMemo(() => mode() !== "list")
const isAddMode = createMemo(() => mode() === "add")
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy))
const formTitle = createMemo(() => {
if (!isFormMode()) return language.t("dialog.server.title")

View File

@@ -1,6 +1,6 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal } from "solid-js"
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
@@ -36,7 +36,6 @@ import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { promptEnabled, promptProbe } from "@/testing/prompt"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments } from "./prompt-input/attachments"
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
@@ -244,23 +243,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
},
)
const working = createMemo(() => status()?.type !== "idle")
const tip = () => {
if (working()) {
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
)
}
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
)
}
const imageAttachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
@@ -572,7 +554,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
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]
const paths = await files.searchFilesAndDirectories(query)
const fileOptions: AtOption[] = paths
.filter((path) => !seen.has(path))
@@ -623,7 +604,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
if (!cmd) return
promptProbe.select(cmd.id)
closePopover()
if (cmd.type === "custom") {
@@ -712,20 +692,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
})
if (promptEnabled()) {
createEffect(() => {
promptProbe.set({
popover: store.popover,
slash: {
active: slashActive() ?? null,
ids: slashFlat().map((cmd) => cmd.id),
},
})
})
onCleanup(() => promptProbe.clear())
}
const selectPopoverActive = () => {
if (store.popover === "at") {
const items = atFlat()
@@ -1044,7 +1010,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return true
}
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({
editor: () => editorRef,
isDialogActive: () => !!dialog.active,
setDraggingType: (type) => setStore("draggingType", type),
@@ -1242,20 +1208,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
// Note: Shift+Enter is handled earlier, before IME check
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
if (event.repeat) return
if (
working() &&
prompt
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
.trim().length === 0 &&
imageAttachments().length === 0 &&
commentCount() === 0
) {
return
}
handleSubmit(event)
}
}
@@ -1384,18 +1336,36 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_FILE_TYPES.join(",")}
class="hidden"
onChange={(e) => {
const list = e.currentTarget.files
if (list) void addAttachments(Array.from(list))
const file = e.currentTarget.files?.[0]
if (file) void addAttachment(file)
e.currentTarget.value = ""
}}
/>
<div class="flex items-center gap-1 pointer-events-auto">
<Tooltip placement="top" inactive={!prompt.dirty() && !working()} value={tip()}>
<Tooltip
placement="top"
inactive={!prompt.dirty() && !working()}
value={
<Switch>
<Match when={working()}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
</Match>
<Match when={true}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
</Match>
</Switch>
}
>
<IconButton
data-action="prompt-submit"
type="submit"
@@ -1498,7 +1468,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
id={local.model.current()!.provider.id}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
@@ -1530,7 +1500,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
id={local.model.current()!.provider.id}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>

View File

@@ -71,18 +71,6 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const addAttachment = (file: File) => add(file)
const addAttachments = async (files: File[], toast = true) => {
let found = false
for (const file of files) {
const ok = await add(file, false)
if (ok) found = true
}
if (!found && files.length > 0 && toast) warn()
return found
}
const removeAttachment = (id: string) => {
const current = prompt.current()
const next = current.filter((part) => part.type !== "image" || part.id !== id)
@@ -96,14 +84,18 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
event.preventDefault()
event.stopPropagation()
const files = Array.from(clipboardData.items).flatMap((item) => {
if (item.kind !== "file") return []
const file = item.getAsFile()
return file ? [file] : []
})
const items = Array.from(clipboardData.items)
const fileItems = items.filter((item) => item.kind === "file")
if (files.length > 0) {
await addAttachments(files)
if (fileItems.length > 0) {
let found = false
for (const item of fileItems) {
const file = item.getAsFile()
if (!file) continue
const ok = await add(file, false)
if (ok) found = true
}
if (!found) warn()
return
}
@@ -177,7 +169,12 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const dropped = event.dataTransfer?.files
if (!dropped) return
await addAttachments(Array.from(dropped))
let found = false
for (const file of Array.from(dropped)) {
const ok = await add(file, false)
if (ok) found = true
}
if (!found && dropped.length > 0) warn()
}
onMount(() => {
@@ -194,7 +191,6 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
return {
addAttachment,
addAttachments,
removeAttachment,
handlePaste,
}

View File

@@ -49,32 +49,6 @@ describe("buildRequestParts", () => {
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
})
test("keeps multiple uploaded attachments in order", () => {
const result = buildRequestParts({
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
{
type: "image",
id: "img_2",
filename: "b.pdf",
mime: "application/pdf",
dataUrl: "data:application/pdf;base64,BBB",
},
],
text: "check these",
messageID: "msg_multi",
sessionID: "ses_multi",
sessionDirectory: "/repo",
})
const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
expect(files).toHaveLength(2)
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
})
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

@@ -1,6 +1,4 @@
import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-picker"
export { ACCEPTED_FILE_TYPES }
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
const IMAGE_EXTS = new Map([
@@ -20,6 +18,61 @@ const TEXT_MIMES = new Set([
"application/yaml",
])
export const ACCEPTED_FILE_TYPES = [
...ACCEPTED_IMAGE_TYPES,
"application/pdf",
"text/*",
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
".c",
".cc",
".cjs",
".conf",
".cpp",
".css",
".csv",
".cts",
".env",
".go",
".gql",
".graphql",
".h",
".hh",
".hpp",
".htm",
".html",
".ini",
".java",
".js",
".json",
".jsx",
".log",
".md",
".mdx",
".mjs",
".mts",
".py",
".rb",
".rs",
".sass",
".scss",
".sh",
".sql",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
".zsh",
]
const SAMPLE = 4096
function kind(type: string) {

View File

@@ -126,7 +126,7 @@ describe("prompt-input history", () => {
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
const value = "a\nb\nc"
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(false)
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
@@ -135,14 +135,11 @@ describe("prompt-input history", () => {
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(false)
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("up", "", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)

View File

@@ -27,7 +27,7 @@ export function canNavigateHistoryAtCursor(direction: "up" | "down", text: strin
const atStart = position === 0
const atEnd = position === text.length
if (inHistory) return atStart || atEnd
if (direction === "up") return position === 0 && text.length === 0
if (direction === "up") return position === 0
return position === text.length
}

View File

@@ -267,14 +267,14 @@ export function SessionContextTab() {
return (
<ScrollView
class="@container h-full"
class="@container h-full pb-10"
viewportRef={(el) => {
scroll = el
restoreScroll()
}}
onScroll={handleScroll}
>
<div class="px-6 pt-4 pb-10 flex flex-col gap-10">
<div class="px-6 pt-4 flex flex-col gap-10">
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
<For each={stats}>
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}

View File

@@ -274,11 +274,12 @@ export function SessionHeader() {
type="button"
variant="ghost"
size="small"
class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
class="hidden md:flex w-[240px] max-w-full min-w-0 pl-0.5 pr-2 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
onClick={() => command.trigger("file.open")}
aria-label={language.t("session.header.searchFiles")}
>
<div class="flex min-w-0 flex-1 items-center overflow-visible">
<div class="flex min-w-0 flex-1 items-center gap-1.5 overflow-visible">
<Icon name="magnifying-glass" size="small" class="icon-base shrink-0 size-4" />
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
{language.t("session.header.search.placeholder", {
project: name(),

View File

@@ -24,7 +24,6 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
})
let input: HTMLInputElement | undefined
let blurFrame: number | undefined
let editRequested = false
const isDefaultTitle = () => {
const number = props.terminal.titleNumber
@@ -169,14 +168,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
left: `${store.menuPosition.x}px`,
top: `${store.menuPosition.y}px`,
}}
onCloseAutoFocus={(e) => {
if (!editRequested) return
e.preventDefault()
editRequested = false
requestAnimationFrame(() => edit())
}}
>
<DropdownMenu.Item onSelect={() => (editRequested = true)}>
<DropdownMenu.Item onSelect={edit}>
<Icon name="edit" class="w-4 h-4 mr-2" />
{language.t("common.rename")}
</DropdownMenu.Item>

View File

@@ -1,41 +1,27 @@
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
import { Component, Show, createMemo, createResource, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings, monoFontFamily } from "@/context/settings"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link"
import { SettingsList } from "./settings-list"
let demoSoundState = {
cleanup: undefined as (() => void) | undefined,
timeout: undefined as NodeJS.Timeout | undefined,
run: 0,
}
type ThemeOption = {
id: string
name: string
}
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
function loadFont() {
font ??= import("@opencode-ai/ui/font-loader")
return font
}
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
// delay the playback by 100ms during quick selection changes and pause existing sounds.
const stopDemoSound = () => {
demoSoundState.run += 1
if (demoSoundState.cleanup) {
demoSoundState.cleanup()
}
@@ -43,19 +29,12 @@ const stopDemoSound = () => {
demoSoundState.cleanup = undefined
}
const playDemoSound = (id: string | undefined) => {
const playDemoSound = (src: string | undefined) => {
stopDemoSound()
if (!id) return
if (!src) return
const run = ++demoSoundState.run
demoSoundState.timeout = setTimeout(() => {
void playSoundById(id).then((cleanup) => {
if (demoSoundState.run !== run) {
cleanup?.()
return
}
demoSoundState.cleanup = cleanup
})
demoSoundState.cleanup = playSound(src)
}, 100)
}
@@ -65,10 +44,6 @@ export const SettingsGeneral: Component = () => {
const platform = usePlatform()
const settings = useSettings()
onMount(() => {
void theme.loadThemes()
})
const [store, setStore] = createStore({
checking: false,
})
@@ -129,7 +104,9 @@ export const SettingsGeneral: Component = () => {
.finally(() => setStore("checking", false))
}
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const themeOptions = createMemo(() =>
Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })),
)
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") },
@@ -166,7 +143,7 @@ export const SettingsGeneral: Component = () => {
] as const
const fontOptionsList = [...fontOptions]
const noneSound = { id: "none", label: "sound.option.none" } as const
const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const
const soundOptions = [noneSound, ...SOUND_OPTIONS]
const soundSelectProps = (
@@ -181,7 +158,7 @@ export const SettingsGeneral: Component = () => {
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
playDemoSound(option.id === "none" ? undefined : option.id)
playDemoSound(option.src)
},
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
@@ -192,7 +169,7 @@ export const SettingsGeneral: Component = () => {
}
setEnabled(true)
set(option.id)
playDemoSound(option.id)
playDemoSound(option.src)
},
variant: "secondary" as const,
size: "small" as const,
@@ -344,9 +321,6 @@ export const SettingsGeneral: Component = () => {
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
value={(o) => o.value}
label={(o) => language.t(o.label)}
onHighlight={(option) => {
void loadFont().then((x) => x.ensureMonoFont(option?.value))
}}
onSelect={(option) => option && settings.appearance.setFont(option.value)}
variant="secondary"
size="small"

View File

@@ -4,7 +4,6 @@ import { Icon } from "@opencode-ai/ui/icon"
import { Popover } from "@opencode-ai/ui/popover"
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
@@ -16,6 +15,7 @@ import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
import { DialogSelectServer } from "./dialog-select-server"
const pollMs = 10_000
@@ -53,15 +53,11 @@ const listServersByHealth = (
})
}
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
const checkServerHealth = useCheckServerHealth()
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => {
if (!enabled()) {
setStatus(reconcile({}))
return
}
const list = servers()
let dead = false
@@ -134,30 +130,41 @@ const useDefaultServerKey = (
}
}
const useMcpToggleMutation = () => {
const sync = useSync()
const sdk = useSDK()
const language = useLanguage()
const useMcpToggle = (input: {
sync: ReturnType<typeof useSync>
sdk: ReturnType<typeof useSDK>
language: ReturnType<typeof useLanguage>
}) => {
const [loading, setLoading] = createSignal<string | null>(null)
return useMutation(() => ({
mutationFn: async (name: string) => {
const status = sync.data.mcp[name]
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data)
},
onError: (err) => {
const toggle = async (name: string) => {
if (loading()) return
setLoading(name)
try {
const status = input.sync.data.mcp[name]
await (status?.status === "connected"
? input.sdk.client.mcp.disconnect({ name })
: input.sdk.client.mcp.connect({ name }))
const result = await input.sdk.client.mcp.status()
if (result.data) input.sync.set("mcp", result.data)
} catch (err) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
title: input.language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
},
}))
} finally {
setLoading(null)
}
}
return { loading, toggle }
}
export function StatusPopover() {
const sync = useSync()
const sdk = useSDK()
const server = useServer()
const platform = usePlatform()
const dialog = useDialog()
@@ -165,12 +172,6 @@ export function StatusPopover() {
const navigate = useNavigate()
const [shown, setShown] = createSignal(false)
let dialogRun = 0
let dialogDead = false
onCleanup(() => {
dialogDead = true
dialogRun += 1
})
const servers = createMemo(() => {
const current = server.current
const list = server.list
@@ -178,9 +179,9 @@ export function StatusPopover() {
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
})
const health = useServerHealth(servers, shown)
const health = useServerHealth(servers)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const toggleMcp = useMcpToggleMutation()
const mcp = useMcpToggle({ sync, sdk, language })
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
@@ -276,8 +277,8 @@ export function StatusPopover() {
aria-disabled={isBlocked()}
onClick={() => {
if (isBlocked()) return
server.setActive(key)
navigate("/")
queueMicrotask(() => server.setActive(key))
}}
>
<ServerHealthIndicator health={health[key]} />
@@ -309,13 +310,7 @@ export function StatusPopover() {
<Button
variant="secondary"
class="mt-3 self-start h-8 px-3 py-1.5"
onClick={() => {
const run = ++dialogRun
void import("./dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
})
}}
onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)}
>
{language.t("status.popover.action.manageServers")}
</Button>
@@ -342,11 +337,8 @@ export function StatusPopover() {
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
onClick={() => mcp.toggle(name)}
disabled={mcp.loading() === name}
>
<div
classList={{
@@ -362,11 +354,8 @@ export function StatusPopover() {
<div onClick={(event) => event.stopPropagation()}>
<Switch
checked={enabled()}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
onChange={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}
disabled={mcp.loading() === name}
onChange={() => mcp.toggle(name)}
/>
</div>
</button>

View File

@@ -1,7 +1,4 @@
import { withAlpha } from "@opencode-ai/ui/theme/color"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
import type { HexColor } from "@opencode-ai/ui/theme/types"
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
import { showToast } from "@opencode-ai/ui/toast"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
@@ -68,11 +65,14 @@ const debugTerminal = (...values: unknown[]) => {
console.debug("[terminal]", ...values)
}
const errorName = (err: unknown) => {
const errorStatus = (err: unknown) => {
if (!err || typeof err !== "object") return
if (!("name" in err)) return
const errorName = err.name
return typeof errorName === "string" ? errorName : undefined
if (!("data" in err)) return
const data = err.data
if (!data || typeof data !== "object") return
if (!("statusCode" in data)) return
const status = data.statusCode
return typeof status === "number" ? status : undefined
}
const useTerminalUiBindings = (input: {
@@ -168,12 +168,6 @@ export const Terminal = (props: TerminalProps) => {
const theme = useTheme()
const language = useLanguage()
const server = useServer()
const directory = sdk.directory
const client = sdk.client
const url = sdk.url
const auth = server.current?.http
const username = auth?.username ?? "opencode"
const password = auth?.password ?? ""
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
const id = local.pty.id
@@ -224,7 +218,7 @@ export const Terminal = (props: TerminalProps) => {
}
const pushSize = (cols: number, rows: number) => {
return client.pty
return sdk.client.pty
.update({
ptyID: id,
size: { cols, rows },
@@ -483,11 +477,11 @@ export const Terminal = (props: TerminalProps) => {
}
const gone = () =>
client.pty
sdk.client.pty
.get({ ptyID: id })
.then(() => false)
.catch((err) => {
if (errorName(err) === "NotFoundError") return true
if (errorStatus(err) === 404) return true
debugTerminal("failed to inspect terminal session", err)
return false
})
@@ -515,14 +509,14 @@ export const Terminal = (props: TerminalProps) => {
if (disposed) return
drop?.()
const next = new URL(url + `/pty/${id}/connect`)
next.searchParams.set("directory", directory)
next.searchParams.set("cursor", String(seek))
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
next.username = username
next.password = password
const url = new URL(sdk.url + `/pty/${id}/connect`)
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(seek))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.username = server.current?.http.username ?? "opencode"
url.password = server.current?.http.password ?? ""
const socket = new WebSocket(next)
const socket = new WebSocket(url)
socket.binaryType = "arraybuffer"
ws = socket

View File

@@ -5,7 +5,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { useTheme } from "@opencode-ai/ui/theme"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
@@ -77,7 +77,6 @@ export function Titlebar() {
const canBack = createMemo(() => history.index > 0)
const canForward = createMemo(() => history.index < history.stack.length - 1)
const hasProjects = createMemo(() => layout.projects.list().length > 0)
const back = () => {
const next = backPath(history)
@@ -252,38 +251,36 @@ export function Titlebar() {
</div>
</div>
</Show>
<Show when={hasProjects()}>
<div
class="flex items-center gap-0 transition-transform"
classList={{
"translate-x-0": !layout.sidebar.opened(),
"-translate-x-[36px]": layout.sidebar.opened(),
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
</Show>
<div
class="flex items-center gap-0 transition-transform"
classList={{
"translate-x-0": !layout.sidebar.opened(),
"-translate-x-[36px]": layout.sidebar.opened(),
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
</div>
</div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />

View File

@@ -1,89 +0,0 @@
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
export const ACCEPTED_FILE_TYPES = [
...ACCEPTED_IMAGE_TYPES,
"application/pdf",
"text/*",
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
".c",
".cc",
".cjs",
".conf",
".cpp",
".css",
".csv",
".cts",
".env",
".go",
".gql",
".graphql",
".h",
".hh",
".hpp",
".htm",
".html",
".ini",
".java",
".js",
".json",
".jsx",
".log",
".md",
".mdx",
".mjs",
".mts",
".py",
".rb",
".rs",
".sass",
".scss",
".sh",
".sql",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
".zsh",
]
const MIME_EXT = new Map([
["image/png", "png"],
["image/jpeg", "jpg"],
["image/gif", "gif"],
["image/webp", "webp"],
["application/pdf", "pdf"],
["application/json", "json"],
["application/ld+json", "jsonld"],
["application/toml", "toml"],
["application/x-toml", "toml"],
["application/x-yaml", "yaml"],
["application/xml", "xml"],
["application/yaml", "yaml"],
])
const TEXT_EXT = ["txt", "text", "md", "markdown", "log", "csv"]
export const ACCEPTED_FILE_EXTENSIONS = Array.from(
new Set(
ACCEPTED_FILE_TYPES.flatMap((item) => {
if (item.startsWith(".")) return [item.slice(1)]
if (item === "text/*") return TEXT_EXT
const out = MIME_EXT.get(item)
return out ? [out] : []
}),
),
).sort()
export function filePickerFilters(ext?: string[]) {
if (!ext || ext.length === 0) return undefined
return [{ name: "Files", extensions: ext }]
}

View File

@@ -40,11 +40,4 @@ describe("command keybind helpers", () => {
expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
expect(formatKeybind("none")).toBe("")
})
test("formatKeybind prefers the first combo", () => {
const display = formatKeybind("mod+k,mod+p")
expect(display.includes("K") || display.includes("k")).toBe(true)
expect(display.includes("P") || display.includes("p")).toBe(false)
})
})

View File

@@ -1,10 +1,10 @@
import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { dict as en } from "@/i18n/en"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { dict as en } from "@/i18n/en"
import { Persist, persisted } from "@/utils/persist"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
@@ -238,10 +238,9 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
})
const warnedDuplicates = new Set<string>()
type CommandCatalog = Record<string, CommandCatalogItem>
const [catalog, setCatalog, _, catalogReady] = persisted(
Persist.global("command.catalog.v1"),
createStore<CommandCatalog>({}),
createStore<Record<string, CommandCatalogItem>>({}),
)
const bind = (id: string, def: KeybindConfig | undefined) => {
@@ -260,7 +259,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
if (seen.has(opt.id)) {
if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
warnedDuplicates.add(opt.id)
console.warn(`[command] duplicate command id "${opt.id}" registered; keeping first entry`)
console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`)
}
continue
}
@@ -275,19 +274,16 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
createEffect(() => {
if (!catalogReady()) return
setCatalog(
registered().reduce((acc, opt) => {
const id = actionId(opt.id)
acc[id] = {
title: opt.title,
description: opt.description,
category: opt.category,
keybind: opt.keybind,
slash: opt.slash,
}
return acc
}, {} as CommandCatalog),
)
for (const opt of registered()) {
const id = actionId(opt.id)
setCatalog(id, {
title: opt.title,
description: opt.description,
category: opt.category,
keybind: opt.keybind,
slash: opt.slash,
})
}
})
const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))

View File

@@ -9,7 +9,17 @@ import type {
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
import {
createContext,
getOwner,
Match,
onCleanup,
onMount,
type ParentProps,
Switch,
untrack,
useContext,
} from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist"
@@ -70,8 +80,6 @@ function createGlobalSync() {
let active = true
let projectWritten = false
let bootedAt = 0
let bootingRoot = false
onCleanup(() => {
active = false
@@ -250,11 +258,6 @@ function createGlobalSync() {
const sdk = sdkFor(directory)
await bootstrapDirectory({
directory,
global: {
config: globalStore.config,
project: globalStore.project,
provider: globalStore.provider,
},
sdk,
store: child[0],
setStore: child[1],
@@ -275,20 +278,15 @@ function createGlobalSync() {
const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
const recent = bootingRoot || Date.now() - bootedAt < 1500
if (directory === "global") {
applyGlobalEvent({
event,
project: globalStore.project,
refresh: () => {
if (recent) return
queue.refresh()
},
refresh: queue.refresh,
setGlobalProject: setProjects,
})
if (event.type === "server.connected" || event.type === "global.disposed") {
if (recent) return
for (const directory of Object.keys(children.children)) {
queue.push(directory)
}
@@ -327,19 +325,17 @@ function createGlobalSync() {
})
async function bootstrap() {
bootingRoot = true
try {
await bootstrapGlobal({
globalSDK: globalSDK.client,
requestFailedTitle: language.t("common.requestFailed"),
translate: language.t,
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
setGlobalStore: setBootStore,
})
bootedAt = Date.now()
} finally {
bootingRoot = false
}
await bootstrapGlobal({
globalSDK: globalSDK.client,
connectErrorTitle: language.t("dialog.server.add.error"),
connectErrorDescription: language.t("error.globalSync.connectFailed", {
url: globalSDK.url,
}),
requestFailedTitle: language.t("common.requestFailed"),
translate: language.t,
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
setGlobalStore: setBootStore,
})
}
onMount(() => {
@@ -382,7 +378,6 @@ function createGlobalSync() {
return globalStore.error
},
child: children.child,
peek: children.peek,
bootstrap,
updateConfig,
project: projectApi,
@@ -396,7 +391,13 @@ const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
export function GlobalSyncProvider(props: ParentProps) {
const value = createGlobalSync()
return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
return (
<Switch>
<Match when={value.ready}>
<GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
</Match>
</Switch>
)
}
export function useGlobalSync() {

View File

@@ -31,102 +31,73 @@ type GlobalStore = {
reload: undefined | "pending" | "complete"
}
function waitForPaint() {
return new Promise<void>((resolve) => {
let done = false
const finish = () => {
if (done) return
done = true
resolve()
}
const timer = setTimeout(finish, 50)
if (typeof requestAnimationFrame !== "function") return
requestAnimationFrame(() => {
clearTimeout(timer)
finish()
})
})
}
function errors(list: PromiseSettledResult<unknown>[]) {
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
}
function runAll(list: Array<() => Promise<unknown>>) {
return Promise.allSettled(list.map((item) => item()))
}
function showErrors(input: {
errors: unknown[]
title: string
translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string
}) {
if (input.errors.length === 0) return
const message = formatServerError(input.errors[0], input.translate)
const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
showToast({
variant: "error",
title: input.title,
description: message + more,
})
}
export async function bootstrapGlobal(input: {
globalSDK: OpencodeClient
connectErrorTitle: string
connectErrorDescription: string
requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string
setGlobalStore: SetStoreFunction<GlobalStore>
}) {
const fast = [
() =>
retry(() =>
input.globalSDK.path.get().then((x) => {
input.setGlobalStore("path", x.data!)
}),
),
() =>
retry(() =>
input.globalSDK.global.config.get().then((x) => {
input.setGlobalStore("config", x.data!)
}),
),
() =>
retry(() =>
input.globalSDK.provider.list().then((x) => {
input.setGlobalStore("provider", normalizeProviderList(x.data!))
}),
),
const health = await input.globalSDK.global
.health()
.then((x) => x.data)
.catch(() => undefined)
if (!health?.healthy) {
showToast({
variant: "error",
title: input.connectErrorTitle,
description: input.connectErrorDescription,
})
input.setGlobalStore("ready", true)
return
}
const tasks = [
retry(() =>
input.globalSDK.path.get().then((x) => {
input.setGlobalStore("path", x.data!)
}),
),
retry(() =>
input.globalSDK.global.config.get().then((x) => {
input.setGlobalStore("config", x.data!)
}),
),
retry(() =>
input.globalSDK.project.list().then((x) => {
const projects = (x.data ?? [])
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
input.setGlobalStore("project", projects)
}),
),
retry(() =>
input.globalSDK.provider.list().then((x) => {
input.setGlobalStore("provider", normalizeProviderList(x.data!))
}),
),
retry(() =>
input.globalSDK.provider.auth().then((x) => {
input.setGlobalStore("provider_auth", x.data ?? {})
}),
),
]
const slow = [
() =>
retry(() =>
input.globalSDK.project.list().then((x) => {
const projects = (x.data ?? [])
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
input.setGlobalStore("project", projects)
}),
),
]
showErrors({
errors: errors(await runAll(fast)),
title: input.requestFailedTitle,
translate: input.translate,
formatMoreCount: input.formatMoreCount,
})
await waitForPaint()
showErrors({
errors: errors(await runAll(slow)),
title: input.requestFailedTitle,
translate: input.translate,
formatMoreCount: input.formatMoreCount,
})
const results = await Promise.allSettled(tasks)
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
if (errors.length) {
const message = formatServerError(errors[0], input.translate)
const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : ""
showToast({
variant: "error",
title: input.requestFailedTitle,
description: message + more,
})
}
input.setGlobalStore("ready", true)
}
@@ -140,10 +111,6 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
}, {})
}
function projectID(directory: string, projects: Project[]) {
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
}
export async function bootstrapDirectory(input: {
directory: string
sdk: OpencodeClient
@@ -152,130 +119,88 @@ export async function bootstrapDirectory(input: {
vcsCache: VcsCache
loadSessions: (directory: string) => Promise<void> | void
translate: (key: string, vars?: Record<string, string | number>) => string
global: {
config: Config
project: Project[]
provider: ProviderListResponse
}
}) {
const loading = input.store.status !== "complete"
const seededProject = projectID(input.directory, input.global.project)
if (seededProject) input.setStore("project", seededProject)
if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) {
input.setStore("provider", input.global.provider)
}
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
input.setStore("config", input.global.config)
}
if (loading) input.setStore("status", "partial")
if (input.store.status !== "complete") input.setStore("status", "loading")
const fast = [
() =>
seededProject
? Promise.resolve()
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))),
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
() =>
retry(() =>
input.sdk.path.get().then((x) => {
input.setStore("path", x.data!)
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
}),
),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
() =>
retry(() =>
input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs
input.setStore("vcs", next)
if (next?.branch) input.vcsCache.setStore("value", next)
}),
),
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
() =>
retry(() =>
input.sdk.permission.list().then((x) => {
const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
const blockingRequests = {
project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
provider: () =>
input.sdk.provider.list().then((x) => {
input.setStore("provider", normalizeProviderList(x.data!))
}),
agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
}
try {
await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
} catch (err) {
console.error("Failed to bootstrap instance", err)
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(err, input.translate),
})
input.setStore("status", "partial")
return
}
if (input.store.status !== "complete") input.setStore("status", "partial")
Promise.all([
input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
input.loadSessions(input.directory),
input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs
input.setStore("vcs", next)
if (next?.branch) input.vcsCache.setStore("value", next)
}),
input.sdk.permission.list().then((x) => {
const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
)
batch(() => {
for (const sessionID of Object.keys(input.store.permission)) {
if (grouped[sessionID]) continue
input.setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
input.setStore(
"permission",
sessionID,
reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
batch(() => {
for (const sessionID of Object.keys(input.store.permission)) {
if (grouped[sessionID]) continue
input.setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
input.setStore(
"permission",
sessionID,
reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
),
() =>
retry(() =>
input.sdk.question.list().then((x) => {
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
batch(() => {
for (const sessionID of Object.keys(input.store.question)) {
if (grouped[sessionID]) continue
input.setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
input.setStore(
"question",
sessionID,
reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
),
]
const slow = [
() =>
retry(() =>
input.sdk.provider.list().then((x) => {
input.setStore("provider", normalizeProviderList(x.data!))
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
() => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))),
() => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))),
]
const errs = errors(await runAll(fast))
if (errs.length > 0) {
console.error("Failed to bootstrap instance", errs[0])
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(errs[0], input.translate),
})
}
await waitForPaint()
const slowErrs = errors(await runAll(slow))
if (slowErrs.length > 0) {
console.error("Failed to finish bootstrap instance", slowErrs[0])
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(slowErrs[0], input.translate),
})
}
if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
}
})
}),
input.sdk.question.list().then((x) => {
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
batch(() => {
for (const sessionID of Object.keys(input.store.question)) {
if (grouped[sessionID]) continue
input.setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
input.setStore(
"question",
sessionID,
reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
]).then(() => {
input.setStore("status", "complete")
})
}

View File

@@ -226,15 +226,6 @@ export function createChildStoreManager(input: {
return childStore
}
function peek(directory: string, options: ChildOptions = {}) {
const childStore = ensureChild(directory)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
}
return childStore
}
function projectMeta(directory: string, patch: ProjectMeta) {
const [store, setStore] = ensureChild(directory)
const cached = metaCache.get(directory)
@@ -265,7 +256,6 @@ export function createChildStoreManager(input: {
children,
ensureChild,
child,
peek,
projectMeta,
projectIcon,
mark,

View File

@@ -15,8 +15,6 @@ import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown }
project: Project[]
@@ -213,7 +211,6 @@ export function applyDirectoryEvent(input: {
}
case "message.part.updated": {
const part = (event.properties as { part: Part }).part
if (SKIP_PARTS.has(part.type)) break
const parts = input.store.part[part.messageID]
if (!parts) {
input.setStore("part", part.messageID, [part])

View File

@@ -1,10 +1,42 @@
import * as i18n from "@solid-primitives/i18n"
import { createEffect, createMemo, createResource } from "solid-js"
import { createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { Persist, persisted } from "@/utils/persist"
import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { dict as zht } from "@/i18n/zht"
import { dict as ko } from "@/i18n/ko"
import { dict as de } from "@/i18n/de"
import { dict as es } from "@/i18n/es"
import { dict as fr } from "@/i18n/fr"
import { dict as da } from "@/i18n/da"
import { dict as ja } from "@/i18n/ja"
import { dict as pl } from "@/i18n/pl"
import { dict as ru } from "@/i18n/ru"
import { dict as ar } from "@/i18n/ar"
import { dict as no } from "@/i18n/no"
import { dict as br } from "@/i18n/br"
import { dict as th } from "@/i18n/th"
import { dict as bs } from "@/i18n/bs"
import { dict as tr } from "@/i18n/tr"
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
import { dict as uiZht } from "@opencode-ai/ui/i18n/zht"
import { dict as uiKo } from "@opencode-ai/ui/i18n/ko"
import { dict as uiDe } from "@opencode-ai/ui/i18n/de"
import { dict as uiEs } from "@opencode-ai/ui/i18n/es"
import { dict as uiFr } from "@opencode-ai/ui/i18n/fr"
import { dict as uiDa } from "@opencode-ai/ui/i18n/da"
import { dict as uiJa } from "@opencode-ai/ui/i18n/ja"
import { dict as uiPl } from "@opencode-ai/ui/i18n/pl"
import { dict as uiRu } from "@opencode-ai/ui/i18n/ru"
import { dict as uiAr } from "@opencode-ai/ui/i18n/ar"
import { dict as uiNo } from "@opencode-ai/ui/i18n/no"
import { dict as uiBr } from "@opencode-ai/ui/i18n/br"
import { dict as uiTh } from "@opencode-ai/ui/i18n/th"
import { dict as uiBs } from "@opencode-ai/ui/i18n/bs"
import { dict as uiTr } from "@opencode-ai/ui/i18n/tr"
export type Locale =
| "en"
@@ -27,7 +59,6 @@ export type Locale =
type RawDictionary = typeof en & typeof uiEn
type Dictionary = i18n.Flatten<RawDictionary>
type Source = { dict: Record<string, string> }
function cookie(locale: Locale) {
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
@@ -94,43 +125,24 @@ const LABEL_KEY: Record<Locale, keyof Dictionary> = {
}
const base = i18n.flatten({ ...en, ...uiEn })
const dicts = new Map<Locale, Dictionary>([["en", base]])
const merge = (app: Promise<Source>, ui: Promise<Source>) =>
Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary)
const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")),
zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")),
ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")),
de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")),
es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")),
fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")),
da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")),
ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")),
pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")),
ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")),
ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")),
no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")),
br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")),
th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")),
}
function loadDict(locale: Locale) {
const hit = dicts.get(locale)
if (hit) return Promise.resolve(hit)
if (locale === "en") return Promise.resolve(base)
const load = loaders[locale]
return load().then((next: Dictionary) => {
dicts.set(locale, next)
return next
})
}
export function loadLocaleDict(locale: Locale) {
return loadDict(locale).then(() => undefined)
const DICT: Record<Locale, Dictionary> = {
en: base,
zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) },
zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) },
ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) },
de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) },
es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) },
fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) },
da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) },
ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) },
pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) },
ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) },
ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) },
no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) },
br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) },
th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) },
bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) },
tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr }) },
}
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
@@ -156,6 +168,27 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole
{ locale: "tr", match: (language) => language.startsWith("tr") },
]
type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
zh,
zht,
ko,
de,
es,
fr,
da,
ja,
pl,
ru,
ar,
no,
br,
th,
bs,
tr,
}
void PARITY_CHECK
function detectLocale(): Locale {
if (typeof navigator !== "object") return "en"
@@ -170,48 +203,27 @@ function detectLocale(): Locale {
return "en"
}
export function normalizeLocale(value: string): Locale {
function normalizeLocale(value: string): Locale {
return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
}
function readStoredLocale() {
if (typeof localStorage !== "object") return
try {
const raw = localStorage.getItem("opencode.global.dat:language")
if (!raw) return
const next = JSON.parse(raw) as { locale?: string }
if (typeof next?.locale !== "string") return
return normalizeLocale(next.locale)
} catch {
return
}
}
const warm = readStoredLocale() ?? detectLocale()
if (warm !== "en") void loadDict(warm)
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
name: "Language",
init: (props: { locale?: Locale }) => {
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
init: () => {
const [store, setStore, _, ready] = persisted(
Persist.global("language", ["language.v1"]),
createStore({
locale: initial,
locale: detectLocale() as Locale,
}),
)
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
console.log("locale", locale())
const intl = createMemo(() => INTL[locale()])
const [dict] = createResource(locale, loadDict, {
initialValue: dicts.get(initial) ?? base,
})
const dict = createMemo<Dictionary>(() => DICT[locale()])
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
key: keyof Dictionary,
params?: Record<string, string | number | boolean>,
) => string
const t = i18n.translator(dict, i18n.resolveTemplate)
const label = (value: Locale) => t(LABEL_KEY[value])

View File

@@ -12,7 +12,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist"
import { playSoundById } from "@/utils/sound"
import { playSound, soundSrc } from "@/utils/sound"
type NotificationBase = {
directory?: string
@@ -234,7 +234,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
if (session.parentID) return
if (settings.sounds.agentEnabled()) {
void playSoundById(settings.sounds.agent())
playSound(soundSrc(settings.sounds.agent()))
}
append({
@@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
if (session?.parentID) return
if (settings.sounds.errorsEnabled()) {
void playSoundById(settings.sounds.errors())
playSound(soundSrc(settings.sounds.errors()))
}
const error = "error" in event.properties ? event.properties.error : undefined

View File

@@ -5,7 +5,7 @@ import { ServerConnection } from "./server"
type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
type OpenFilePickerOptions = { title?: string; multiple?: boolean }
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type UpdateInfo = { updateAvailable: boolean; version?: string }

View File

@@ -1,10 +1,10 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { checksum } from "@opencode-ai/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist"
import { checksum } from "@opencode-ai/util/encode"
interface PartBase {
content: string
@@ -250,7 +250,6 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
}
}
const owner = getOwner()
const load = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}`
const existing = cache.get(key)
@@ -260,13 +259,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
return existing.value
}
const entry = createRoot(
(dispose) => ({
value: createPromptSession(dir, id),
dispose,
}),
owner,
)
const entry = createRoot((dispose) => ({
value: createPromptSession(dir, id),
dispose,
}))
cache.set(key, entry)
prune()

View File

@@ -104,13 +104,6 @@ function withFallback<T>(read: () => T | undefined, fallback: T) {
return createMemo(() => read() ?? fallback)
}
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
function loadFont() {
font ??= import("@opencode-ai/ui/font-loader")
return font
}
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
name: "Settings",
init: () => {
@@ -118,11 +111,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
createEffect(() => {
if (typeof document === "undefined") return
const id = store.appearance?.font ?? defaultSettings.appearance.font
if (id !== defaultSettings.appearance.font) {
void loadFont().then((x) => x.ensureMonoFont(id))
}
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id))
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
})
return {

View File

@@ -14,8 +14,6 @@ import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
}
@@ -180,8 +178,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return globalSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const initialMessagePageSize = 80
const historyMessagePageSize = 200
const messagePageSize = 200
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
@@ -339,8 +336,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
batch(() => {
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
for (const p of next.part) {
const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
if (filtered.length) input.setStore("part", p.id, filtered)
input.setStore("part", p.id, p.part)
}
setMeta("limit", key, message.length)
setMeta("cursor", key, next.cursor)
@@ -464,7 +460,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
if (cached && hasSession && !opts?.force) return
const limit = meta.limit[key] ?? initialMessagePageSize
const limit = meta.limit[key] ?? messagePageSize
const sessionReq =
hasSession && !opts?.force
? Promise.resolve()
@@ -561,7 +557,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const [, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID)
const step = count ?? historyMessagePageSize
const step = count ?? messagePageSize
if (meta.loading[key]) return
if (meta.complete[key]) return
const before = meta.cursor[key]

View File

@@ -1,18 +1,45 @@
const template = "Terminal {{number}}"
import { dict as ar } from "@/i18n/ar"
import { dict as br } from "@/i18n/br"
import { dict as bs } from "@/i18n/bs"
import { dict as da } from "@/i18n/da"
import { dict as de } from "@/i18n/de"
import { dict as en } from "@/i18n/en"
import { dict as es } from "@/i18n/es"
import { dict as fr } from "@/i18n/fr"
import { dict as ja } from "@/i18n/ja"
import { dict as ko } from "@/i18n/ko"
import { dict as no } from "@/i18n/no"
import { dict as pl } from "@/i18n/pl"
import { dict as ru } from "@/i18n/ru"
import { dict as th } from "@/i18n/th"
import { dict as tr } from "@/i18n/tr"
import { dict as zh } from "@/i18n/zh"
import { dict as zht } from "@/i18n/zht"
const numbered = [
template,
"محطة طرفية {{number}}",
"Терминал {{number}}",
"ターミナル {{number}}",
"터미널 {{number}}",
"เทอร์มินัล {{number}}",
"终端 {{number}}",
"終端機 {{number}}",
]
const numbered = Array.from(
new Set([
en["terminal.title.numbered"],
ar["terminal.title.numbered"],
br["terminal.title.numbered"],
bs["terminal.title.numbered"],
da["terminal.title.numbered"],
de["terminal.title.numbered"],
es["terminal.title.numbered"],
fr["terminal.title.numbered"],
ja["terminal.title.numbered"],
ko["terminal.title.numbered"],
no["terminal.title.numbered"],
pl["terminal.title.numbered"],
ru["terminal.title.numbered"],
th["terminal.title.numbered"],
tr["terminal.title.numbered"],
zh["terminal.title.numbered"],
zht["terminal.title.numbered"],
]),
)
export function defaultTitle(number: number) {
return template.replace("{{number}}", String(number))
return en["terminal.title.numbered"].replace("{{number}}", String(number))
}
export function isDefaultTitle(title: string, number: number) {

View File

@@ -185,60 +185,6 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
})
onCleanup(unsub)
const update = (client: ReturnType<typeof useSDK>["client"], pty: Partial<LocalPTY> & { id: string }) => {
const index = store.all.findIndex((x) => x.id === pty.id)
const previous = index >= 0 ? store.all[index] : undefined
if (index >= 0) {
setStore("all", index, (item) => ({ ...item, ...pty }))
}
client.pty
.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
.catch((error: unknown) => {
if (previous) {
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
if (currentIndex >= 0) setStore("all", currentIndex, previous)
}
console.error("Failed to update terminal", error)
})
}
const clone = async (client: ReturnType<typeof useSDK>["client"], id: string) => {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
const next = await client.pty
.create({
title: pty.title,
})
.catch((error: unknown) => {
console.error("Failed to clone terminal", error)
return undefined
})
if (!next?.data) return
const active = store.active === pty.id
batch(() => {
setStore("all", index, {
id: next.data.id,
title: next.data.title ?? pty.title,
titleNumber: pty.titleNumber,
buffer: undefined,
cursor: undefined,
scrollY: undefined,
rows: undefined,
cols: undefined,
})
if (active) {
setStore("active", next.data.id)
}
})
}
return {
ready,
all: createMemo(() => store.all),
@@ -270,7 +216,24 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
})
},
update(pty: Partial<LocalPTY> & { id: string }) {
update(sdk.client, pty)
const index = store.all.findIndex((x) => x.id === pty.id)
const previous = index >= 0 ? store.all[index] : undefined
if (index >= 0) {
setStore("all", index, (item) => ({ ...item, ...pty }))
}
sdk.client.pty
.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
.catch((error: unknown) => {
if (previous) {
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
if (currentIndex >= 0) setStore("all", currentIndex, previous)
}
console.error("Failed to update terminal", error)
})
},
trim(id: string) {
const index = store.all.findIndex((x) => x.id === id)
@@ -285,23 +248,37 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
})
},
async clone(id: string) {
await clone(sdk.client, id)
},
bind() {
const client = sdk.client
return {
trim(id: string) {
const index = store.all.findIndex((x) => x.id === id)
if (index === -1) return
setStore("all", index, (pty) => trimTerminal(pty))
},
update(pty: Partial<LocalPTY> & { id: string }) {
update(client, pty)
},
async clone(id: string) {
await clone(client, id)
},
}
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
const clone = await sdk.client.pty
.create({
title: pty.title,
})
.catch((error: unknown) => {
console.error("Failed to clone terminal", error)
return undefined
})
if (!clone?.data) return
const active = store.active === pty.id
batch(() => {
setStore("all", index, {
id: clone.data.id,
title: clone.data.title ?? pty.title,
titleNumber: pty.titleNumber,
// New PTY process, so start clean.
buffer: undefined,
cursor: undefined,
scrollY: undefined,
rows: undefined,
cols: undefined,
})
if (active) {
setStore("active", clone.data.id)
}
})
},
open(id: string) {
setStore("active", id)
@@ -426,7 +403,6 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
trim: (id: string) => workspace().trim(id),
trimAll: () => workspace().trimAll(),
clone: (id: string) => workspace().clone(id),
bind: () => workspace(),
open: (id: string) => workspace().open(id),
close: (id: string) => workspace().close(id),
move: (id: string, to: number) => workspace().move(id, to),

View File

@@ -97,15 +97,10 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
throw new Error(getRootNotFoundError())
}
const localUrl = () =>
`http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
const isLocalHost = () => ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname)
const getCurrentUrl = () => {
if (location.hostname.includes("opencode.ai")) return localUrl()
if (import.meta.env.DEV) return localUrl()
if (isLocalHost()) return localUrl()
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
}

View File

@@ -22,7 +22,7 @@ export function useProviders() {
const providers = () => {
if (dir()) {
const [projectStore] = globalSync.child(dir())
if (projectStore.provider.all.length > 0) return projectStore.provider
return projectStore.provider
}
return globalSync.data.provider
}

View File

@@ -204,7 +204,6 @@ export const dict = {
"common.cancel": "إلغاء",
"common.connect": "اتصال",
"common.disconnect": "قطع الاتصال",
"common.continue": "إرسال",
"common.submit": "إرسال",
"common.save": "حفظ",
"common.saving": "جارٍ الحفظ...",

View File

@@ -204,7 +204,6 @@ export const dict = {
"common.cancel": "Cancelar",
"common.connect": "Conectar",
"common.disconnect": "Desconectar",
"common.continue": "Enviar",
"common.submit": "Enviar",
"common.save": "Salvar",
"common.saving": "Salvando...",

View File

@@ -221,7 +221,6 @@ export const dict = {
"common.cancel": "Otkaži",
"common.connect": "Poveži",
"common.disconnect": "Prekini vezu",
"common.continue": "Pošalji",
"common.submit": "Pošalji",
"common.save": "Sačuvaj",
"common.saving": "Čuvanje...",

View File

@@ -219,7 +219,6 @@ export const dict = {
"common.cancel": "Annuller",
"common.connect": "Forbind",
"common.disconnect": "Frakobl",
"common.continue": "Indsend",
"common.submit": "Indsend",
"common.save": "Gem",
"common.saving": "Gemmer...",

View File

@@ -209,7 +209,6 @@ export const dict = {
"common.cancel": "Abbrechen",
"common.connect": "Verbinden",
"common.disconnect": "Trennen",
"common.continue": "Absenden",
"common.submit": "Absenden",
"common.save": "Speichern",
"common.saving": "Speichert...",

View File

@@ -23,8 +23,6 @@ export const dict = {
"command.sidebar.toggle": "Toggle sidebar",
"command.project.open": "Open project",
"command.project.previous": "Previous project",
"command.project.next": "Next project",
"command.provider.connect": "Connect provider",
"command.server.switch": "Switch server",
"command.settings.open": "Open settings",
@@ -223,7 +221,6 @@ export const dict = {
"common.open": "Open",
"common.connect": "Connect",
"common.disconnect": "Disconnect",
"common.continue": "Continue",
"common.submit": "Submit",
"common.save": "Save",
"common.saving": "Saving...",
@@ -276,7 +273,7 @@ export const dict = {
"prompt.context.includeActiveFile": "Include active file",
"prompt.context.removeActiveFile": "Remove active file from context",
"prompt.context.removeFile": "Remove file from context",
"prompt.action.attachFile": "Add files",
"prompt.action.attachFile": "Add file",
"prompt.attachment.remove": "Remove attachment",
"prompt.action.send": "Send",
"prompt.action.stop": "Stop",
@@ -677,8 +674,6 @@ export const dict = {
"sidebar.project.recentSessions": "Recent sessions",
"sidebar.project.viewAllSessions": "View all sessions",
"sidebar.project.clearNotifications": "Clear notifications",
"sidebar.empty.title": "No projects open",
"sidebar.empty.description": "Open a project to get started",
"debugBar.ariaLabel": "Development performance diagnostics",
"debugBar.na": "n/a",

View File

@@ -220,7 +220,6 @@ export const dict = {
"common.cancel": "Cancelar",
"common.connect": "Conectar",
"common.disconnect": "Desconectar",
"common.continue": "Enviar",
"common.submit": "Enviar",
"common.save": "Guardar",
"common.saving": "Guardando...",

View File

@@ -204,7 +204,6 @@ export const dict = {
"common.cancel": "Annuler",
"common.connect": "Connecter",
"common.disconnect": "Déconnecter",
"common.continue": "Soumettre",
"common.submit": "Soumettre",
"common.save": "Enregistrer",
"common.saving": "Enregistrement...",

View File

@@ -203,7 +203,6 @@ export const dict = {
"common.cancel": "キャンセル",
"common.connect": "接続",
"common.disconnect": "切断",
"common.continue": "送信",
"common.submit": "送信",
"common.save": "保存",
"common.saving": "保存中...",

View File

@@ -207,7 +207,6 @@ export const dict = {
"common.cancel": "취소",
"common.connect": "연결",
"common.disconnect": "연결 해제",
"common.continue": "제출",
"common.submit": "제출",
"common.save": "저장",
"common.saving": "저장 중...",

View File

@@ -223,7 +223,6 @@ export const dict = {
"common.cancel": "Avbryt",
"common.connect": "Koble til",
"common.disconnect": "Koble fra",
"common.continue": "Send inn",
"common.submit": "Send inn",
"common.save": "Lagre",
"common.saving": "Lagrer...",

View File

@@ -205,7 +205,6 @@ export const dict = {
"common.cancel": "Anuluj",
"common.connect": "Połącz",
"common.disconnect": "Rozłącz",
"common.continue": "Prześlij",
"common.submit": "Prześlij",
"common.save": "Zapisz",
"common.saving": "Zapisywanie...",

View File

@@ -220,7 +220,6 @@ export const dict = {
"common.cancel": "Отмена",
"common.connect": "Подключить",
"common.disconnect": "Отключить",
"common.continue": "Отправить",
"common.submit": "Отправить",
"common.save": "Сохранить",
"common.saving": "Сохранение...",

View File

@@ -220,7 +220,6 @@ export const dict = {
"common.cancel": "ยกเลิก",
"common.connect": "เชื่อมต่อ",
"common.disconnect": "ยกเลิกการเชื่อมต่อ",
"common.continue": "ส่ง",
"common.submit": "ส่ง",
"common.save": "บันทึก",
"common.saving": "กำลังบันทึก...",

View File

@@ -225,7 +225,6 @@ export const dict = {
"common.cancel": "İptal",
"common.connect": "Bağlan",
"common.disconnect": "Bağlantı Kes",
"common.continue": "Gönder",
"common.submit": "Gönder",
"common.save": "Kaydet",
"common.saving": "Kaydediliyor...",

View File

@@ -242,7 +242,6 @@ export const dict = {
"common.cancel": "取消",
"common.connect": "连接",
"common.disconnect": "断开连接",
"common.continue": "提交",
"common.submit": "提交",
"common.save": "保存",
"common.saving": "保存中...",

View File

@@ -220,7 +220,6 @@ export const dict = {
"common.cancel": "取消",
"common.connect": "連線",
"common.disconnect": "中斷連線",
"common.continue": "提交",
"common.submit": "提交",
"common.save": "儲存",
"common.saving": "儲存中...",

View File

@@ -1,7 +1,5 @@
export { AppBaseProviders, AppInterface } from "./app"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click"

View File

@@ -1,27 +1,21 @@
import { DataProvider } from "@opencode-ai/ui/context"
import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode"
import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { createEffect, createMemo, type ParentProps, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync"
import { decode64 } from "@/utils/base64"
import { LocalProvider } from "@/context/local"
import { useGlobalSDK } from "@/context/global-sdk"
import { DataProvider } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const location = useLocation()
const navigate = useNavigate()
const sync = useSync()
const slug = createMemo(() => base64Encode(props.directory))
createEffect(() => {
const next = sync.data.path.directory
if (!next || next === props.directory) return
const path = location.pathname.slice(slug().length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
})
return (
<DataProvider
data={sync.data}
@@ -36,34 +30,57 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
export default function Layout(props: ParentProps) {
const params = useParams()
const language = useLanguage()
const navigate = useNavigate()
let invalid = ""
const resolved = createMemo(() => {
if (!params.dir) return ""
return decode64(params.dir) ?? ""
})
const location = useLocation()
const language = useLanguage()
const globalSDK = useGlobalSDK()
const directory = createMemo(() => decode64(params.dir) ?? "")
const [state, setState] = createStore({ invalid: "", resolved: "" })
createEffect(() => {
const dir = params.dir
if (!dir) return
if (resolved()) {
invalid = ""
if (!params.dir) return
const raw = directory()
if (!raw) {
if (state.invalid === params.dir) return
setState("invalid", params.dir)
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: language.t("directory.error.invalidUrl"),
})
navigate("/", { replace: true })
return
}
if (invalid === dir) return
invalid = dir
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: language.t("directory.error.invalidUrl"),
})
navigate("/", { replace: true })
const current = params.dir
globalSDK
.createClient({
directory: raw,
throwOnError: true,
})
.path.get()
.then((x) => {
if (params.dir !== current) return
const next = x.data?.directory ?? raw
batch(() => {
setState("invalid", "")
setState("resolved", next)
})
if (next === raw) return
const path = location.pathname.slice(current.length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
})
.catch(() => {
if (params.dir !== current) return
batch(() => {
setState("invalid", "")
setState("resolved", raw)
})
})
})
return (
<Show when={resolved()} keyed>
<Show when={state.resolved} keyed>
{(resolved) => (
<SDKProvider directory={() => resolved}>
<SyncProvider>

View File

@@ -1,12 +1,11 @@
import { TextField } from "@opencode-ai/ui/text-field"
import { Logo } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button"
import { Component, Show, onMount } from "solid-js"
import { Component, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { Icon } from "@opencode-ai/ui/icon"
import type { E2EWindow } from "@/testing/terminal"
export type InitError = {
name: string
@@ -227,13 +226,6 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
actionError: undefined as string | undefined,
})
onMount(() => {
const win = window as E2EWindow
if (!win.__opencode_e2e) return
const detail = formatError(props.error, language.t)
console.error(`[e2e:error-boundary] ${window.location.pathname}\n${detail}`)
})
async function checkForUpdates() {
if (!platform.checkUpdate) return
setStore("checking", true)

View File

@@ -113,14 +113,6 @@ export default function Home() {
</ul>
</div>
</Match>
<Match when={!sync.ready}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
<Button class="px-3" onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
</Match>
<Match when={true}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<Icon name="folder-add-left" size="large" />

View File

@@ -2,7 +2,6 @@ import {
batch,
createEffect,
createMemo,
createResource,
For,
on,
onCleanup,
@@ -49,16 +48,21 @@ import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { playSoundById } from "@/utils/sound"
import { playSound, soundSrc } from "@/utils/sound"
import { createAim } from "@/utils/aim"
import { setNavigate } from "@/utils/notification-click"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { setSessionHandoff } from "@/pages/session/handoff"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { DialogSelectProvider } from "@/components/dialog-select-provider"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { DialogSettings } from "@/components/dialog-settings"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogEditProject } from "@/components/dialog-edit-project"
import { DebugBar } from "@/components/debug-bar"
import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server"
@@ -105,8 +109,6 @@ export default function Layout(props: ParentProps) {
const pageReady = createMemo(() => ready())
let scrollContainerRef: HTMLDivElement | undefined
let dialogRun = 0
let dialogDead = false
const params = useParams()
const globalSDK = useGlobalSDK()
@@ -126,17 +128,7 @@ export default function Layout(props: ParentProps) {
const theme = useTheme()
const language = useLanguage()
const initialDirectory = decode64(params.dir)
const route = createMemo(() => {
const slug = params.dir
if (!slug) return { slug, dir: "" }
const dir = decode64(slug)
if (!dir) return { slug, dir: "" }
return {
slug,
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
}
})
const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const))
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
system: "theme.scheme.system",
@@ -144,7 +136,7 @@ export default function Layout(props: ParentProps) {
dark: "theme.scheme.dark",
}
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
const currentDir = createMemo(() => route().dir)
const currentDir = createMemo(() => decode64(params.dir) ?? "")
const [state, setState] = createStore({
autoselect: !initialDirectory,
@@ -198,8 +190,6 @@ export default function Layout(props: ParentProps) {
})
onCleanup(() => {
dialogDead = true
dialogRun += 1
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
clearTimeout(sortNowTimeout)
if (sortNowInterval) clearInterval(sortNowInterval)
@@ -210,22 +200,13 @@ export default function Layout(props: ParentProps) {
onMount(() => {
const stop = () => setState("sizing", false)
const blur = () => reset()
const hide = () => {
if (document.visibilityState !== "hidden") return
reset()
}
window.addEventListener("pointerup", stop)
window.addEventListener("pointercancel", stop)
window.addEventListener("blur", stop)
window.addEventListener("blur", blur)
document.addEventListener("visibilitychange", hide)
onCleanup(() => {
window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop)
window.removeEventListener("blur", stop)
window.removeEventListener("blur", blur)
document.removeEventListener("visibilitychange", hide)
})
})
@@ -245,12 +226,6 @@ export default function Layout(props: ParentProps) {
navLeave.current = undefined
}
const reset = () => {
disarm()
setState("hoverSession", undefined)
setHoverProject(undefined)
}
const arm = () => {
if (layout.sidebar.opened()) return
if (state.hoverProject === undefined) return
@@ -302,6 +277,16 @@ export default function Layout(props: ParentProps) {
setHoverProject(undefined)
})
const autoselecting = createMemo(() => {
if (params.dir) return false
if (!state.autoselect) return false
if (!pageReady()) return true
if (!layoutReady()) return true
const list = layout.projects.list()
if (list.length > 0) return true
return !!server.projects.last()
})
createEffect(() => {
if (!state.autoselect) return
const dir = params.dir
@@ -319,7 +304,8 @@ export default function Layout(props: ParentProps) {
const clearSidebarHoverState = () => {
if (layout.sidebar.opened()) return
reset()
setState("hoverSession", undefined)
setHoverProject(undefined)
}
const navigateWithSidebarReset = (href: string) => {
@@ -335,9 +321,10 @@ export default function Layout(props: ParentProps) {
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
const nextThemeId = ids[nextIndex]
theme.setTheme(nextThemeId)
const nextTheme = theme.themes()[nextThemeId]
showToast({
title: language.t("toast.theme.title"),
description: theme.name(nextThemeId),
description: nextTheme?.name ?? nextThemeId,
})
}
@@ -492,7 +479,7 @@ export default function Layout(props: ParentProps) {
if (e.details.type === "permission.asked") {
if (settings.sounds.permissionsEnabled()) {
void playSoundById(settings.sounds.permissions())
playSound(soundSrc(settings.sounds.permissions()))
}
if (settings.notifications.permissions()) {
void platform.notify(title, description, href)
@@ -506,8 +493,8 @@ export default function Layout(props: ParentProps) {
}
const currentSession = params.id
if (workspaceKey(directory) === workspaceKey(currentDir()) && props.sessionID === currentSession) return
if (workspaceKey(directory) === workspaceKey(currentDir()) && session?.parentID === currentSession) return
if (directory === currentDir() && props.sessionID === currentSession) return
if (directory === currentDir() && session?.parentID === currentSession) return
dismissSessionAlert(sessionKey)
@@ -565,14 +552,13 @@ export default function Layout(props: ParentProps) {
const currentProject = createMemo(() => {
const directory = currentDir()
if (!directory) return
const key = workspaceKey(directory)
const projects = layout.projects.list()
const sandbox = projects.find((p) => p.sandboxes?.some((item) => workspaceKey(item) === key))
const sandbox = projects.find((p) => p.sandboxes?.includes(directory))
if (sandbox) return sandbox
const direct = projects.find((p) => workspaceKey(p.worktree) === key)
const direct = projects.find((p) => p.worktree === directory)
if (direct) return direct
const [child] = globalSync.child(directory, { bootstrap: false })
@@ -586,23 +572,33 @@ export default function Layout(props: ParentProps) {
return projects.find((p) => p.worktree === root)
})
const [autoselecting] = createResource(async () => {
await ready.promise
await layout.ready.promise
if (!untrack(() => state.autoselect)) return
createEffect(
on(
() => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }),
(value) => {
if (!value.ready) return
if (!value.layoutReady) return
if (!state.autoselect) return
if (value.dir) return
const list = layout.projects.list()
const last = server.projects.last()
const last = server.projects.last()
if (list.length === 0) {
if (!last) return
await openProject(last, true)
} else {
const next = list.find((project) => project.worktree === last) ?? list[0]
if (!next) return
await openProject(next.worktree, true)
}
})
if (value.list.length === 0) {
if (!last) return
setState("autoselect", false)
openProject(last, false)
navigateToProject(last)
return
}
const next = value.list.find((project) => project.worktree === last) ?? value.list[0]
if (!next) return
setState("autoselect", false)
openProject(next.worktree, false)
navigateToProject(next.worktree)
},
),
)
const workspaceName = (directory: string, projectId?: string, branch?: string) => {
const key = workspaceKey(directory)
@@ -642,7 +638,7 @@ export default function Layout(props: ParentProps) {
const activeDir = currentDir()
return workspaceIds(project).filter((directory) => {
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
const active = workspaceKey(directory) === workspaceKey(activeDir)
const active = directory === activeDir
return expanded || active
})
})
@@ -653,11 +649,7 @@ export default function Layout(props: ParentProps) {
const projects = layout.projects.list()
for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) {
if (!expanded) continue
const key = workspaceKey(directory)
const project = projects.find(
(item) =>
workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key),
)
const project = projects.find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
if (!project) continue
if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
setStore("workspaceExpanded", directory, false)
@@ -709,7 +701,7 @@ export default function Layout(props: ParentProps) {
seen: lru,
keep: sessionID,
limit: PREFETCH_MAX_SESSIONS_PER_DIR,
preserve: params.id && workspaceKey(directory) === workspaceKey(currentDir()) ? [params.id] : undefined,
preserve: directory === params.dir && params.id ? [params.id] : undefined,
})
}
@@ -722,7 +714,7 @@ export default function Layout(props: ParentProps) {
})
createEffect(() => {
route()
params.dir
globalSDK.url
prefetchToken.value += 1
@@ -948,26 +940,6 @@ export default function Layout(props: ParentProps) {
navigateToSession(session)
}
function navigateProjectByOffset(offset: number) {
const projects = layout.projects.list()
if (projects.length === 0) return
const current = currentProject()?.worktree
const fallback = currentDir() ? projectRoot(currentDir()) : undefined
const active = current ?? fallback
const index = active ? projects.findIndex((project) => project.worktree === active) : -1
const target =
index === -1
? offset > 0
? projects[0]
: projects[projects.length - 1]
: projects[(index + offset + projects.length) % projects.length]
if (!target) return
openProject(target.worktree)
}
function navigateSessionByUnseen(offset: number) {
const sessions = currentSessions()
if (sessions.length === 0) return
@@ -1034,20 +1006,6 @@ export default function Layout(props: ParentProps) {
keybind: "mod+o",
onSelect: () => chooseProject(),
},
{
id: "project.previous",
title: language.t("command.project.previous"),
category: language.t("command.category.project"),
keybind: "mod+alt+arrowup",
onSelect: () => navigateProjectByOffset(-1),
},
{
id: "project.next",
title: language.t("command.project.next"),
category: language.t("command.category.project"),
keybind: "mod+alt+arrowdown",
onSelect: () => navigateProjectByOffset(1),
},
{
id: "provider.connect",
title: language.t("command.provider.connect"),
@@ -1150,10 +1108,10 @@ export default function Layout(props: ParentProps) {
},
]
for (const [id] of availableThemeEntries()) {
for (const [id, definition] of availableThemeEntries()) {
commands.push({
id: `theme.set.${id}`,
title: language.t("command.theme.set", { theme: theme.name(id) }),
title: language.t("command.theme.set", { theme: definition.name ?? id }),
category: language.t("command.category.theme"),
onSelect: () => theme.commitPreview(),
onHighlight: () => {
@@ -1204,41 +1162,25 @@ export default function Layout(props: ParentProps) {
})
function connectProvider() {
const run = ++dialogRun
void import("@/components/dialog-select-provider").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectProvider />)
})
dialog.show(() => <DialogSelectProvider />)
}
function openServer() {
const run = ++dialogRun
void import("@/components/dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />)
})
dialog.show(() => <DialogSelectServer />)
}
function openSettings() {
const run = ++dialogRun
void import("@/components/dialog-settings").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSettings />)
})
dialog.show(() => <DialogSettings />)
}
function projectRoot(directory: string) {
const key = workspaceKey(directory)
const project = layout.projects
.list()
.find(
(item) =>
workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key),
)
.find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
if (project) return project.worktree
const known = Object.entries(store.workspaceOrder).find(
([root, dirs]) => workspaceKey(root) === key || dirs.some((item) => workspaceKey(item) === key),
([root, dirs]) => root === directory || dirs.includes(directory),
)
if (known) return known[0]
@@ -1254,6 +1196,13 @@ export default function Layout(props: ParentProps) {
return currentProject()?.worktree ?? projectRoot(directory)
}
function touchProjectRoute() {
const root = currentProject()?.worktree
if (!root) return
if (server.projects.last() !== root) server.projects.touch(root)
return root
}
function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
setStore("lastProjectSession", root, { directory, id, at: Date.now() })
return root
@@ -1362,7 +1311,7 @@ export default function Layout(props: ParentProps) {
function openProject(directory: string, navigate = true) {
layout.projects.open(directory)
if (navigate) return navigateToProject(directory)
if (navigate) navigateToProject(directory)
}
const handleDeepLinks = (urls: string[]) => {
@@ -1417,9 +1366,8 @@ export default function Layout(props: ParentProps) {
function closeProject(directory: string) {
const list = layout.projects.list()
const key = workspaceKey(directory)
const index = list.findIndex((x) => workspaceKey(x.worktree) === key)
const active = workspaceKey(currentProject()?.worktree ?? "") === key
const index = list.findIndex((x) => x.worktree === directory)
const active = currentProject()?.worktree === directory
if (index === -1) return
const next = list[index + 1]
@@ -1451,13 +1399,7 @@ export default function Layout(props: ParentProps) {
layout.sidebar.toggleWorkspaces(project.worktree)
}
const showEditProjectDialog = (project: LocalProject) => {
const run = ++dialogRun
void import("@/components/dialog-edit-project").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogEditProject project={project} />)
})
}
const showEditProjectDialog = (project: LocalProject) => dialog.show(() => <DialogEditProject project={project} />)
async function chooseProject() {
function resolve(result: string | string[] | null) {
@@ -1478,14 +1420,10 @@ export default function Layout(props: ParentProps) {
})
resolve(result)
} else {
const run = ++dialogRun
void import("@/components/dialog-select-directory").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(
() => <x.DialogSelectDirectory multiple={true} onSelect={resolve} />,
() => resolve(null),
)
})
dialog.show(
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
() => resolve(null),
)
}
}
@@ -1764,51 +1702,38 @@ export default function Layout(props: ParentProps) {
const activeRoute = {
session: "",
sessionProject: "",
directory: "",
}
createEffect(
on(
() => {
return [pageReady(), route().slug, params.id, currentProject()?.worktree, currentDir()] as const
},
([ready, slug, id, root, dir]) => {
if (!ready || !slug || !dir) {
() => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const,
([ready, dir, id]) => {
if (!ready || !dir) {
activeRoute.session = ""
activeRoute.sessionProject = ""
activeRoute.directory = ""
return
}
const directory = decode64(dir)
if (!directory) return
const root = touchProjectRoute() ?? activeProjectRoot(directory)
if (!id) {
activeRoute.session = ""
activeRoute.sessionProject = ""
activeRoute.directory = ""
return
}
const session = `${slug}/${id}`
if (!root) {
const session = `${dir}/${id}`
if (session !== activeRoute.session) {
activeRoute.session = session
activeRoute.directory = dir
activeRoute.sessionProject = ""
return
}
if (server.projects.last() !== root) server.projects.touch(root)
const changed = session !== activeRoute.session || dir !== activeRoute.directory
if (changed) {
activeRoute.session = session
activeRoute.directory = dir
activeRoute.sessionProject = syncSessionRoute(dir, id, root)
activeRoute.sessionProject = syncSessionRoute(directory, id, root)
return
}
if (root === activeRoute.sessionProject) return
activeRoute.directory = dir
activeRoute.sessionProject = rememberSessionRoute(dir, id, root)
activeRoute.sessionProject = rememberSessionRoute(directory, id, root)
},
),
)
@@ -1818,9 +1743,6 @@ export default function Layout(props: ParentProps) {
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
})
const side = createMemo(() => Math.max(layout.sidebar.width(), 244))
const panel = createMemo(() => Math.max(side() - 64, 0))
const loadedSessionDirs = new Set<string>()
createEffect(
@@ -1875,13 +1797,8 @@ export default function Layout(props: ParentProps) {
const local = project.worktree
const dirs = [local, ...(project.sandboxes ?? [])]
const active = currentProject()
const directory = workspaceKey(active?.worktree ?? "") === workspaceKey(project.worktree) ? currentDir() : undefined
const extra =
directory &&
workspaceKey(directory) !== workspaceKey(local) &&
!dirs.some((item) => workspaceKey(item) === workspaceKey(directory))
? directory
: undefined
const directory = active?.worktree === project.worktree ? currentDir() : undefined
const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
@@ -2004,7 +1921,6 @@ export default function Layout(props: ParentProps) {
const projectSidebarCtx: ProjectSidebarContext = {
currentDir,
currentProject,
sidebarOpened: () => layout.sidebar.opened(),
sidebarHovering,
hoverProject: () => state.hoverProject,
@@ -2012,10 +1928,6 @@ export default function Layout(props: ParentProps) {
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
onProjectMouseLeave: (worktree) => aim.leave(worktree),
onProjectFocus: (worktree) => aim.activate(worktree),
onHoverOpenChanged: (worktree, hoverOpen) => {
if (!hoverOpen && state.hoverProject && state.hoverProject !== worktree) return
setState("hoverProject", hoverOpen ? worktree : undefined)
},
navigateToProject,
openSidebar: () => layout.sidebar.open(),
closeProject,
@@ -2047,7 +1959,6 @@ export default function Layout(props: ParentProps) {
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened())
const empty = createMemo(() => !params.dir && layout.projects.list().length === 0)
const projectName = createMemo(() => {
const item = project()
if (!item) return ""
@@ -2097,29 +2008,10 @@ export default function Layout(props: ParentProps) {
"max-w-full overflow-hidden": panelProps.mobile,
}}
style={{
width: panelProps.mobile ? undefined : `${panel()}px`,
width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
}}
>
<Show
when={project()}
fallback={
<Show when={empty()}>
<div class="flex-1 min-h-0 -mt-4 flex items-center justify-center px-6 pb-64 text-center">
<div class="mt-8 flex max-w-60 flex-col items-center gap-6 text-center">
<div class="flex flex-col gap-3">
<div class="text-14-medium text-text-strong">{language.t("sidebar.empty.title")}</div>
<div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
{language.t("sidebar.empty.description")}
</div>
</div>
<Button size="large" icon="folder-add-left" onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
</div>
</Show>
}
>
<Show when={project()}>
<>
<div class="shrink-0 pl-1 py-1">
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
@@ -2160,11 +2052,9 @@ export default function Layout(props: ParentProps) {
variant="ghost"
data-action="project-menu"
data-project={slug()}
class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"opacity-100": panelProps.mobile || merged(),
"opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
!panelProps.mobile && !merged(),
"opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
}}
aria-label={language.t("common.moreOptions")}
/>
@@ -2370,7 +2260,13 @@ export default function Layout(props: ParentProps) {
helpLabel={() => language.t("sidebar.help")}
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
renderPanel={() =>
mobile ? <SidebarPanel project={currentProject} mobile /> : <SidebarPanel project={currentProject} merged />
mobile ? (
<SidebarPanel project={currentProject} mobile />
) : (
<Show when={currentProject()}>
<SidebarPanel project={currentProject} merged />
</Show>
)
}
/>
)
@@ -2389,7 +2285,7 @@ export default function Layout(props: ParentProps) {
"absolute inset-y-0 left-0": true,
"z-10": true,
}}
style={{ width: `${side()}px` }}
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
ref={(el) => {
setState("nav", el)
}}
@@ -2404,29 +2300,26 @@ export default function Layout(props: ParentProps) {
}}
>
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
<Show when={layout.sidebar.opened()}>
<div onPointerDown={() => setState("sizing", true)}>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
collapseThreshold={244}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
onCollapse={layout.sidebar.close}
/>
</div>
</Show>
</nav>
<Show when={layout.sidebar.opened()}>
<div
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: `${side()}px` }}
onPointerDown={() => setState("sizing", true)}
>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
</div>
</Show>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
@@ -2466,7 +2359,7 @@ export default function Layout(props: ParentProps) {
!state.sizing,
}}
style={{
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
"--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
}}
>
<main
@@ -2474,7 +2367,7 @@ export default function Layout(props: ParentProps) {
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
@@ -2513,7 +2406,7 @@ export default function Layout(props: ParentProps) {
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${panel()}px)` }}
style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
</div>

View File

@@ -104,14 +104,14 @@ describe("layout deep links", () => {
describe("layout workspace helpers", () => {
test("normalizes trailing slash in workspace key", () => {
expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo")
expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:/tmp/demo")
expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:\\tmp\\demo")
})
test("preserves posix and drive roots in workspace key", () => {
expect(workspaceKey("/")).toBe("/")
expect(workspaceKey("///")).toBe("/")
expect(workspaceKey("C:\\")).toBe("C:/")
expect(workspaceKey("C://")).toBe("C:/")
expect(workspaceKey("C:\\")).toBe("C:\\")
expect(workspaceKey("C:\\\\\\")).toBe("C:\\")
expect(workspaceKey("C:///")).toBe("C:/")
})

View File

@@ -1,17 +1,11 @@
import { getFilename } from "@opencode-ai/util/path"
import { type Session } from "@opencode-ai/sdk/v2/client"
type SessionStore = {
session?: Session[]
path: { directory: string }
}
export const workspaceKey = (directory: string) => {
const value = directory.replaceAll("\\", "/")
const drive = value.match(/^([A-Za-z]:)\/+$/)
if (drive) return `${drive[1]}/`
if (/^\/+$/i.test(value)) return "/"
return value.replace(/\/+$/, "")
const drive = directory.match(/^([A-Za-z]:)[\\/]+$/)
if (drive) return `${drive[1]}${directory.includes("\\") ? "\\" : "/"}`
if (/^[\\/]+$/.test(directory)) return directory.includes("\\") ? "\\" : "/"
return directory.replace(/[\\/]+$/, "")
}
function sortSessions(now: number) {
@@ -31,24 +25,24 @@ function sortSessions(now: number) {
const isRootVisibleSession = (session: Session, directory: string) =>
workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
const roots = (store: SessionStore) =>
(store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory))
export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) =>
store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now))
export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now))
export const latestRootSession = (stores: SessionStore[], now: number) =>
stores.flatMap(roots).sort(sortSessions(now))[0]
export const latestRootSession = (stores: { session: Session[]; path: { directory: string } }[], now: number) =>
stores
.flatMap((store) => store.session.filter((session) => isRootVisibleSession(session, store.path.directory)))
.sort(sortSessions(now))[0]
export function hasProjectPermissions<T>(
request: Record<string, T[] | undefined> | undefined,
request: Record<string, T[] | undefined>,
include: (item: T) => boolean = () => true,
) {
return Object.values(request ?? {}).some((list) => list?.some(include))
return Object.values(request).some((list) => list?.some(include))
}
export const childMapByParent = (sessions: Session[] | undefined) => {
export const childMapByParent = (sessions: Session[]) => {
const map = new Map<string, string[]>()
for (const session of sessions ?? []) {
for (const session of sessions) {
if (!session.parentID) continue
const existing = map.get(session.parentID)
if (existing) {

View File

@@ -104,7 +104,7 @@ const SessionRow = (props: {
}): JSX.Element => (
<A
href={`/${props.slug}/session/${props.session.id}`}
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onPointerDown={props.warmPress}
onPointerEnter={props.warmHover}
onPointerLeave={props.cancelHoverPrefetch}
@@ -115,26 +115,30 @@ const SessionRow = (props: {
props.clearHoverProjectSoon()
}}
>
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={props.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={props.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={props.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={props.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
<div class="flex items-center gap-1 w-full">
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={props.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={props.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={props.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={props.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{props.session.title}
</span>
</div>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{props.session.title}</span>
</A>
)
@@ -153,49 +157,34 @@ const SessionHoverPreview = (props: {
messageLabel: (message: Message) => string | undefined
onMessageSelect: (message: Message) => void
trigger: JSX.Element
}): JSX.Element => {
let ref: HTMLDivElement | undefined
return (
<HoverCard
openDelay={1000}
closeDelay={props.sidebarHovering() ? 600 : 0}
placement="right-start"
gutter={16}
shift={-2}
trigger={
<div ref={ref} class="min-w-0 w-full">
{props.trigger}
</div>
}
open={props.hoverSession() === props.session.id}
onOpenChange={(open) => {
if (!open) {
props.setHoverSession(undefined)
return
}
if (!ref?.matches(":hover")) return
props.setHoverSession(props.session.id)
}}
}): JSX.Element => (
<HoverCard
openDelay={1000}
closeDelay={props.sidebarHovering() ? 600 : 0}
placement="right-start"
gutter={16}
shift={-2}
trigger={props.trigger}
open={props.hoverSession() === props.session.id}
onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
>
<Show
when={props.hoverReady()}
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
>
<Show
when={props.hoverReady()}
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
>
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
<MessageNav
messages={props.hoverMessages() ?? []}
current={undefined}
getLabel={props.messageLabel}
onMessageSelect={props.onMessageSelect}
size="normal"
class="w-60"
/>
</div>
</Show>
</HoverCard>
)
}
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
<MessageNav
messages={props.hoverMessages() ?? []}
current={undefined}
getLabel={props.messageLabel}
onMessageSelect={props.onMessageSelect}
size="normal"
class="w-60"
/>
</div>
</Show>
</HoverCard>
)
export const SessionItem = (props: SessionItemProps): JSX.Element => {
const params = useParams()
@@ -309,71 +298,62 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
return (
<div
data-session-id={props.session.id}
class="group/session relative w-full min-w-0 rounded-md cursor-default pl-2 pr-3 transition-colors
class="group/session relative w-full rounded-md cursor-default pl-2 pr-3 transition-colors
hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
>
<div class="flex min-w-0 items-center gap-1">
<div class="min-w-0 flex-1">
<Show
when={hoverEnabled()}
fallback={
<Tooltip
placement={props.mobile ? "bottom" : "right"}
value={props.session.title}
gutter={10}
class="min-w-0 w-full"
>
{item}
</Tooltip>
}
>
<SessionHoverPreview
mobile={props.mobile}
nav={props.nav}
hoverSession={props.hoverSession}
session={props.session}
sidebarHovering={props.sidebarHovering}
hoverReady={hoverReady}
hoverMessages={hoverMessages}
language={language}
isActive={isActive}
slug={props.slug}
setHoverSession={props.setHoverSession}
messageLabel={messageLabel}
onMessageSelect={(message) => {
if (!isActive())
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
}}
trigger={item}
/>
</Show>
</div>
<div
class="shrink-0 overflow-hidden transition-[width,opacity]"
classList={{
"w-6 opacity-100 pointer-events-auto": !!props.mobile,
"w-0 opacity-0 pointer-events-none": !props.mobile,
"group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
"group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
}}
>
<Tooltip value={language.t("common.archive")} placement="top">
<IconButton
icon="archive"
variant="ghost"
class="size-6 rounded-md"
aria-label={language.t("common.archive")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void props.archiveSession(props.session)
}}
/>
<Show
when={hoverEnabled()}
fallback={
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
{item}
</Tooltip>
</div>
}
>
<SessionHoverPreview
mobile={props.mobile}
nav={props.nav}
hoverSession={props.hoverSession}
session={props.session}
sidebarHovering={props.sidebarHovering}
hoverReady={hoverReady}
hoverMessages={hoverMessages}
language={language}
isActive={isActive}
slug={props.slug}
setHoverSession={props.setHoverSession}
messageLabel={messageLabel}
onMessageSelect={(message) => {
if (!isActive())
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
}}
trigger={item}
/>
</Show>
<div
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
classList={{
"opacity-100 pointer-events-auto": !!props.mobile,
"opacity-0 pointer-events-none": !props.mobile,
"group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
"group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
}}
>
<Tooltip value={language.t("common.archive")} placement="top">
<IconButton
icon="archive"
variant="ghost"
class="size-6 rounded-md"
aria-label={language.t("common.archive")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void props.archiveSession(props.session)
}}
/>
</Tooltip>
</div>
</div>
)
@@ -395,26 +375,30 @@ export const NewSessionItem = (props: {
<A
href={`/${props.slug}/session`}
end
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onClick={() => {
props.setHoverSession(undefined)
if (layout.sidebar.opened()) return
props.clearHoverProjectSoon()
}}
>
<div class="shrink-0 size-6 flex items-center justify-center">
<Icon name="new-session" size="small" class="text-icon-weak" />
<div class="flex items-center gap-1 w-full">
<div class="shrink-0 size-6 flex items-center justify-center">
<Icon name="new-session" size="small" class="text-icon-weak" />
</div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{label}
</span>
</div>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{label}</span>
</A>
)
return (
<div class="group/session relative w-full min-w-0 rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
<div class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
<Show
when={!tooltip()}
fallback={
<Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10} class="min-w-0 w-full">
<Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10}>
{item}
</Tooltip>
}

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