diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index ddfa7fd161..dac757ae91 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -10,6 +10,9 @@ adamdotdevin -agusbasari29 AI PR slop ariane-emory +-atharvau AI review spamming literally every PR +-danieljoshuanazareth +-danieljoshuanazareth edemaine -florianleibert fwang @@ -17,8 +20,9 @@ iamdavidhill jayair kitlangton kommander +-opencode2026 r44vc0rp rekram1-node -spider-yamet clawdbot/llm psychosis, spam pinging the team thdxr --OpenCode2026 +-OpenCodeEngineer bot that spams issues diff --git a/.github/workflows/close-issues.yml b/.github/workflows/close-issues.yml new file mode 100644 index 0000000000..04b6ae7ac8 --- /dev/null +++ b/.github/workflows/close-issues.yml @@ -0,0 +1,24 @@ +name: close-issues + +on: + schedule: + - cron: "0 2 * * *" # Daily at 2:00 AM + workflow_dispatch: + +jobs: + close: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Close stale issues + env: + GITHUB_TOKEN: ${{ github.token }} + run: bun script/github/close-issues.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c08d7edf3b..96f437a73f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -11,7 +11,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }} jobs: deploy: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml index 2529c14c20..9d94682f11 100644 --- a/.github/workflows/nix-hashes.yml +++ b/.github/workflows/nix-hashes.yml @@ -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="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)" + HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)" if [ -z "$HASH" ]; then echo "::error::Failed to compute hash for ${SYSTEM}" diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml deleted file mode 100644 index a4b8583f92..0000000000 --- a/.github/workflows/stale-issues.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: stale-issues - -on: - schedule: - - cron: "30 1 * * *" # Daily at 1:30 AM - workflow_dispatch: - -env: - DAYS_BEFORE_STALE: 90 - DAYS_BEFORE_CLOSE: 7 - -jobs: - stale: - runs-on: ubuntu-latest - permissions: - issues: write - steps: - - uses: actions/stale@v10 - with: - days-before-stale: ${{ env.DAYS_BEFORE_STALE }} - days-before-close: ${{ env.DAYS_BEFORE_CLOSE }} - stale-issue-label: "stale" - close-issue-message: | - [automated] Closing due to ${{ env.DAYS_BEFORE_STALE }}+ days of inactivity. - - Feel free to reopen if you still need this! - stale-issue-message: | - [automated] This issue has had no activity for ${{ env.DAYS_BEFORE_STALE }} days. - - It will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days if there's no new activity. - remove-stale-when-updated: true - exempt-issue-labels: "pinned,security,feature-request,on-hold" - start-date: "2025-12-27" diff --git a/.github/workflows/vouch-manage-by-issue.yml b/.github/workflows/vouch-manage-by-issue.yml index 9604bf87f3..79687639df 100644 --- a/.github/workflows/vouch-manage-by-issue.yml +++ b/.github/workflows/vouch-manage-by-issue.yml @@ -33,6 +33,6 @@ jobs: with: issue-id: ${{ github.event.issue.number }} comment-id: ${{ github.event.comment.id }} - roles: admin,maintain + roles: admin,maintain,write env: GITHUB_TOKEN: ${{ steps.committer.outputs.token }} diff --git a/.opencode/command/changelog.md b/.opencode/command/changelog.md new file mode 100644 index 0000000000..85dbf9b97b --- /dev/null +++ b/.opencode/command/changelog.md @@ -0,0 +1,21 @@ +--- +model: opencode/kimi-k2.5 +--- + +create UPCOMING_CHANGELOG.md + +it should have sections + +``` +# TUI + +# Desktop + +# Core + +# Misc +``` + +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 into the appropriate section. diff --git a/README.zh.md b/README.zh.md index 0859ed11d0..46d9f761cb 100644 --- a/README.zh.md +++ b/README.zh.md @@ -137,4 +137,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换: --- -**加入我们的社区** [飞书](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) +**加入我们的社区** [飞书](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) diff --git a/README.zht.md b/README.zht.md index b7d8b8fc47..7ef51d8fdd 100644 --- a/README.zht.md +++ b/README.zht.md @@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。 --- -**加入我們的社群** [飞书](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) +**加入我們的社群** [飞书](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) diff --git a/bun.lock b/bun.lock index 6c78ce260d..c852477cb9 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.2.27", + "version": "1.3.2", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -41,9 +41,11 @@ "@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:", @@ -77,7 +79,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.2.27", + "version": "1.3.2", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -111,7 +113,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.2.27", + "version": "1.3.2", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -128,7 +130,7 @@ "devDependencies": { "@cloudflare/workers-types": "catalog:", "@tsconfig/node22": "22.0.2", - "@types/bun": "1.3.0", + "@types/bun": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "drizzle-kit": "catalog:", @@ -138,7 +140,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.2.27", + "version": "1.3.2", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -162,7 +164,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.2.27", + "version": "1.3.2", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -186,7 +188,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.2.27", + "version": "1.3.2", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -219,7 +221,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.2.27", + "version": "1.3.2", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -250,7 +252,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.2.27", + "version": "1.3.2", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -279,7 +281,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.2.27", + "version": "1.3.2", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -295,7 +297,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.2.27", + "version": "1.3.2", "bin": { "opencode": "./bin/opencode", }, @@ -325,11 +327,9 @@ "@aws-sdk/credential-providers": "3.993.0", "@clack/prompts": "1.0.0-alpha.1", "@effect/platform-node": "catalog:", - "@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", + "@modelcontextprotocol/sdk": "1.27.1", "@npmcli/arborist": "9.4.0", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", @@ -339,8 +339,8 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.4", - "@opentui/core": "0.1.87", - "@opentui/solid": "0.1.87", + "@opentui/core": "0.1.90", + "@opentui/solid": "0.1.90", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -359,6 +359,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", + "gitlab-ai-provider": "5.3.3", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -369,6 +370,8 @@ "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:", @@ -421,7 +424,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.2.27", + "version": "1.3.2", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -445,7 +448,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.2.27", + "version": "1.3.2", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -456,7 +459,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.2.27", + "version": "1.3.2", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -491,7 +494,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.2.27", + "version": "1.3.2", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -537,7 +540,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.2.27", + "version": "1.3.2", "dependencies": { "zod": "catalog:", }, @@ -548,7 +551,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.2.27", + "version": "1.3.2", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -612,7 +615,7 @@ "@tailwindcss/vite": "4.1.11", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", - "@types/bun": "1.3.9", + "@types/bun": "1.3.11", "@types/luxon": "3.7.1", "@types/node": "22.13.9", "@types/semver": "7.7.1", @@ -1114,10 +1117,6 @@ "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], - "@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=="], @@ -1332,7 +1331,7 @@ "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], "@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="], @@ -1482,21 +1481,21 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - "@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": ["@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-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.87", "", { "os": "darwin", "cpu": "arm64" }, "sha512-G8oq85diOfkU6n0T1CxCle7oDmpKxwhcdhZ9khBMU5IrfLx9ZDuCM3F6MsiRQWdvPPCq2oomNbd64bYkPamYgw=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.90", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XFrm2zCg1SlHPQ5A2HX/I4dCrmTjYaCJIIpo3QuPIvZBGH3aBMdWDJh2tXw7AB5Mmh8X1K4hDkP5nlK9x0Ewow=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.87", "", { "os": "darwin", "cpu": "x64" }, "sha512-MYTFQfOHm6qO7YaY4GHK9u/oJlXY6djaaxl5I+k4p2mk3vvuFIl/AP1ypITwBFjyV5gyp7PRWFp4nGfY9oN8bw=="], + "@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-linux-arm64": ["@opentui/core-linux-arm64@0.1.87", "", { "os": "linux", "cpu": "arm64" }, "sha512-he8o1h5M6oskRJ7wE+xKJgmWnv5ZwN6gB3M/Z+SeHtOMPa5cZmi3TefTjG54llEgFfx0F9RcqHof7TJ/GNxRkw=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.90", "", { "os": "linux", "cpu": "arm64" }, "sha512-OTbvBTP5mVQ4uwKyuz6b59ElG+D0i1Ln+q6cVhNkLgeRLySIn1uXEzUFQGlnVgb8lFDANsn3yQmdv+R+Cpw0og=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.87", "", { "os": "linux", "cpu": "x64" }, "sha512-aiUwjPlH4yDcB8/6YDKSmMkaoGAAltL0Xo0AzXyAtJXWK5tkCSaYjEVwzJ/rYRkr4Magnad+Mjth4AQUWdR2AA=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.90", "", { "os": "linux", "cpu": "x64" }, "sha512-2PJi/LLlO7tGk9Ful/n+6iBdg1RFrA9ibU7wVneE6Z1P0LCYeu7bpwMzea1TXL0eAQWPHsjTs9aPlqPxln0EJw=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.87", "", { "os": "win32", "cpu": "arm64" }, "sha512-cmP0pOyREjWGniHqbDmaMY7U+1AyagrD8VseJbU0cGpNgVpG2/gbrJUGdfdLB0SNb+mzLdx6SOjdxtrElwRCQA=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.90", "", { "os": "win32", "cpu": "arm64" }, "sha512-+sTRaOb7gCMZ6iLuuG4y9kzyweJzBDcIJN0Xh49ikFWTwVECDXEVtXahNGlw57avm2yYUoNzmpBjK/LV7zBj9A=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.87", "", { "os": "win32", "cpu": "x64" }, "sha512-N2GErAAP8iODf2RPp86pilPaVKiD6G4pkpZL5nLGbKsl0bndrVTpSqZcn8+/nQwFZDPD/AsiRTYNOfWOblhzOw=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.90", "", { "os": "win32", "cpu": "x64" }, "sha512-aVFyErckWp4oW9NJ/ZDKBUAlTlfVUiRXGP63JXFOoeqI7EYaM8uBt6rgZAJuUdFWCN2Q66WRS8Y2mk+0BJwVBg=="], - "@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=="], + "@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=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -1936,6 +1935,8 @@ "@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=="], @@ -2014,10 +2015,14 @@ "@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=="], @@ -2104,7 +2109,7 @@ "@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="], - "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], "@types/cacache": ["@types/cacache@20.0.1", "", { "dependencies": { "@types/node": "*", "minipass": "*" } }, "sha512-QlKW3AFoFr/hvPHwFHMIVUH/ZCYeetBNou3PCmxu5LaNDvrtBlPJtIA6uhmU9JRt9oxj7IYoqoLcpxtzpPiTcw=="], @@ -2518,7 +2523,7 @@ "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], - "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], "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=="], @@ -2952,7 +2957,7 @@ "express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="], - "express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], + "express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="], "expressive-code": ["expressive-code@0.41.7", "", { "dependencies": { "@expressive-code/core": "^0.41.7", "@expressive-code/plugin-frames": "^0.41.7", "@expressive-code/plugin-shiki": "^0.41.7", "@expressive-code/plugin-text-markers": "^0.41.7" } }, "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA=="], @@ -3100,6 +3105,8 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], + "gitlab-ai-provider": ["gitlab-ai-provider@5.3.3", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-k0kRUoAhDvoRC28hQW4sPp+A3cfpT5c/oL9Ng10S0oBiF2Tci1AtsX1iclJM5Os8C1nIIAXBW8LMr0GY7rwcGA=="], + "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=="], @@ -3878,6 +3885,10 @@ "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=="], @@ -4014,6 +4025,8 @@ "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "poe-oauth": ["poe-oauth@0.0.5", "", {}, "sha512-InvPkB/Hoe4hg2Hic2UhD6PiFjyxJojmQxkyPzybpyNh8SLGBLJDI/Df9FNJFzkGXgoVRSbchPoVS3SiWehDiw=="], + "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=="], @@ -5186,10 +5199,6 @@ "@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=="], @@ -5246,6 +5255,8 @@ "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + "@modelcontextprotocol/sdk/hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="], + "@modelcontextprotocol/sdk/jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="], "@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -5334,8 +5345,6 @@ "@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=="], @@ -5592,6 +5601,10 @@ "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.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-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=="], @@ -5664,6 +5677,10 @@ "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=="], @@ -6416,12 +6433,18 @@ "motion/framer-motion/motion-dom": ["motion-dom@12.35.2", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-pWXFMTwvGDbx1Fe9YL5HZebv2NhvGBzRtiNUv58aoK7+XrsuaydQ0JGRKK2r+bTKlwgSWwWxHbP5249Qr/BNpg=="], + "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=="], "opencontrol/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + "opencontrol/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], + "opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="], "opencontrol/@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], diff --git a/flake.lock b/flake.lock index 59eb118fa4..805be8739b 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1772091128, - "narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=", + "lastModified": 1773909469, + "narHash": "sha256-vglVrLfHjFIzIdV9A27Ugul6rh3I1qHbbitGW7dk420=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "3f0336406035444b4a24b942788334af5f906259", + "rev": "7149c06513f335be57f26fcbbbe34afda923882b", "type": "github" }, "original": { diff --git a/github/index.ts b/github/index.ts index 1a0a992622..6bfa964623 100644 --- a/github/index.ts +++ b/github/index.ts @@ -496,7 +496,6 @@ async function subscribeSessionEvents() { const TOOL: Record = { todowrite: ["Todo", "\x1b[33m\x1b[1m"], - todoread: ["Todo", "\x1b[33m\x1b[1m"], bash: ["Bash", "\x1b[31m\x1b[1m"], edit: ["Edit", "\x1b[32m\x1b[1m"], glob: ["Glob", "\x1b[34m\x1b[1m"], diff --git a/nix/hashes.json b/nix/hashes.json index 9ee3aeab2d..b6f0fed892 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-Gv0pHYCinlj0SQXRQ/a9ozYPxECwdrC99ssTzpeOr1I=", - "aarch64-linux": "sha256-WzVt5goOrxoGe26juzRf73PWPqwnB1URu2TYjxye/Aw=", - "aarch64-darwin": "sha256-18Nn0TR1wK2gRUF/FFP4vFMY/td49XkfjOwFbD5iJNc=", - "x86_64-darwin": "sha256-zk2yaulPzUUiCerCPJaCOCLhklXKMp9mSv7v0N8AMfA=" + "x86_64-linux": "sha256-0VwVhbOtK1r16cVSZcHaI/8fUPc6aYQiUnh7Q3bSHqs=", + "aarch64-linux": "sha256-z5b234MIS0QqDYLopyaT2hd9CAtEbcSo28y0eMfPsBs=", + "aarch64-darwin": "sha256-sn16mtZIhF9OSBrfAHpDCJO6Nt19mdoxvYAOnwWgwDk=", + "x86_64-darwin": "sha256-FaZpwGuWzfypA28ct86xAnW2RuFFUiXjPkr5wVTLN/o=" } } diff --git a/package.json b/package.json index 7ce06896ad..915e2ef0ac 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.10", + "packageManager": "bun@1.3.11", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev:desktop": "bun --cwd packages/desktop tauri dev", @@ -26,7 +26,7 @@ ], "catalog": { "@effect/platform-node": "4.0.0-beta.35", - "@types/bun": "1.3.9", + "@types/bun": "1.3.11", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", "ulid": "3.0.1", diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index aced0756c0..90af177ed1 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -175,9 +175,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) { +export async function openPalette(page: Page, key = "K") { await defocus(page) - await page.keyboard.press(`${modKey}+P`) + await page.keyboard.press(`${modKey}+${key}`) const dialog = page.getByRole("dialog") await expect(dialog).toBeVisible() diff --git a/packages/app/e2e/app/palette.spec.ts b/packages/app/e2e/app/palette.spec.ts index 3ccfd7a925..4c701fab27 100644 --- a/packages/app/e2e/app/palette.spec.ts +++ b/packages/app/e2e/app/palette.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "../fixtures" -import { openPalette } from "../actions" +import { closeDialog, openPalette } from "../actions" test("search palette opens and closes", async ({ page, gotoSession }) => { await gotoSession() @@ -9,3 +9,12 @@ 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) +}) diff --git a/packages/app/e2e/prompt/prompt-history.spec.ts b/packages/app/e2e/prompt/prompt-history.spec.ts index ec68998144..1c9c079550 100644 --- a/packages/app/e2e/prompt/prompt-history.spec.ts +++ b/packages/app/e2e/prompt/prompt-history.spec.ts @@ -108,7 +108,10 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page await page.keyboard.type(draft) await wait(page, draft) - await edge(page, "start") + // Clear the draft before navigating history (ArrowUp only works when prompt is empty) + await prompt.fill("") + await wait(page, "") + await page.keyboard.press("ArrowUp") await wait(page, second) @@ -119,7 +122,7 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page await wait(page, second) await page.keyboard.press("ArrowDown") - await wait(page, draft) + await wait(page, "") }) }) diff --git a/packages/app/e2e/session/session-review.spec.ts b/packages/app/e2e/session/session-review.spec.ts index c0421f0283..07071239d9 100644 --- a/packages/app/e2e/session/session-review.spec.ts +++ b/packages/app/e2e/session/session-review.spec.ts @@ -169,6 +169,70 @@ async function overflow(page: Parameters[0]["page"], file: string) } } +async function openReviewFile(page: Parameters[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[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[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) @@ -218,6 +282,56 @@ test("review applies inline comment clicks without horizontal overflow", async ( }) }) +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) diff --git a/packages/app/e2e/settings/settings-keybinds.spec.ts b/packages/app/e2e/settings/settings-keybinds.spec.ts index 5789dc0eb0..4fc50b68d7 100644 --- a/packages/app/e2e/settings/settings-keybinds.spec.ts +++ b/packages/app/e2e/settings/settings-keybinds.spec.ts @@ -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("P") + expect(initialKeybind).toContain("K") await keybindButton.click() await expect(keybindButton).toHaveText(/press/i) diff --git a/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts b/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts index d10fca0e49..1317d2bb68 100644 --- a/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts +++ b/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts @@ -1,6 +1,16 @@ import { test, expect } from "../fixtures" -import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions" +import { + defocus, + cleanupSession, + cleanupTestProject, + closeSidebar, + createTestProject, + hoverSessionItem, + openSidebar, + waitSession, +} 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() @@ -37,3 +47,72 @@ 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) + } +}) diff --git a/packages/app/e2e/terminal/terminal-tabs.spec.ts b/packages/app/e2e/terminal/terminal-tabs.spec.ts index ca1f7eee8b..6b6fa4c62b 100644 --- a/packages/app/e2e/terminal/terminal-tabs.spec.ts +++ b/packages/app/e2e/terminal/terminal-tabs.spec.ts @@ -1,7 +1,7 @@ import type { Page } from "@playwright/test" import { runTerminal, waitTerminalReady } from "../actions" import { test, expect } from "../fixtures" -import { terminalSelector } from "../selectors" +import { dropdownMenuContentSelector, terminalSelector } from "../selectors" import { terminalToggleKey, workspacePersistKey } from "../utils" type State = { @@ -130,3 +130,39 @@ 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) + }) +}) diff --git a/packages/app/package.json b/packages/app/package.json index 545d313098..733da17bfe 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.2.27", + "version": "1.3.2", "description": "", "type": "module", "exports": { @@ -51,9 +51,11 @@ "@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:", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 9a282bbb70..0eb5b4e9e0 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -6,9 +6,10 @@ 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" +import { ThemeProvider } from "@opencode-ai/ui/theme/context" 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, @@ -31,7 +32,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, useLanguage } from "@/context/language" +import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LayoutProvider } from "@/context/layout" import { ModelsProvider } from "@/context/models" import { NotificationProvider } from "@/context/notification" @@ -81,6 +82,11 @@ function MarkedProviderWithNativeParser(props: ParentProps) { return {props.children} } +function QueryProvider(props: ParentProps) { + const client = new QueryClient() + return {props.children} +} + function AppShellProviders(props: ParentProps) { return ( @@ -124,7 +130,7 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { ) } -export function AppBaseProviders(props: ParentProps) { +export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { return ( @@ -133,14 +139,16 @@ export function AppBaseProviders(props: ParentProps) { void window.api?.setTitlebar?.({ mode }) }} > - + }> - - - {props.children} - - + + + + {props.children} + + + diff --git a/packages/app/src/components/debug-bar.tsx b/packages/app/src/components/debug-bar.tsx index cbb24f77bc..f4b7a1bc0e 100644 --- a/packages/app/src/components/debug-bar.tsx +++ b/packages/app/src/components/debug-bar.tsx @@ -55,7 +55,7 @@ function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string;
@@ -363,11 +363,7 @@ export function DebugBar() { return (
@@ -92,7 +89,14 @@ export const DialogSelectMcp: Component = () => {
e.stopPropagation()}> - toggle(i.name)} /> + { + if (toggle.isPending) return + toggle.mutate(i.name) + }} + />
) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index f8d14cbb94..ca4c42a376 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -6,6 +6,7 @@ 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" @@ -186,7 +187,6 @@ export function DialogSelectServer() { name: "", username: DEFAULT_USERNAME, password: "", - adding: false, error: "", showForm: false, status: undefined as boolean | undefined, @@ -198,7 +198,6 @@ export function DialogSelectServer() { username: "", password: "", error: "", - busy: false, status: undefined as boolean | undefined, }, }) @@ -209,7 +208,6 @@ export function DialogSelectServer() { name: "", username: DEFAULT_USERNAME, password: "", - adding: false, error: "", showForm: false, status: undefined, @@ -224,10 +222,78 @@ 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) @@ -296,7 +362,7 @@ export function DialogSelectServer() { } const handleAddChange = (value: string) => { - if (store.addServer.adding) return + if (addMutation.isPending) return setStore("addServer", { url: value, error: "" }) void previewStatus(value, store.addServer.username, store.addServer.password, (next) => setStore("addServer", { status: next }), @@ -304,12 +370,12 @@ export function DialogSelectServer() { } const handleAddNameChange = (value: string) => { - if (store.addServer.adding) return + if (addMutation.isPending) return setStore("addServer", { name: value, error: "" }) } const handleAddUsernameChange = (value: string) => { - if (store.addServer.adding) return + if (addMutation.isPending) return setStore("addServer", { username: value, error: "" }) void previewStatus(store.addServer.url, value, store.addServer.password, (next) => setStore("addServer", { status: next }), @@ -317,7 +383,7 @@ export function DialogSelectServer() { } const handleAddPasswordChange = (value: string) => { - if (store.addServer.adding) return + if (addMutation.isPending) return setStore("addServer", { password: value, error: "" }) void previewStatus(store.addServer.url, store.addServer.username, value, (next) => setStore("addServer", { status: next }), @@ -325,7 +391,7 @@ export function DialogSelectServer() { } const handleEditChange = (value: string) => { - if (store.editServer.busy) return + if (editMutation.isPending) return setStore("editServer", { value, error: "" }) void previewStatus(value, store.editServer.username, store.editServer.password, (next) => setStore("editServer", { status: next }), @@ -333,12 +399,12 @@ export function DialogSelectServer() { } const handleEditNameChange = (value: string) => { - if (store.editServer.busy) return + if (editMutation.isPending) return setStore("editServer", { name: value, error: "" }) } const handleEditUsernameChange = (value: string) => { - if (store.editServer.busy) return + if (editMutation.isPending) return setStore("editServer", { username: value, error: "" }) void previewStatus(store.editServer.value, value, store.editServer.password, (next) => setStore("editServer", { status: next }), @@ -346,85 +412,13 @@ export function DialogSelectServer() { } const handleEditPasswordChange = (value: string) => { - if (store.editServer.busy) return + if (editMutation.isPending) 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" @@ -464,23 +458,26 @@ export function DialogSelectServer() { password: conn.http.password ?? "", error: "", status: store.status[ServerConnection.key(conn)]?.healthy, - busy: false, }) } const submitForm = () => { if (mode() === "add") { - void handleAdd(store.addServer.url) + if (addMutation.isPending) return + setStore("addServer", { error: "" }) + addMutation.mutate(store.addServer.url) return } const original = editing() if (!original) return - void handleEdit(original, store.editServer.value) + if (editMutation.isPending) return + setStore("editServer", { error: "" }) + editMutation.mutate({ original, value: store.editServer.value }) } const isFormMode = createMemo(() => mode() !== "list") const isAddMode = createMemo(() => mode() === "add") - const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy)) + const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending)) const formTitle = createMemo(() => { if (!isFormMode()) return language.t("dialog.server.title") diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 55cfaa490f..ee98e68cd5 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -572,6 +572,7 @@ export const PromptInput: Component = (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)) @@ -1043,7 +1044,7 @@ export const PromptInput: Component = (props) => { return true } - const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({ + const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({ editor: () => editorRef, isDialogActive: () => !!dialog.active, setDraggingType: (type) => setStore("draggingType", type), @@ -1383,11 +1384,12 @@ export const PromptInput: Component = (props) => { { - const file = e.currentTarget.files?.[0] - if (file) void addAttachment(file) + const list = e.currentTarget.files + if (list) void addAttachments(Array.from(list)) e.currentTarget.value = "" }} /> @@ -1496,7 +1498,7 @@ export const PromptInput: Component = (props) => { > @@ -1528,7 +1530,7 @@ export const PromptInput: Component = (props) => { > diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index eca508c6ce..fa9930f683 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -71,6 +71,18 @@ 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) @@ -84,18 +96,14 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { event.preventDefault() event.stopPropagation() - const items = Array.from(clipboardData.items) - const fileItems = items.filter((item) => item.kind === "file") + const files = Array.from(clipboardData.items).flatMap((item) => { + if (item.kind !== "file") return [] + const file = item.getAsFile() + return file ? [file] : [] + }) - 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() + if (files.length > 0) { + await addAttachments(files) return } @@ -169,12 +177,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { const dropped = event.dataTransfer?.files if (!dropped) return - 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() + await addAttachments(Array.from(dropped)) } onMount(() => { @@ -191,6 +194,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { return { addAttachment, + addAttachments, removeAttachment, handlePaste, } diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts index 4c2e2d8bec..ce09ae9217 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.test.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts @@ -49,6 +49,32 @@ 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 }] diff --git a/packages/app/src/components/prompt-input/files.ts b/packages/app/src/components/prompt-input/files.ts index 594991d07a..eae8af03d9 100644 --- a/packages/app/src/components/prompt-input/files.ts +++ b/packages/app/src/components/prompt-input/files.ts @@ -1,4 +1,6 @@ -export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] +import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-picker" + +export { ACCEPTED_FILE_TYPES } const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES) const IMAGE_EXTS = new Map([ @@ -18,61 +20,6 @@ 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) { diff --git a/packages/app/src/components/prompt-input/history.test.ts b/packages/app/src/components/prompt-input/history.test.ts index 37b5ce1962..5e9c2c66ea 100644 --- a/packages/app/src/components/prompt-input/history.test.ts +++ b/packages/app/src/components/prompt-input/history.test.ts @@ -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(true) + expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(false) expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false) expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false) @@ -135,11 +135,14 @@ describe("prompt-input history", () => { expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false) expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true) - expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true) + expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(false) 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) diff --git a/packages/app/src/components/prompt-input/history.ts b/packages/app/src/components/prompt-input/history.ts index de62653211..79e8abc0d9 100644 --- a/packages/app/src/components/prompt-input/history.ts +++ b/packages/app/src/components/prompt-input/history.ts @@ -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 + if (direction === "up") return position === 0 && text.length === 0 return position === text.length } diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 9aa101bdb9..4d90930a0e 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -267,14 +267,14 @@ export function SessionContextTab() { return ( { scroll = el restoreScroll() }} onScroll={handleScroll} > -
+
{(stat) => [0])} value={stat.value()} />} diff --git a/packages/app/src/components/session/session-sortable-terminal-tab.tsx b/packages/app/src/components/session/session-sortable-terminal-tab.tsx index 8989587425..ba697f91af 100644 --- a/packages/app/src/components/session/session-sortable-terminal-tab.tsx +++ b/packages/app/src/components/session/session-sortable-terminal-tab.tsx @@ -24,6 +24,7 @@ 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 @@ -168,8 +169,14 @@ 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()) + }} > - + (editRequested = true)}> {language.t("common.rename")} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index b768bafcca..f4b8198e7e 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -1,27 +1,41 @@ -import { Component, Show, createMemo, createResource, type JSX } from "solid-js" +import { Component, Show, createMemo, createResource, onMount, 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" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSettings, monoFontFamily } from "@/context/settings" -import { playSound, SOUND_OPTIONS } from "@/utils/sound" +import { playSoundById, 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 | 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() } @@ -29,12 +43,19 @@ const stopDemoSound = () => { demoSoundState.cleanup = undefined } -const playDemoSound = (src: string | undefined) => { +const playDemoSound = (id: string | undefined) => { stopDemoSound() - if (!src) return + if (!id) return + const run = ++demoSoundState.run demoSoundState.timeout = setTimeout(() => { - demoSoundState.cleanup = playSound(src) + void playSoundById(id).then((cleanup) => { + if (demoSoundState.run !== run) { + cleanup?.() + return + } + demoSoundState.cleanup = cleanup + }) }, 100) } @@ -44,6 +65,10 @@ export const SettingsGeneral: Component = () => { const platform = usePlatform() const settings = useSettings() + onMount(() => { + void theme.loadThemes() + }) + const [store, setStore] = createStore({ checking: false, }) @@ -104,9 +129,7 @@ export const SettingsGeneral: Component = () => { .finally(() => setStore("checking", false)) } - const themeOptions = createMemo(() => - Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })), - ) + const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ { value: "system", label: language.t("theme.scheme.system") }, @@ -143,7 +166,7 @@ export const SettingsGeneral: Component = () => { ] as const const fontOptionsList = [...fontOptions] - const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const + const noneSound = { id: "none", label: "sound.option.none" } as const const soundOptions = [noneSound, ...SOUND_OPTIONS] const soundSelectProps = ( @@ -158,7 +181,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.src) + playDemoSound(option.id === "none" ? undefined : option.id) }, onSelect: (option: (typeof soundOptions)[number] | undefined) => { if (!option) return @@ -169,7 +192,7 @@ export const SettingsGeneral: Component = () => { } setEnabled(true) set(option.id) - playDemoSound(option.src) + playDemoSound(option.id) }, variant: "secondary" as const, size: "small" as const, @@ -321,6 +344,9 @@ 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" diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 063205f0c3..8d5ecac39a 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -4,6 +4,7 @@ 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" @@ -15,7 +16,6 @@ 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,11 +53,15 @@ const listServersByHealth = ( }) } -const useServerHealth = (servers: Accessor) => { +const useServerHealth = (servers: Accessor, enabled: Accessor) => { const checkServerHealth = useCheckServerHealth() const [status, setStatus] = createStore({} as Record) createEffect(() => { + if (!enabled()) { + setStatus(reconcile({})) + return + } const list = servers() let dead = false @@ -130,41 +134,30 @@ const useDefaultServerKey = ( } } -const useMcpToggle = (input: { - sync: ReturnType - sdk: ReturnType - language: ReturnType -}) => { - const [loading, setLoading] = createSignal(null) +const useMcpToggleMutation = () => { + const sync = useSync() + const sdk = useSDK() + const language = useLanguage() - 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) { + 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) => { showToast({ variant: "error", - title: input.language.t("common.requestFailed"), + title: 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() @@ -172,6 +165,12 @@ 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 @@ -179,9 +178,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) + const health = useServerHealth(servers, shown) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) - const mcp = useMcpToggle({ sync, sdk, language }) + const toggleMcp = useMcpToggleMutation() 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 @@ -310,7 +309,13 @@ export function StatusPopover() { @@ -337,8 +342,11 @@ export function StatusPopover() { diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index aed46f1262..0a5a7d2d3e 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -1,4 +1,7 @@ -import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme" +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 { 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" diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 77de1a73ce..0a41f31196 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -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" +import { useTheme } from "@opencode-ai/ui/theme/context" import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" diff --git a/packages/app/src/constants/file-picker.ts b/packages/app/src/constants/file-picker.ts new file mode 100644 index 0000000000..c661bc8f36 --- /dev/null +++ b/packages/app/src/constants/file-picker.ts @@ -0,0 +1,89 @@ +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 }] +} diff --git a/packages/app/src/context/command-keybind.test.ts b/packages/app/src/context/command-keybind.test.ts index 4e38efd8da..c8e2dbb5d0 100644 --- a/packages/app/src/context/command-keybind.test.ts +++ b/packages/app/src/context/command-keybind.test.ts @@ -32,6 +32,25 @@ describe("command keybind helpers", () => { expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false) }) + test("matchKeybind supports bracket keys", () => { + const keybinds = parseKeybind("mod+alt+[, mod+alt+]") + const prev = keybinds[0] + const next = keybinds[1] + + expect( + matchKeybind( + keybinds, + new KeyboardEvent("keydown", { key: "[", ctrlKey: prev?.ctrl, metaKey: prev?.meta, altKey: true }), + ), + ).toBe(true) + expect( + matchKeybind( + keybinds, + new KeyboardEvent("keydown", { key: "]", ctrlKey: next?.ctrl, metaKey: next?.meta, altKey: true }), + ), + ).toBe(true) + }) + test("formatKeybind returns human readable output", () => { const display = formatKeybind("ctrl+alt+arrowup") @@ -40,4 +59,11 @@ 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) + }) }) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 2d1e501353..cbd08e99f5 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -9,17 +9,7 @@ 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, - Match, - onCleanup, - onMount, - type ParentProps, - Switch, - untrack, - useContext, -} from "solid-js" +import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" import { Persist, persisted } from "@/utils/persist" @@ -80,6 +70,8 @@ function createGlobalSync() { let active = true let projectWritten = false + let bootedAt = 0 + let bootingRoot = false onCleanup(() => { active = false @@ -258,6 +250,11 @@ 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], @@ -278,15 +275,20 @@ 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: queue.refresh, + refresh: () => { + if (recent) return + 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) } @@ -325,17 +327,19 @@ function createGlobalSync() { }) async function bootstrap() { - 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, - }) + 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 + } } onMount(() => { @@ -392,13 +396,7 @@ const GlobalSyncContext = createContext>() export function GlobalSyncProvider(props: ParentProps) { const value = createGlobalSync() - return ( - - - {props.children} - - - ) + return {props.children} } export function useGlobalSync() { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 13494b7ade..4790011a53 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -15,7 +15,7 @@ import { retry } from "@opencode-ai/util/retry" import { batch } from "solid-js" import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { State, VcsCache } from "./types" -import { cmp, normalizeProviderList } from "./utils" +import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { formatServerError } from "@/utils/server-errors" type GlobalStore = { @@ -31,73 +31,102 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } +function waitForPaint() { + return new Promise((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[]) { + return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason) +} + +function runAll(list: Array<() => Promise>) { + return Promise.allSettled(list.map((item) => item())) +} + +function showErrors(input: { + errors: unknown[] + title: string + translate: (key: string, vars?: Record) => 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 formatMoreCount: (count: number) => string setGlobalStore: SetStoreFunction }) { - 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 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 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, - }) - } + 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, + }) input.setGlobalStore("ready", true) } @@ -111,6 +140,10 @@ function groupBySession(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 @@ -119,88 +152,130 @@ export async function bootstrapDirectory(input: { vcsCache: VcsCache loadSessions: (directory: string) => Promise | void translate: (key: string, vars?: Record) => string -}) { - if (input.store.status !== "complete") input.setStore("status", "loading") - - 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!)), + 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") - try { - await Promise.all(Object.values(blockingRequests).map((p) => retry(p))) - } catch (err) { - console.error("Failed to bootstrap instance", err) + 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", normalizeAgentList(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) 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), + ) + 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(err, input.translate), + description: formatServerError(errs[0], input.translate), }) - input.setStore("status", "partial") - return } - if (input.store.status !== "complete") input.setStore("status", "partial") + 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), + }) + } - 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" }, - ), - ) - } - }) - }), - 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") - }) + if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete") } diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index cf2da135cb..892129788e 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -494,8 +494,10 @@ describe("applyDirectoryEvent", () => { }) test("updates vcs branch in store and cache", () => { - const [store, setStore] = createStore(baseState()) - const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] }) + const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } })) + const [cacheStore, setCacheStore] = createStore({ + value: { branch: "main", default_branch: "main" } as State["vcs"], + }) applyDirectoryEvent({ event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } }, @@ -511,8 +513,8 @@ describe("applyDirectoryEvent", () => { }, }) - expect(store.vcs).toEqual({ branch: "feature/test" }) - expect(cacheStore.value).toEqual({ branch: "feature/test" }) + expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" }) + expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" }) }) test("routes disposal and lsp events to side-effect handlers", () => { diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index b8eda0573f..4af6365535 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -15,6 +15,8 @@ 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[] @@ -211,6 +213,7 @@ 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]) @@ -268,9 +271,9 @@ export function applyDirectoryEvent(input: { break } case "vcs.branch.updated": { - const props = event.properties as { branch: string } + const props = event.properties as { branch?: string } if (input.store.vcs?.branch === props.branch) break - const next = { branch: props.branch } + const next = { ...input.store.vcs, branch: props.branch } input.setStore("vcs", next) if (input.vcsCache) input.vcsCache.setStore("value", next) break diff --git a/packages/app/src/context/global-sync/utils.test.ts b/packages/app/src/context/global-sync/utils.test.ts new file mode 100644 index 0000000000..6d44ac9a89 --- /dev/null +++ b/packages/app/src/context/global-sync/utils.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test" +import type { Agent } from "@opencode-ai/sdk/v2/client" +import { normalizeAgentList } from "./utils" + +const agent = (name = "build") => + ({ + name, + mode: "primary", + permission: {}, + options: {}, + }) as Agent + +describe("normalizeAgentList", () => { + test("keeps array payloads", () => { + expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")]) + }) + + test("wraps a single agent payload", () => { + expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")]) + }) + + test("extracts agents from keyed objects", () => { + expect( + normalizeAgentList({ + build: agent("build"), + docs: agent("docs"), + }), + ).toEqual([agent("build"), agent("docs")]) + }) + + test("drops invalid payloads", () => { + expect(normalizeAgentList({ name: "AbortError" })).toEqual([]) + expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")]) + }) +}) diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts index 6b78134a61..cac58f3174 100644 --- a/packages/app/src/context/global-sync/utils.ts +++ b/packages/app/src/context/global-sync/utils.ts @@ -1,7 +1,21 @@ -import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client" +import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client" export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) +function isAgent(input: unknown): input is Agent { + if (!input || typeof input !== "object") return false + const item = input as { name?: unknown; mode?: unknown } + if (typeof item.name !== "string") return false + return item.mode === "subagent" || item.mode === "primary" || item.mode === "all" +} + +export function normalizeAgentList(input: unknown): Agent[] { + if (Array.isArray(input)) return input.filter(isAgent) + if (isAgent(input)) return [input] + if (!input || typeof input !== "object") return [] + return Object.values(input).filter(isAgent) +} + export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse { return { ...input, diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index b1edd541c3..51dc09cd7d 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -1,42 +1,10 @@ import * as i18n from "@solid-primitives/i18n" -import { createEffect, createMemo } from "solid-js" +import { createEffect, createMemo, createResource } 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" @@ -59,6 +27,7 @@ export type Locale = type RawDictionary = typeof en & typeof uiEn type Dictionary = i18n.Flatten +type Source = { dict: Record } function cookie(locale: Locale) { return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax` @@ -125,24 +94,43 @@ const LABEL_KEY: Record = { } const base = i18n.flatten({ ...en, ...uiEn }) -const DICT: Record = { - 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 dicts = new Map([["en", base]]) + +const merge = (app: Promise, ui: Promise) => + Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary) + +const loaders: Record, () => Promise> = { + 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 localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [ @@ -168,27 +156,6 @@ 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, Record> = { - 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" @@ -203,27 +170,48 @@ function detectLocale(): Locale { return "en" } -function normalizeLocale(value: string): Locale { +export 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: () => { + init: (props: { locale?: Locale }) => { + const initial = props.locale ?? readStoredLocale() ?? detectLocale() const [store, setStore, _, ready] = persisted( Persist.global("language", ["language.v1"]), createStore({ - locale: detectLocale() as Locale, + locale: initial, }), ) const locale = createMemo(() => normalizeLocale(store.locale)) - console.log("locale", locale()) const intl = createMemo(() => INTL[locale()]) - const dict = createMemo(() => DICT[locale()]) + const [dict] = createResource(locale, loadDict, { + initialValue: dicts.get(initial) ?? base, + }) - const t = i18n.translator(dict, i18n.resolveTemplate) + const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as ( + key: keyof Dictionary, + params?: Record, + ) => string const label = (value: Locale) => t(LABEL_KEY[value]) diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 04bc2fdaaa..281a1ef33d 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -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 { playSound, soundSrc } from "@/utils/sound" +import { playSoundById } 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()) { - playSound(soundSrc(settings.sounds.agent())) + void playSoundById(settings.sounds.agent()) } append({ @@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi if (session?.parentID) return if (settings.sounds.errorsEnabled()) { - playSound(soundSrc(settings.sounds.errors())) + void playSoundById(settings.sounds.errors()) } const error = "error" in event.properties ? event.properties.error : undefined diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index b8ed58e343..3bdc46391b 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -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 } +type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] } type SaveFilePickerOptions = { title?: string; defaultPath?: string } type UpdateInfo = { updateAvailable: boolean; version?: string } diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 48788fe8ec..eddd752eb4 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -104,6 +104,13 @@ function withFallback(read: () => T | undefined, fallback: T) { return createMemo(() => read() ?? fallback) } +let font: Promise | undefined + +function loadFont() { + font ??= import("@opencode-ai/ui/font-loader") + return font +} + export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({ name: "Settings", init: () => { @@ -111,7 +118,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont createEffect(() => { if (typeof document === "undefined") return - document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font)) + 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)) }) return { diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 0f20087234..bbf4fc5ec4 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -14,6 +14,8 @@ 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)) } @@ -178,7 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return globalSync.child(directory) } const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") - const messagePageSize = 200 + const initialMessagePageSize = 80 + const historyMessagePageSize = 200 const inflight = new Map>() const inflightDiff = new Map>() const inflightTodo = new Map>() @@ -336,7 +339,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ batch(() => { input.setStore("message", input.sessionID, reconcile(message, { key: "id" })) for (const p of next.part) { - input.setStore("part", p.id, p.part) + const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type)) + if (filtered.length) input.setStore("part", p.id, filtered) } setMeta("limit", key, message.length) setMeta("cursor", key, next.cursor) @@ -460,7 +464,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] ?? messagePageSize + const limit = meta.limit[key] ?? initialMessagePageSize const sessionReq = hasSession && !opts?.force ? Promise.resolve() @@ -557,7 +561,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 ?? messagePageSize + const step = count ?? historyMessagePageSize if (meta.loading[key]) return if (meta.complete[key]) return const before = meta.cursor[key] diff --git a/packages/app/src/context/terminal-title.ts b/packages/app/src/context/terminal-title.ts index 3e8fa9af25..c8b18f4211 100644 --- a/packages/app/src/context/terminal-title.ts +++ b/packages/app/src/context/terminal-title.ts @@ -1,45 +1,18 @@ -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 template = "Terminal {{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"], - ]), -) +const numbered = [ + template, + "محطة طرفية {{number}}", + "Терминал {{number}}", + "ターミナル {{number}}", + "터미널 {{number}}", + "เทอร์มินัล {{number}}", + "终端 {{number}}", + "終端機 {{number}}", +] export function defaultTitle(number: number) { - return en["terminal.title.numbered"].replace("{{number}}", String(number)) + return template.replace("{{number}}", String(number)) } export function isDefaultTitle(title: string, number: number) { diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index a25f8b4b25..a8f2360bbf 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -22,7 +22,7 @@ export function useProviders() { const providers = () => { if (dir()) { const [projectStore] = globalSync.child(dir()) - return projectStore.provider + if (projectStore.provider.all.length > 0) return projectStore.provider } return globalSync.data.provider } diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index c8f58c796e..6e40e03007 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -722,8 +722,6 @@ export const dict = { "settings.permissions.tool.skill.description": "تحميل مهارة بالاسم", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "تشغيل استعلامات خادم اللغة", - "settings.permissions.tool.todoread.title": "قراءة المهام", - "settings.permissions.tool.todoread.description": "قراءة قائمة المهام", "settings.permissions.tool.todowrite.title": "كتابة المهام", "settings.permissions.tool.todowrite.description": "تحديث قائمة المهام", "settings.permissions.tool.webfetch.title": "جلب الويب", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 3112e91bbe..3c7ef9d828 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -732,8 +732,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Carregar uma habilidade por nome", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Executar consultas de servidor de linguagem", - "settings.permissions.tool.todoread.title": "Ler Tarefas", - "settings.permissions.tool.todoread.description": "Ler a lista de tarefas", "settings.permissions.tool.todowrite.title": "Escrever Tarefas", "settings.permissions.tool.todowrite.description": "Atualizar a lista de tarefas", "settings.permissions.tool.webfetch.title": "Buscar Web", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index f2dbd8493c..15b73453be 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -806,8 +806,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Učitaj vještinu po nazivu", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Pokreni upite jezičnog servera", - "settings.permissions.tool.todoread.title": "Čitanje liste zadataka", - "settings.permissions.tool.todoread.description": "Čitanje liste zadataka", "settings.permissions.tool.todowrite.title": "Ažuriranje liste zadataka", "settings.permissions.tool.todowrite.description": "Ažuriraj listu zadataka", "settings.permissions.tool.webfetch.title": "Web preuzimanje", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index e90e1071ad..55212faccd 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -800,8 +800,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Indlæs en færdighed efter navn", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Kør sprogserverforespørgsler", - "settings.permissions.tool.todoread.title": "Læs To-do", - "settings.permissions.tool.todoread.description": "Læs to-do listen", "settings.permissions.tool.todowrite.title": "Skriv To-do", "settings.permissions.tool.todowrite.description": "Opdater to-do listen", "settings.permissions.tool.webfetch.title": "Webhentning", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 69658b29e9..552375f572 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -743,8 +743,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Eine Fähigkeit nach Namen laden", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Language-Server-Abfragen ausführen", - "settings.permissions.tool.todoread.title": "Todo lesen", - "settings.permissions.tool.todoread.description": "Die Todo-Liste lesen", "settings.permissions.tool.todowrite.title": "Todo schreiben", "settings.permissions.tool.todowrite.description": "Die Todo-Liste aktualisieren", "settings.permissions.tool.webfetch.title": "Web-Abruf", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 72caed40ad..bdf97ec0fe 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -23,6 +23,8 @@ 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", @@ -274,7 +276,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 file", + "prompt.action.attachFile": "Add files", "prompt.attachment.remove": "Remove attachment", "prompt.action.send": "Send", "prompt.action.stop": "Stop", @@ -533,6 +535,8 @@ export const dict = { "session.review.noVcs.createGit.action": "Create Git repository", "session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable", "session.review.noChanges": "No changes", + "session.review.noUncommittedChanges": "No uncommitted changes yet", + "session.review.noBranchChanges": "No branch changes yet", "session.files.selectToOpen": "Select a file to open", "session.files.all": "All files", @@ -898,8 +902,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Load a skill by name", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Run language server queries", - "settings.permissions.tool.todoread.title": "Todo Read", - "settings.permissions.tool.todoread.description": "Read the todo list", "settings.permissions.tool.todowrite.title": "Todo Write", "settings.permissions.tool.todowrite.description": "Update the todo list", "settings.permissions.tool.webfetch.title": "Web Fetch", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 9e36e4de6d..31fd71c044 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -813,8 +813,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Cargar una habilidad por nombre", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Ejecutar consultas de servidor de lenguaje", - "settings.permissions.tool.todoread.title": "Leer Todo", - "settings.permissions.tool.todoread.description": "Leer la lista de tareas", "settings.permissions.tool.todowrite.title": "Escribir Todo", "settings.permissions.tool.todowrite.description": "Actualizar la lista de tareas", "settings.permissions.tool.webfetch.title": "Web Fetch", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index f53b3882c6..e19282a76e 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -741,8 +741,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Charger une compétence par son nom", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Exécuter des requêtes de serveur de langage", - "settings.permissions.tool.todoread.title": "Lire Todo", - "settings.permissions.tool.todoread.description": "Lire la liste de tâches", "settings.permissions.tool.todowrite.title": "Écrire Todo", "settings.permissions.tool.todowrite.description": "Mettre à jour la liste de tâches", "settings.permissions.tool.webfetch.title": "Récupération Web", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index d66a7341d5..52e4ab6ed9 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -727,8 +727,6 @@ export const dict = { "settings.permissions.tool.skill.description": "名前によるスキルの読み込み", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "言語サーバークエリの実行", - "settings.permissions.tool.todoread.title": "Todo読み込み", - "settings.permissions.tool.todoread.description": "Todoリストの読み込み", "settings.permissions.tool.todowrite.title": "Todo書き込み", "settings.permissions.tool.todowrite.description": "Todoリストの更新", "settings.permissions.tool.webfetch.title": "Web取得", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index d534c27e8f..8d9efabb63 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -726,8 +726,6 @@ export const dict = { "settings.permissions.tool.skill.description": "이름으로 기술 로드", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "언어 서버 쿼리 실행", - "settings.permissions.tool.todoread.title": "할 일 읽기", - "settings.permissions.tool.todoread.description": "할 일 목록 읽기", "settings.permissions.tool.todowrite.title": "할 일 쓰기", "settings.permissions.tool.todowrite.description": "할 일 목록 업데이트", "settings.permissions.tool.webfetch.title": "웹 가져오기", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index c23d0a2792..7342ec083d 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -807,8 +807,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Last en ferdighet etter navn", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Kjør språkserverforespørsler", - "settings.permissions.tool.todoread.title": "Les gjøremål", - "settings.permissions.tool.todoread.description": "Les gjøremålslisten", "settings.permissions.tool.todowrite.title": "Skriv gjøremål", "settings.permissions.tool.todowrite.description": "Oppdater gjøremålslisten", "settings.permissions.tool.webfetch.title": "Webhenting", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index dac847b217..d3a3d62662 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -729,8 +729,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Ładowanie umiejętności według nazwy", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Uruchamianie zapytań serwera językowego", - "settings.permissions.tool.todoread.title": "Odczyt Todo", - "settings.permissions.tool.todoread.description": "Odczyt listy zadań", "settings.permissions.tool.todowrite.title": "Zapis Todo", "settings.permissions.tool.todowrite.description": "Aktualizacja listy zadań", "settings.permissions.tool.webfetch.title": "Pobieranie z sieci", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 684d5deecd..ac02f8dbeb 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -808,8 +808,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Загрузка навыка по имени", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Запросы к языковому серверу", - "settings.permissions.tool.todoread.title": "Todo Read", - "settings.permissions.tool.todoread.description": "Чтение списка задач", "settings.permissions.tool.todowrite.title": "Todo Write", "settings.permissions.tool.todowrite.description": "Обновление списка задач", "settings.permissions.tool.webfetch.title": "Web Fetch", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 80f0da94ec..8d146123f2 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -796,8 +796,6 @@ export const dict = { "settings.permissions.tool.skill.description": "โหลดทักษะตามชื่อ", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "เรียกใช้การสืบค้นเซิร์ฟเวอร์ภาษา", - "settings.permissions.tool.todoread.title": "อ่านรายการงาน", - "settings.permissions.tool.todoread.description": "อ่านรายการงาน", "settings.permissions.tool.todowrite.title": "เขียนรายการงาน", "settings.permissions.tool.todowrite.description": "อัปเดตรายการงาน", "settings.permissions.tool.webfetch.title": "ดึงข้อมูลจากเว็บ", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 9041e0dd07..fb3c0c26f6 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -816,8 +816,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Ada göre bir beceri yükle", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Dil sunucusu sorguları çalıştır", - "settings.permissions.tool.todoread.title": "Görev Oku", - "settings.permissions.tool.todoread.description": "Görev listesini oku", "settings.permissions.tool.todowrite.title": "Görev Yaz", "settings.permissions.tool.todowrite.description": "Görev listesini güncelle", "settings.permissions.tool.webfetch.title": "Web Getir", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index cf64ca9b2c..2a7ababb2b 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -795,8 +795,6 @@ export const dict = { "settings.permissions.tool.skill.description": "按名称加载技能", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "运行语言服务器查询", - "settings.permissions.tool.todoread.title": "读取待办", - "settings.permissions.tool.todoread.description": "读取待办列表", "settings.permissions.tool.todowrite.title": "更新待办", "settings.permissions.tool.todowrite.description": "更新待办列表", "settings.permissions.tool.webfetch.title": "网页获取", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 02c00d17a2..8ee29733ef 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -790,8 +790,6 @@ export const dict = { "settings.permissions.tool.skill.description": "按名稱載入技能", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "執行語言伺服器查詢", - "settings.permissions.tool.todoread.title": "讀取待辦", - "settings.permissions.tool.todoread.description": "讀取待辦清單", "settings.permissions.tool.todowrite.title": "更新待辦", "settings.permissions.tool.todowrite.description": "更新待辦清單", "settings.permissions.tool.webfetch.title": "Web Fetch", diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 6c870dfa4d..d80e9fffb0 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,5 +1,7 @@ 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" diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index cd5e079a69..6d3b04be9d 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -2,8 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" -import { createMemo, createResource, type ParentProps, Show } from "solid-js" -import { useGlobalSDK } from "@/context/global-sdk" +import { createEffect, createMemo, type ParentProps, Show } from "solid-js" import { useLanguage } from "@/context/language" import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" @@ -11,10 +10,18 @@ import { SyncProvider, useSync } from "@/context/sync" import { decode64 } from "@/utils/base64" 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 ( ) { export default function Layout(props: ParentProps) { const params = useParams() - const location = useLocation() const language = useLanguage() - const globalSDK = useGlobalSDK() const navigate = useNavigate() let invalid = "" - const [resolved] = createResource( - () => { - if (params.dir) return [location.pathname, params.dir] as const - }, - async ([pathname, b64Dir]) => { - const directory = decode64(b64Dir) + const resolved = createMemo(() => { + if (!params.dir) return "" + return decode64(params.dir) ?? "" + }) - if (!directory) { - if (invalid === params.dir) return - invalid = b64Dir - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: language.t("directory.error.invalidUrl"), - }) - navigate("/", { replace: true }) - return - } - - return await globalSDK - .createClient({ - directory, - throwOnError: true, - }) - .path.get() - .then((x) => { - const next = x.data?.directory ?? directory - invalid = "" - if (next === directory) return next - const path = pathname.slice(b64Dir.length + 1) - navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) - }) - .catch(() => { - invalid = "" - return directory - }) - }, - ) + createEffect(() => { + const dir = params.dir + if (!dir) return + if (resolved()) { + invalid = "" + return + } + if (invalid === dir) return + invalid = dir + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: language.t("directory.error.invalidUrl"), + }) + navigate("/", { replace: true }) + }) return ( diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index ba3a2b9427..4c795b9683 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -113,6 +113,14 @@ export default function Home() {
+ +
+
{language.t("common.loading")}
+ +
+
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 8e2248469d..b5a96110f6 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -49,21 +49,16 @@ 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 { playSound, soundSrc } from "@/utils/sound" +import { playSoundById } 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" -import { DialogSelectProvider } from "@/components/dialog-select-provider" -import { DialogSelectServer } from "@/components/dialog-select-server" -import { DialogSettings } from "@/components/dialog-settings" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" 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" @@ -110,6 +105,8 @@ 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() @@ -139,7 +136,7 @@ export default function Layout(props: ParentProps) { dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir, } }) - const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) + const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const)) const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] const colorSchemeKey: Record = { system: "theme.scheme.system", @@ -201,6 +198,8 @@ export default function Layout(props: ParentProps) { }) onCleanup(() => { + dialogDead = true + dialogRun += 1 if (navLeave.current !== undefined) clearTimeout(navLeave.current) clearTimeout(sortNowTimeout) if (sortNowInterval) clearInterval(sortNowInterval) @@ -211,13 +210,22 @@ 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) }) }) @@ -237,6 +245,12 @@ 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 @@ -305,8 +319,7 @@ export default function Layout(props: ParentProps) { const clearSidebarHoverState = () => { if (layout.sidebar.opened()) return - setState("hoverSession", undefined) - setHoverProject(undefined) + reset() } const navigateWithSidebarReset = (href: string) => { @@ -322,10 +335,9 @@ 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: nextTheme?.name ?? nextThemeId, + description: theme.name(nextThemeId), }) } @@ -480,7 +492,7 @@ export default function Layout(props: ParentProps) { if (e.details.type === "permission.asked") { if (settings.sounds.permissionsEnabled()) { - playSound(soundSrc(settings.sounds.permissions())) + void playSoundById(settings.sounds.permissions()) } if (settings.notifications.permissions()) { void platform.notify(title, description, href) @@ -936,6 +948,28 @@ 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 + + // warm up child store to prevent flicker + globalSync.child(target.worktree) + openProject(target.worktree) + } + function navigateSessionByUnseen(offset: number) { const sessions = currentSessions() if (sessions.length === 0) return @@ -1002,6 +1036,20 @@ 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"), @@ -1104,10 +1152,10 @@ export default function Layout(props: ParentProps) { }, ] - for (const [id, definition] of availableThemeEntries()) { + for (const [id] of availableThemeEntries()) { commands.push({ id: `theme.set.${id}`, - title: language.t("command.theme.set", { theme: definition.name ?? id }), + title: language.t("command.theme.set", { theme: theme.name(id) }), category: language.t("command.category.theme"), onSelect: () => theme.commitPreview(), onHighlight: () => { @@ -1158,15 +1206,27 @@ export default function Layout(props: ParentProps) { }) function connectProvider() { - dialog.show(() => ) + const run = ++dialogRun + void import("@/components/dialog-select-provider").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) } function openServer() { - dialog.show(() => ) + const run = ++dialogRun + void import("@/components/dialog-select-server").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) } function openSettings() { - dialog.show(() => ) + const run = ++dialogRun + void import("@/components/dialog-settings").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) } function projectRoot(directory: string) { @@ -1393,7 +1453,13 @@ export default function Layout(props: ParentProps) { layout.sidebar.toggleWorkspaces(project.worktree) } - const showEditProjectDialog = (project: LocalProject) => dialog.show(() => ) + const showEditProjectDialog = (project: LocalProject) => { + const run = ++dialogRun + void import("@/components/dialog-edit-project").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) + } async function chooseProject() { function resolve(result: string | string[] | null) { @@ -1414,10 +1480,14 @@ export default function Layout(props: ParentProps) { }) resolve(result) } else { - dialog.show( - () => , - () => resolve(null), - ) + const run = ++dialogRun + void import("@/components/dialog-select-directory").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show( + () => , + () => resolve(null), + ) + }) } } @@ -1750,6 +1820,9 @@ 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() createEffect( @@ -1941,6 +2014,10 @@ 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, @@ -2022,7 +2099,7 @@ export default function Layout(props: ParentProps) { "max-w-full overflow-hidden": panelProps.mobile, }} style={{ - width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`, + width: panelProps.mobile ? undefined : `${panel()}px`, }} > @@ -2312,7 +2391,7 @@ export default function Layout(props: ParentProps) { "absolute inset-y-0 left-0": true, "z-10": true, }} - style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }} + style={{ width: `${side()}px` }} ref={(el) => { setState("nav", el) }} @@ -2327,26 +2406,29 @@ export default function Layout(props: ParentProps) { }} >
{sidebarContent()}
- -
setState("sizing", true)}> - { - setState("sizing", true) - if (sizet !== undefined) clearTimeout(sizet) - sizet = window.setTimeout(() => setState("sizing", false), 120) - layout.sidebar.resize(w) - }} - onCollapse={layout.sidebar.close} - /> -
-
+ + + +