diff --git a/.github/TEAM_MEMBERS b/.github/TEAM_MEMBERS index ee2e26f452..5eb465e0dd 100644 --- a/.github/TEAM_MEMBERS +++ b/.github/TEAM_MEMBERS @@ -18,4 +18,4 @@ simonklee Slickstef11 usrnk1 vimtor -StarpTech +starptech diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1c41a66faa..037020c03a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -219,6 +219,7 @@ jobs: build-electron: needs: + - build-cli - version if: github.repository == 'anomalyco/opencode' continue-on-error: false @@ -315,7 +316,10 @@ jobs: env: OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} + OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }} RUST_TARGET: ${{ matrix.settings.target }} + GH_TOKEN: ${{ github.token }} + GITHUB_RUN_ID: ${{ github.run_id }} - name: Build run: bun run build @@ -339,7 +343,8 @@ jobs: env: OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} GH_TOKEN: ${{ steps.committer.outputs.token }} - CSC_KEYCHAIN: build.keychain + CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }} + CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8 APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} diff --git a/.opencode/command/translate.md b/.opencode/command/translate.md index de18ae2ee8..8d493f4a81 100644 --- a/.opencode/command/translate.md +++ b/.opencode/command/translate.md @@ -1,6 +1,6 @@ --- description: translate English to other languages -model: opencode/gpt-5.6-sol +model: opencode/claude-opus-4-8 --- run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time. diff --git a/.opencode/tool/github-triage.ts b/.opencode/tool/github-triage.ts index e861e1e467..f25ca48b38 100644 --- a/.opencode/tool/github-triage.ts +++ b/.opencode/tool/github-triage.ts @@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin" const TEAM = { tui: ["kommander", "simonklee"], desktop_web: ["Hona", "Brendonovich"], - core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"], + core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"], inference: ["fwang", "MrMushrooooom", "starptech"], windows: ["Hona"], } as const diff --git a/bun.lock b/bun.lock index e905ada8c5..2f31450ed0 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -37,7 +37,6 @@ "@dnd-kit/helpers": "0.5.0", "@dnd-kit/solid": "0.5.0", "@kobalte/core": "catalog:", - "@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", @@ -66,7 +65,7 @@ "diff": "catalog:", "effect": "catalog:", "fuzzysort": "catalog:", - "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", + "ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553", "luxon": "catalog:", "marked": "catalog:", "marked-shiki": "catalog:", @@ -96,7 +95,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.18.11", + "version": "1.17.18", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -144,7 +143,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -158,7 +157,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -194,7 +193,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -221,7 +220,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -243,7 +242,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -267,7 +266,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -287,7 +286,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.18.11", + "version": "1.17.18", "bin": { "opencode": "./bin/opencode", }, @@ -295,7 +294,7 @@ "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.88", + "@ai-sdk/azure": "3.0.49", "@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", @@ -303,8 +302,8 @@ "@ai-sdk/google": "3.0.73", "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", - "@ai-sdk/mistral": "3.0.51", - "@ai-sdk/openai": "3.0.84", + "@ai-sdk/mistral": "3.0.27", + "@ai-sdk/openai": "3.0.53", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", @@ -339,7 +338,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.12.1", + "gitlab-ai-provider": "6.10.0", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -381,16 +380,16 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "@zip.js/zip.js": "2.7.62", - "drizzle-orm": "catalog:", "effect": "catalog:", "electron-context-menu": "4.1.2", "electron-log": "^5", "electron-store": "11.0.2", "electron-updater": "6.8.9", "electron-window-state": "^5.0.3", + "marked": "^15", }, "devDependencies": { "@actions/artifact": "4.0.0", @@ -435,7 +434,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -449,7 +448,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "effect": "catalog:", }, @@ -461,7 +460,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -493,7 +492,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -509,7 +508,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -540,7 +539,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -559,7 +558,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.18.11", + "version": "1.17.18", "bin": { "opencode": "./bin/opencode", }, @@ -570,7 +569,7 @@ "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.88", + "@ai-sdk/azure": "3.0.49", "@ai-sdk/cerebras": "2.0.60", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", @@ -578,8 +577,8 @@ "@ai-sdk/google": "3.0.73", "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", - "@ai-sdk/mistral": "3.0.51", - "@ai-sdk/openai": "3.0.84", + "@ai-sdk/mistral": "3.0.27", + "@ai-sdk/openai": "3.0.53", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", @@ -632,7 +631,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.12.1", + "gitlab-ai-provider": "6.10.0", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -690,7 +689,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -707,9 +706,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.4.5", - "@opentui/keymap": ">=0.4.5", - "@opentui/solid": ">=0.4.5", + "@opentui/core": ">=0.4.3", + "@opentui/keymap": ">=0.4.3", + "@opentui/solid": ">=0.4.3", }, "optionalPeers": [ "@opentui/core", @@ -766,7 +765,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "cross-spawn": "catalog:", }, @@ -781,7 +780,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -796,10 +795,9 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "@kobalte/core": "catalog:", - "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -811,6 +809,7 @@ "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", "@solidjs/meta": "catalog:", + "@solidjs/router": "catalog:", "diff": "catalog:", "dompurify": "3.3.1", "fuzzysort": "catalog:", @@ -840,7 +839,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -853,10 +852,9 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "@ibm/plex": "6.4.1", - "@kobalte/core": "catalog:", "@opencode-ai/stats-core": "workspace:*", "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -887,7 +885,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -906,7 +904,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -938,7 +936,6 @@ "@types/node": "catalog:", "@types/react": "18.0.25", "react": "18.2.0", - "react-dom": "18.2.0", "solid-js": "catalog:", "storybook": "^10.2.13", "storybook-solidjs-vite": "^10.0.9", @@ -948,7 +945,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -975,7 +972,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1001,7 +998,6 @@ "remend": "catalog:", "shiki": "catalog:", "solid-list": "catalog:", - "solid-sonner": "catalog:", "strip-ansi": "7.1.2", }, "devDependencies": { @@ -1027,7 +1023,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.18.11", + "version": "1.17.18", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -1070,19 +1066,18 @@ ], "patchedDependencies": { "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", - "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", - "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", - "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", + "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", - "@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch", }, "overrides": { "@opentui/core": "catalog:", @@ -1104,9 +1099,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.4.5", - "@opentui/keymap": "0.4.5", - "@opentui/solid": "0.4.5", + "@opentui/core": "0.4.3", + "@opentui/keymap": "0.4.3", + "@opentui/solid": "0.4.3", "@pierre/diffs": "1.2.10", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1117,7 +1112,7 @@ "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "@tailwindcss/vite": "4.1.11", - "@tanstack/solid-virtual": "3.13.32", + "@tanstack/solid-virtual": "3.13.28", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", "@types/bun": "1.3.13", @@ -1137,7 +1132,7 @@ "hono": "4.10.7", "hono-openapi": "1.1.2", "luxon": "3.6.1", - "marked": "17.0.6", + "marked": "17.0.1", "marked-shiki": "1.2.1", "opentui-spinner": "0.0.7", "remeda": "2.26.0", @@ -1146,7 +1141,6 @@ "shiki": "4.2.0", "solid-js": "1.9.10", "solid-list": "0.3.0", - "solid-sonner": "0.3.1", "sst": "4.13.1", "tailwindcss": "4.1.11", "typescript": "5.8.2", @@ -1180,7 +1174,7 @@ "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="], - "@ai-sdk/azure": ["@ai-sdk/azure@3.0.88", "", { "dependencies": { "@ai-sdk/deepseek": "2.0.47", "@ai-sdk/openai": "3.0.84", "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RRjZkB1lYplh8dpBarnvkl1j7sYLHsyXua7erL3oNcMK7fHcl4bPO5C7iQhD1O/DqD/zCceDifnege1s+8yEvw=="], + "@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="], "@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="], @@ -1190,7 +1184,7 @@ "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="], - "@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MzcQ321JO8OY+TVLFI81A7cIIuoeLLxrLCDD+8C1E3Ro6UFyfMtRXo9bw9OhTMRSDMo6hgSDOo4Fekz8aJtQYQ=="], + "@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="], "@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EtvsWfGrqx3OhzJdoi82qH+4yzEPPKZr2utyQ+w8cHKoFeg0+8Lou9Z3uixy73WEwz8Z1+AR8QT9fZ64AWGYPA=="], @@ -1204,7 +1198,7 @@ "@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="], - "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="], + "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="], "@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="], @@ -2044,27 +2038,27 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="], + "@opentui/core": ["@opentui/core@0.4.3", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.3", "@opentui/core-darwin-x64": "0.4.3", "@opentui/core-linux-arm64": "0.4.3", "@opentui/core-linux-arm64-musl": "0.4.3", "@opentui/core-linux-x64": "0.4.3", "@opentui/core-linux-x64-musl": "0.4.3", "@opentui/core-win32-arm64": "0.4.3", "@opentui/core-win32-x64": "0.4.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-rrJfAk13tALDqldYjhc78eWQ+aKq1iknJgffIOg3OwyZoqQo+p6gtuqyhmWvXIfQzlNUbpgpCPcxbXlhMnlaHQ=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p5+7AAxpxGuDGagyQfewKtmTFnN7THvTVY4FyKqUtJomNaHdQXPHztapNNzMx0DGWbwOUbVKzpL+yc3CZY3chQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-+fh0vEUE0lwVC7RW5ijYLRlTLp5NfvCRj8SzxDVd7IL2j2ssB6YXcfIbXq2EW7UGnrejwPRXf1tgUrIXW9KmOw=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-gl6qA5QJy6u8Cbt7gOtHbhhfMZ4qQDb0kEwFXHcMGmbnKzz4OHoq74D6tNjyvSQB9saoC7C6C0tvn2DcJOuNog=="], - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-8p8g8/AEq/xFGpQ7XcIFKcAqjc0QwsZcv+Ll9RbCDpUA56FGH6jfLDir0KYTNTgYXJTIrBIENI9K46VuxMUMQA=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-dXpJitiZdYE3hq2Pvx6e9I0uPQSOcnaLLp1pDgWAHv+3kvKSHEX//9Yr/pV/Ua6qqT7p+2D/K4vXNap/NKVo2w=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/QiFpCrpU2O7vy8QYmLIQYbvAtKDgmqcVjR7dGtqSzkiQk3ktNJoo5RozG7ueXnjung1Wp0nKldKxo2Csg/OrA=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-Mx2zuOjrhm/z2SDS6RExIyjP/SnN/8QhhagxURUw0jQi/NssGSeAllu1cBAFFnhobJL5QLTE4FU4CRhUK9svgg=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.3", "", { "os": "win32", "cpu": "x64" }, "sha512-NuoqvWKGXaYnmlqvu7Gg2lLI6yVMnS9OfWBvxp+7Q+McSgHFSTQmYBXaPpvQ8HikpQXE1nCeMPtuSG4PdZHe2w=="], - "@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="], + "@opentui/keymap": ["@opentui/keymap@0.4.3", "", { "dependencies": { "@opentui/core": "0.4.3" }, "peerDependencies": { "@opentui/react": "0.4.3", "@opentui/solid": "0.4.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-sinX0pyQBRrEvo89PSSUbSUDIYpL3xWo81VEfec58VFoVRB5FG48/deAtvRTQfJ8w1kgbzN8hzdOXdSm61zBmw=="], - "@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="], + "@opentui/solid": ["@opentui/solid@0.4.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-RcV0+S8HMdXOASyr7HmJUBuTUIaFPzAxMDa44VftS5C2JUgrmAuWo0Njv1q3TWRB1owjHnyKhEfWGKq7A82wxw=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -2786,9 +2780,9 @@ "@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=="], - "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.32", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-yhX4A4Kgn+wyTg6Mmu8+zwoMTwjz4K1ucvLfRJ8f0rPGDDAIqSaf0v6oU0yT9+SvrjmUaZQ0VX7g4byexbhNng=="], + "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.28", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-kRuOEL5orH/rzGgxNgfgOttsgV6cgrUeupVtrHMITb5p0rZ3hnxhbu/lhKcR9+7x+EJdfUtJIb2CVC85mlw15g=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="], "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], @@ -3850,13 +3844,13 @@ "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], - "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#83c0a07", {}, "anomalyco-ghostty-web-83c0a07", "sha512-Lf2v1agHkVUpMpHBWWuCZrhOEmcwwin5/Hboc9rZwQ7/CKkIh5rU1r1CvfLlhkMoFv+ed8z52RZ8hkzGZZj3MQ=="], + "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#513463a", {}, "anomalyco-ghostty-web-513463a", "sha512-GZR8LSmgGzViWnBJrqRI8MpAZRCJxhcr1Hi9Tyeh7YRooHZQjK9J97FQRD3tbBaM2wjq05gzGY2UEsG+JtZeBw=="], "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "gitlab-ai-provider": ["gitlab-ai-provider@6.12.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Qn5iHqvjG8yktI5MWaUgdRR94l7O4WtYW0CAbhsCh1Tj0Fei/DeprOYPVyf4Nht1Ix6U2PXSYM32QOHI6Z2TDw=="], + "gitlab-ai-provider": ["gitlab-ai-provider@6.10.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-oWEZ06rDO6JjB7INHO882wyBAQqCZVHiDHwCs5M+VPmdDj8TzhGXcYesA2CcV5RoI5lfHLKwGp5uKFB62VWpqw=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], @@ -4318,7 +4312,7 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], "marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="], @@ -5128,8 +5122,6 @@ "solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="], - "solid-sonner": ["solid-sonner@0.3.1", "", { "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-F/+zi9yKJTHh5hX1UGJfkDvyC+F34Vi3jgy44NJwOKCgic1QtAon0b1iT9OsDO77RTgR+PCil+3Y5B8T2Owy1Q=="], - "solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="], "solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="], @@ -5662,11 +5654,7 @@ "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], - "@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], - - "@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - - "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], + "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], @@ -5678,9 +5666,9 @@ "@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], - "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], + "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], "@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], @@ -5706,9 +5694,7 @@ "@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - - "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="], + "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], @@ -6034,9 +6020,7 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], - "@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="], - - "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], + "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], "@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], @@ -6044,6 +6028,8 @@ "@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="], + "@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], "@opencode-ai/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], @@ -6052,8 +6038,6 @@ "@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="], - "@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], "@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], @@ -6062,8 +6046,6 @@ "@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], - "@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], - "@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=="], "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], @@ -6180,12 +6162,6 @@ "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="], - "ai-gateway-provider/@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="], - - "ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="], - - "ai-gateway-provider/@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="], - "ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], "ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="], @@ -6412,7 +6388,7 @@ "opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="], - "opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], + "opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], @@ -6920,10 +6896,6 @@ "@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], - "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - - "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], - "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], "@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], @@ -6994,16 +6966,6 @@ "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], - "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="], - - "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - - "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], - - "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], - - "ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], @@ -7156,10 +7118,6 @@ "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="], - "opencode/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - - "opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], - "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], @@ -7408,8 +7366,6 @@ "@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], - "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], @@ -7426,12 +7382,6 @@ "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], @@ -7502,8 +7452,6 @@ "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "opencode/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], diff --git a/infra/console.ts b/infra/console.ts index 79556f5e0c..5fbf35de00 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -288,12 +288,8 @@ new sst.cloudflare.x.SolidStart("Console", { server: { placement: { region: "aws:us-east-2" }, transform: { - worker: (args) => { - args.compatibilityFlags = $resolve(args.compatibilityFlags).apply((flags) => [ - ...(flags ?? []), - "global_fetch_strictly_public", - ]) - args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }] + worker: { + tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }], }, }, }, diff --git a/infra/stage.ts b/infra/stage.ts index f0db797448..8d80eefed8 100644 --- a/infra/stage.ts +++ b/infra/stage.ts @@ -8,25 +8,6 @@ export const zoneID = "430ba34c138cfb5360826c4909f99be8" export const awsStage = $app.stage === "production" ? "production" : "dev" export const deployAws = $app.stage === awsStage -if ($app.stage === "production") { - new cloudflare.DnsRecord("TrustCenter", { - zoneId: zoneID, - name: "trust.opencode.ai", - type: "CNAME", - content: "3a69a5bb27875189.vercel-dns-016.com", - proxied: false, - ttl: 60, - }) - - new cloudflare.DnsRecord("TrustCenterVerification", { - zoneId: zoneID, - name: "opencode.ai", - type: "TXT", - content: "compai-domain-verification=org_6993a99c6200a2d642bb115d", - ttl: 60, - }) -} - new cloudflare.RegionalHostname("RegionalHostname", { hostname: domain, regionKey: "us", diff --git a/nix/desktop.nix b/nix/desktop.nix index 2df62f7a1c..d0d7fa7eca 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -8,8 +8,6 @@ makeWrapper, writableTmpDirAsHomeHook, autoPatchelfHook, - copyDesktopItems, - makeDesktopItem, opencode, }: let @@ -29,12 +27,9 @@ stdenv.mkDerivation (finalAttrs: { nodejs makeWrapper writableTmpDirAsHomeHook - ] - ++ lib.optionals stdenv.hostPlatform.isLinux [ + ] ++ lib.optionals stdenv.hostPlatform.isLinux [ autoPatchelfHook - copyDesktopItems - ] - ++ lib.optionals stdenv.hostPlatform.isDarwin [ + ] ++ lib.optionals stdenv.hostPlatform.isDarwin [ # Ad-hoc sign the .app: --config.mac.identity=null below skips signing. darwin.autoSignDarwinBinariesHook ]; @@ -43,37 +38,20 @@ stdenv.mkDerivation (finalAttrs: { (lib.getLib stdenv.cc.cc) ]; - desktopItems = lib.optional stdenv.hostPlatform.isLinux (makeDesktopItem { - name = "ai.opencode.desktop"; - desktopName = "OpenCode"; - exec = "opencode-desktop %U"; - icon = "ai.opencode.desktop"; - # Electron 41 derives X11 WM_CLASS from app.name. - startupWMClass = "OpenCode"; - categories = [ "Development" ]; - }); - env = opencode.env // { ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; }; - postPatch = - # NOTE: Relax Bun version check to be a warning instead of an error - '' - substituteInPlace packages/script/src/index.ts \ - --replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \ - 'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}' - '' - # https://github.com/electron/electron/issues/31121 - # mac builds use a .app bundle which doesnt have this issue - + lib.optionalString stdenv.isLinux '' - BASE_PATH=packages/desktop - FILES=(src/main/windows.ts) - for file in "''${FILES[@]}"; do - substituteInPlace $BASE_PATH/$file \ - --replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'" - done - ''; + # https://github.com/electron/electron/issues/31121 + # mac builds use a .app bundle which doesnt have this issue + postPatch = lib.optionalString stdenv.isLinux '' + BASE_PATH=packages/desktop + FILES=(src/main/windows.ts) + for file in "''${FILES[@]}"; do + substituteInPlace $BASE_PATH/$file \ + --replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'" + done + ''; preBuild = '' cp -r "${electron.dist}" $HOME/.electron-dist @@ -98,38 +76,27 @@ stdenv.mkDerivation (finalAttrs: { runHook postBuild ''; - installPhase = '' - runHook preInstall - '' - + lib.optionalString stdenv.hostPlatform.isDarwin '' - mkdir -p $out/Applications - mv dist/mac*/*.app $out/Applications - makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop - '' - + lib.optionalString stdenv.hostPlatform.isLinux '' - mkdir -p $out/opt/opencode-desktop - cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop - install -Dm644 resources/icons/32x32.png \ - "$out/share/icons/hicolor/32x32/apps/ai.opencode.desktop.png" - install -Dm644 resources/icons/64x64.png \ - "$out/share/icons/hicolor/64x64/apps/ai.opencode.desktop.png" - install -Dm644 resources/icons/128x128.png \ - "$out/share/icons/hicolor/128x128/apps/ai.opencode.desktop.png" - install -Dm644 resources/icons/128x128@2x.png \ - "$out/share/icons/hicolor/256x256/apps/ai.opencode.desktop.png" - install -Dm644 resources/icons/icon.png \ - "$out/share/icons/hicolor/512x512/apps/ai.opencode.desktop.png" - install -Dm644 resources/ai.opencode.desktop.metainfo.xml \ - "$out/share/metainfo/ai.opencode.desktop.metainfo.xml" - makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \ - --inherit-argv0 \ - --set ELECTRON_FORCE_IS_PACKAGED 1 \ - --add-flags $out/opt/opencode-desktop/resources/app.asar \ - --add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}" - '' - + '' - runHook postInstall - ''; + installPhase = + '' + runHook preInstall + '' + + lib.optionalString stdenv.hostPlatform.isDarwin '' + mkdir -p $out/Applications + mv dist/mac*/*.app $out/Applications + makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop + '' + + lib.optionalString stdenv.hostPlatform.isLinux '' + mkdir -p $out/opt/opencode-desktop + cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop + makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \ + --inherit-argv0 \ + --set ELECTRON_FORCE_IS_PACKAGED 1 \ + --add-flags $out/opt/opencode-desktop/resources/app.asar \ + --add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}" + '' + + '' + runHook postInstall + ''; autoPatchelfIgnoreMissingDeps = [ "libc.musl-x86_64.so.1" diff --git a/nix/hashes.json b/nix/hashes.json index 25f0e76812..50208fdbbe 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-a7NyYa9vRUEqDfZNDPXXmFO58RDEgioyuGSl5CPBvxo=", - "aarch64-linux": "sha256-l4OJtSEllHvRhktjcaJYwkXBSaJvsIoyoLusbZfYMcM=", - "aarch64-darwin": "sha256-IIl0BQGs1/HLFh0auiQjiwfSQ2nfHcK2G2BAphYW59c=", - "x86_64-darwin": "sha256-vVeuPyd4ZIRYrHouphTuEb4rkRZLKKTAHe840jNh9rU=" + "x86_64-linux": "sha256-JTtn+wXTXg+yklvIMDLcGFaYhTU6ZrCgKT9JTNEQ3gA=", + "aarch64-linux": "sha256-gXU6zyhvAZrZirkL/PlHdkHtEof/7PVSPCaE34Jnd4U=", + "aarch64-darwin": "sha256-Q0oTG3uzOlD/X2kJingLle529lKFoTpyCW2rHXOZ6iE=", + "x86_64-darwin": "sha256-LINvKHxPibTlJeNzfACQx0x+Yj5oROT6Du3I5AtqqXk=" } } diff --git a/package.json b/package.json index 2a8eef6356..4fa3399264 100644 --- a/package.json +++ b/package.json @@ -40,10 +40,10 @@ "@octokit/rest": "22.0.0", "@hono/standard-validator": "0.2.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.4.5", - "@opentui/keymap": "0.4.5", - "@opentui/solid": "0.4.5", - "@tanstack/solid-virtual": "3.13.32", + "@opentui/core": "0.4.3", + "@opentui/keymap": "0.4.3", + "@opentui/solid": "0.4.3", + "@tanstack/solid-virtual": "3.13.28", "@shikijs/stream": "4.2.0", "ulid": "3.0.1", "@kobalte/core": "0.13.11", @@ -70,7 +70,7 @@ "hono-openapi": "1.1.2", "fuzzysort": "3.1.0", "luxon": "3.6.1", - "marked": "17.0.6", + "marked": "17.0.1", "marked-shiki": "1.2.1", "remend": "1.3.0", "@playwright/test": "1.59.1", @@ -90,7 +90,6 @@ "@sentry/solid": "10.36.0", "@sentry/vite-plugin": "4.6.0", "solid-js": "1.9.10", - "solid-sonner": "0.3.1", "vite-plugin-solid": "2.11.10", "@lydell/node-pty": "1.2.0-beta.12" } @@ -144,20 +143,19 @@ "@types/node": "catalog:" }, "patchedDependencies": { - "@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch", "@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", - "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", - "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", - "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch" + "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", + "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch" } } diff --git a/packages/app/V1_API_MIGRATION.md b/packages/app/V1_API_MIGRATION.md deleted file mode 100644 index 2850f10740..0000000000 --- a/packages/app/V1_API_MIGRATION.md +++ /dev/null @@ -1,220 +0,0 @@ -# V1 API Migration Checklist - -The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name. - -## Events - -- [x] Replace `GET /global/event` with `GET /api/event`. - - `src/context/server-sdk.tsx` -- [x] Reduce current granular session and message events into the existing app projections. - - `src/context/server-session-v2-reducer.ts` - - `src/context/server-session.ts` -- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`. - - `src/context/global-sync/event-reducer.ts` - - `src/context/server-session.ts` - - `src/context/notification.tsx` - - `src/pages/session/usage-exceeded-dialogs.tsx` -- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`. - - `src/context/global-sync/event-reducer.ts` - - `src/context/server-session.ts` -- [x] Adapt current permission and question events to the existing request model. - - `src/context/global-sync/event-reducer.ts` - - `src/context/permission.tsx` -- [x] Consume current file watcher events. - - `src/context/file.tsx` -- [x] Consume current VCS events. - - `src/context/global-sync/event-reducer.ts` - - `src/pages/session.tsx` -- [x] Consume current `pty.exited` events. - - `src/context/terminal.tsx` -- [ ] Migrate LSP and reference events. - - `src/context/global-sync/event-reducer.ts` - -## Sessions - -- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events. - - `src/context/server-sync.tsx` -- [x] Migrate session listing from `GET /session`. - - `src/context/server-sync.tsx` - - `src/context/directory-sync.ts` - - `src/pages/layout.tsx` -- [x] Migrate the remaining direct session read from `GET /session/:sessionID`. - - `src/components/titlebar.tsx` -- [x] Migrate session updates from `PATCH /session/:sessionID`. - - `src/context/directory-sync.ts` - - `src/context/layout.tsx` - - `src/pages/home.tsx` - - `src/pages/layout.tsx` - - `src/pages/session/timeline/message-timeline.tsx` - - `src/components/titlebar-tab-nav.tsx` - - Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`. -- [x] Migrate session deletion from `DELETE /session/:sessionID`. - - `src/pages/session/timeline/message-timeline.tsx` -- [x] Remove session diff loading from `GET /session/:sessionID/diff`. - - Historical Session diffs remain unavailable until the current API defines their snapshot semantics. -- [x] Migrate abort from `POST /session/:sessionID/abort`. - - `src/components/prompt-input/submit.ts` - - `src/pages/session/use-session-commands.tsx` - - `src/pages/session.tsx` -- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`. - - `src/pages/session/use-session-commands.tsx` - - `src/pages/session.tsx` -- [x] Replace `POST /session/:sessionID/summarize` with the current compact API. - - `src/pages/session/use-session-commands.tsx` -- [x] Migrate slash commands from `POST /session/:sessionID/command`. - - `src/components/prompt-input/submit.ts` -- [x] Migrate shell execution from `POST /session/:sessionID/shell`. - - `src/components/prompt-input/submit.ts` -- [x] Migrate session fork from `POST /session/:sessionID/fork`. - - `src/components/dialog-fork.tsx` -- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`. - - `src/pages/session/use-session-commands.tsx` - - `src/pages/session/timeline/message-timeline.tsx` - - Blocked: the current API has no sharing contract or implementation. - -## Session Compatibility Fallbacks - -These calls are retained as fallback adapters. The current production path supplies the current session and message APIs. - -- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary. - - `src/context/server-session.ts` -- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary. - - `src/context/server-session.ts` -- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary. - - `src/context/server-session.ts` - -## Filesystem - -- [ ] Migrate file listing from `GET /file`. - - `src/context/file.tsx` -- [ ] Migrate file reads from `GET /file/content`. - - `src/context/file.tsx` - - `src/pages/session/review-tab.tsx` - - `src/pages/session/v2/review-panel-v2.tsx` -- [x] Migrate path discovery from `GET /path` to `GET /api/path`. - - `src/context/global-sync/bootstrap.ts` - - `src/components/dialog-select-directory.tsx` - - `src/components/dialog-select-directory-v2.tsx` - -## Projects And Worktrees - -- [x] Migrate project listing from `GET /project` to `GET /api/project`. - - `src/context/global-sync/bootstrap.ts` -- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`. - - `src/context/global-sync/bootstrap.ts` -- [ ] Migrate Git initialization from `POST /project/git/init`. - - `src/pages/session.tsx` -- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`. - - `src/context/layout.tsx` - - `src/components/edit-project.ts` - - `src/pages/layout.tsx` -- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`. - - `src/pages/layout.tsx` - - `src/components/prompt-input/submit.ts` - - Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain. -- [ ] Migrate instance disposal from `POST /instance/dispose`. - - `src/pages/layout.tsx` - -## VCS - -- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`. - - `src/context/global-sync/bootstrap.ts` -- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`. - - `src/pages/session.tsx` -- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`. - - `src/pages/layout.tsx` - -## Configuration And Authentication - -- [ ] Migrate global configuration reads from `GET /global/config`. - - `src/context/global-sync/bootstrap.ts` -- [ ] Migrate directory configuration reads from `GET /config`. - - `src/context/global-sync/bootstrap.ts` -- [ ] Migrate global configuration updates from `PATCH /global/config`. - - `src/context/server-sync.tsx` -- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`. - - `src/components/dialog-connect-provider.tsx` -- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`. - - `src/components/dialog-connect-provider.tsx` -- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`. - - Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`. - - `src/components/dialog-connect-provider.tsx` - - `src/components/dialog-custom-provider.tsx` - - `src/components/settings-providers.tsx` - - `src/components/settings-v2/providers.tsx` -- [ ] Migrate global disposal from `POST /global/dispose`. - - `src/components/dialog-connect-provider.tsx` - - `src/components/settings-providers.tsx` - - `src/components/settings-v2/providers.tsx` - -## Permissions And Questions - -- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`. - - `src/context/global-sync/bootstrap.ts` - - `src/context/permission.tsx` -- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`. - - `src/context/permission.tsx` - - `src/pages/session/composer/session-composer-state.ts` -- [x] Migrate question listing from `GET /question` to `GET /api/question/request`. - - `src/context/global-sync/bootstrap.ts` -- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`. - - `src/pages/session/composer/session-question-dock.tsx` - -## Commands, MCP, LSP, And References - -- [x] Migrate command listing from `GET /command` to `GET /api/command`. - - `src/context/global-sync/bootstrap.ts` - - `src/context/server-sync.tsx` -- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`. - - `src/context/server-sync.tsx` -- [ ] Replace legacy MCP authentication with the Integration OAuth workflow. - - `src/context/server-sync.tsx` -- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`. - - `src/context/server-sync.tsx` -- [ ] Migrate LSP status from `GET /lsp`. - - `src/context/server-sync.tsx` -- [x] Move `GET /api/reference` off the legacy generated SDK transport. - - `src/context/global-sync/bootstrap.ts` - -## Search - -- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`. - - `src/components/command-palette.ts` - - `src/components/dialog-command-palette-v2.tsx` - -## PTY And Terminal - -- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`. - - `src/context/terminal.tsx` - - `src/components/terminal.tsx` -- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`. - - `src/components/settings-general.tsx` - - `src/components/settings-v2/general.tsx` -- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`. - - `src/components/terminal.tsx` -- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`. - - `src/components/terminal.tsx` - -## Legacy Types And Adapters - -These are not V1 network requests, but they keep the UI coupled to V1 data contracts. - -- [ ] Replace the current-session-to-legacy-session adapter. - - `src/utils/session.ts` -- [ ] Replace the current-message-to-legacy-message-and-part adapter. - - `src/utils/session-message.ts` -- [ ] Replace current agent, provider, and model adapters to legacy SDK structures. - - `src/context/global-sync/utils.ts` -- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering. -- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone. - - `package.json` - -## Test Infrastructure - -- [ ] Replace V1 endpoint mocks with current API mocks. - - `e2e/utils/mock-server.ts` -- [x] Replace `/global/event` and `/event` interception with current event transport handling. - - `e2e/utils/sse-transport.ts` -- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests. - - `e2e/performance/timeline-stability/fixture.ts` -- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests. diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index df67da5a66..e63445e87d 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -97,7 +97,6 @@ export async function setupTimeline( locale?: string deviceScaleFactor?: number seedHistory?: boolean - protocol?: "v1" | "v2" } = {}, ) { const sessions = input.sessions ?? [session()] @@ -115,7 +114,6 @@ export async function setupTimeline( retry: input.eventRetry ?? 20, }) await mockOpenCodeServer(page, { - protocol: input.protocol, directory, project: project(), provider: provider(), @@ -138,9 +136,6 @@ export async function setupTimeline( }, }), ) - if (settings.newLayoutDesigns === false) { - localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" })) - } }, input.settings ?? {}) if (input.locale) { await page.addInitScript((locale) => { diff --git a/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts b/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts index 838af17c93..2a214831da 100644 --- a/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts +++ b/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts @@ -41,12 +41,7 @@ const assistants = Array.from({ length: 14 }, (_, index) => { const messages = [user, ...assistants] const target = fixture.sessions.find((session) => session.id === fixture.targetID)! const lastID = userID -const lastAssistant = assistants.at(-1)! -const lastPart = lastAssistant.parts.at(-1)! -const lastPartID = - lastPart.type === "tool" - ? lastPart.id - : `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}` +const lastPartID = assistants.at(-1)!.parts.at(-1)!.id benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => { benchmark.setTimeout(180_000) @@ -112,25 +107,9 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) { return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined } }, }) - await page.route(`**/session/${fixture.targetID}`, (route) => { - const current = new URL(route.request().url()).pathname.startsWith("/api/") - return route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify( - current - ? { - data: { - ...target, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - location: { directory: target.directory }, - }, - } - : target, - ), - }) - }) + await page.route(`**/session/${fixture.targetID}`, (route) => + route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }), + ) await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] }) await page.goto(stressSessionHref(fixture.sourceID)) await expectSessionTitle(page, fixture.expected.sourceTitle) @@ -165,8 +144,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) { parent: requests.filter((request) => request.type === "parent").length, } if (mode === "candidate") { - expect(requestCounts.parent).toBe(0) - expect(historyGates).toBe(0) + expect(requestCounts.parent).toBe(1) + expect(historyGates).toBe(1) } return { metrics, requestCounts, historyGateCount: historyGates } } diff --git a/packages/app/e2e/regression/cross-server-tab-close.spec.ts b/packages/app/e2e/regression/cross-server-tab-close.spec.ts index f09a2c7b63..159b5a5067 100644 --- a/packages/app/e2e/regression/cross-server-tab-close.spec.ts +++ b/packages/app/e2e/regression/cross-server-tab-close.spec.ts @@ -1,6 +1,5 @@ import { expect, test, type Page, type Route } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" -import { currentSession } from "../utils/mock-server" const serverA = "http://127.0.0.1:4096" const serverB = "http://127.0.0.1:4097" @@ -34,7 +33,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn await tabA.locator('[data-slot="tab-close"] button').click() await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) - await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true) + await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true) await expect(page.getByText(sessionB.title).first()).toBeVisible() const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`)) expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true) @@ -85,21 +84,17 @@ async function mockServers(page: Page, requests: string[]) { const current = url.origin === serverA ? sessionA : sessionB const directory = url.searchParams.get("directory") if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) - if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") - return sse(route) - if (url.pathname === "/global/health") return json(route, {}, 404) - if (url.pathname === "/api/health") return json(route, { pid: 1 }) - if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) - if (url.pathname === "/api/session/active") return json(route, { data: {} }) - if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) }) - if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} }) + if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) + if (url.pathname === "/global/health") return json(route, { healthy: true }) + if (url.pathname === "/session") return json(route, [current]) if (url.pathname === `/session/${current.id}`) return json(route, current) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (url.pathname === `/session/${current.id}/message`) return json(route, []) if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) return json(route, []) - if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) + if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname)) + return json(route, {}) if (url.pathname === "/provider") return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) @@ -121,20 +116,7 @@ async function mockServers(page: Page, requests: string[]) { directory: current.directory, home: current.directory, }) - if (url.pathname === "/api/path") - return json(route, { - state: current.directory, - config: current.directory, - worktree: current.directory, - directory: current.directory, - home: current.directory, - }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) - if (url.pathname === "/api/vcs") - return json(route, { - location: { directory: current.directory }, - data: { branch: "main", defaultBranch: "main" }, - }) return json(route, {}) }) } diff --git a/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts b/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts deleted file mode 100644 index 136232c211..0000000000 --- a/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "@playwright/test" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectSessionTitle } from "../utils/waits" - -const directory = "C:/OpenCode/FileBrowserSidebar" -const projectID = "proj_file_browser_sidebar" -const sessionID = "ses_file_browser_sidebar" -const title = "File browser sidebar" -const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` -const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`) -// Marks the file-browser sidebar DOM node so a remount (fresh node) is detectable. -const PROBE = "original" - -test.use({ viewport: { width: 1440, height: 900 } }) - -// The file-browser sidebar must stay mounted across preview/pinned file-tab -// switches. Remounting resets scroll and filter state. -test("keeps the file-browser sidebar mounted when switching file tabs", async ({ page }) => { - await setup(page) - - await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) - await expectSessionTitle(page, title) - - const panel = page.locator("#review-panel") - await panel.getByRole("button", { name: "Open file" }).click() - await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") - - const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]') - await expect(sidebar).toBeVisible() - await expect(panel.getByRole("button", { name: "file-00.ts" })).toBeVisible() - - await panel.getByRole("button", { name: "file-00.ts" }).click() - await expect(panel.getByRole("tab", { name: "file-00.ts" })).toHaveAttribute("data-selected", "") - await expect(panel.getByText("contents:file-00.ts", { exact: true })).toBeVisible() - - const viewport = panel.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport') - await viewport.hover() - await page.mouse.wheel(0, 100_000) - await expect - .poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) - .toBeLessThanOrEqual(1) - const scrolled = await viewport.evaluate((element) => element.scrollTop) - expect(scrolled).toBeGreaterThan(0) - await writeProbe(page) - - await panel.getByRole("button", { name: "file-79.ts" }).click() - await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "") - await expect(panel.getByText("contents:file-79.ts", { exact: true })).toBeVisible() - expect(await readProbe(page)).toBe(PROBE) - await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled) - - await panel.getByRole("button", { name: "file-78.ts" }).dblclick() - await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "") - await panel.getByRole("button", { name: "file-79.ts" }).click() - await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "") - await panel.getByRole("tab", { name: "file-78.ts" }).click() - await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "") - expect(await readProbe(page)).toBe(PROBE) - await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled) -}) - -type Probed = HTMLElement & { __e2eProbe?: string } - -async function writeProbe(page: Page) { - await page.locator('#review-panel [data-component="session-review-v2-sidebar-root"]').evaluate((el, probe) => { - ;(el as Probed).__e2eProbe = probe - }, PROBE) -} - -async function readProbe(page: Page) { - return page - .locator('#review-panel [data-component="session-review-v2-sidebar-root"]') - .evaluate((el) => (el as Probed).__e2eProbe) -} - -async function setup(page: Page) { - await mockOpenCodeServer(page, { - directory, - project: { - id: projectID, - worktree: directory, - vcs: "git", - name: "file-browser-sidebar", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { - all: [ - { - id: "opencode", - name: "OpenCode", - models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, - }, - ], - connected: ["opencode"], - default: { providerID: "opencode", modelID: "test" }, - }, - sessions: [ - { - id: sessionID, - slug: sessionID, - projectID, - directory, - title, - version: "dev", - time: { created: 1700000000000, updated: 1700000000000 }, - }, - ], - vcsDiff: [], - fileList: (path) => { - if (path) return [] - return files.map((name) => ({ - name, - path: name, - absolute: `${directory}/${name}`, - type: "file" as const, - ignored: false, - })) - }, - fileContent: (path) => ({ type: "text", content: `contents:${path}` }), - pageMessages: () => ({ items: [] }), - }) - - await page.addInitScript( - ({ directory, server, sessionID }) => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - localStorage.setItem( - "opencode.global.dat:server", - JSON.stringify({ - projects: { local: [{ worktree: directory, expanded: true }] }, - lastProject: { local: directory }, - }), - ) - localStorage.setItem( - "opencode.global.dat:layout", - JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), - ) - localStorage.setItem( - "opencode.global.dat:review-panel-v2", - JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }), - ) - localStorage.setItem( - "opencode.window.browser.dat:tabs", - JSON.stringify([{ type: "session", server, sessionId: sessionID }]), - ) - }, - { directory, server, sessionID }, - ) -} diff --git a/packages/app/e2e/regression/legacy-new-session.spec.ts b/packages/app/e2e/regression/legacy-new-session.spec.ts index 45cd64adcc..30233a5aae 100644 --- a/packages/app/e2e/regression/legacy-new-session.spec.ts +++ b/packages/app/e2e/regression/legacy-new-session.spec.ts @@ -24,7 +24,6 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => { await page.addInitScript( ({ directory, draftID, server }) => { localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } })) - localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" })) localStorage.setItem( "opencode.window.browser.dat:tabs", JSON.stringify([{ type: "draft", draftID, server, directory }]), diff --git a/packages/app/e2e/regression/open-file-expand-folder.spec.ts b/packages/app/e2e/regression/open-file-expand-folder.spec.ts deleted file mode 100644 index 37739d2fbb..0000000000 --- a/packages/app/e2e/regression/open-file-expand-folder.spec.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test } from "@playwright/test" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectSessionTitle } from "../utils/waits" - -const directory = "C:/OpenCode/OpenFileExpand" -const projectID = "proj_open_file_expand" -const sessionID = "ses_open_file_expand" -const title = "Open file expand" -const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` - -test.use({ viewport: { width: 1440, height: 900 } }) - -test("expands a folder whose path has a trailing Windows separator", async ({ page }) => { - await mockOpenCodeServer(page, { - directory, - project: { - id: projectID, - worktree: directory, - vcs: "git", - name: "open-file-expand", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { - all: [ - { - id: "opencode", - name: "OpenCode", - models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, - }, - ], - connected: ["opencode"], - default: { providerID: "opencode", modelID: "test" }, - }, - sessions: [ - { - id: sessionID, - slug: sessionID, - projectID, - directory, - title, - version: "dev", - time: { created: 1700000000000, updated: 1700000000000 }, - }, - ], - vcsDiff: [], - fileList: (path) => { - if (path === "frontend\\" || path === "frontend") { - return [ - { - name: "app.ts", - path: "frontend\\app.ts", - absolute: `${directory}/frontend/app.ts`, - type: "file" as const, - ignored: false, - }, - ] - } - if (path) return [] - return [ - { - name: "frontend", - path: "frontend\\", - absolute: `${directory}/frontend`, - type: "directory" as const, - ignored: false, - }, - { - name: "README.md", - path: "README.md", - absolute: `${directory}/README.md`, - type: "file" as const, - ignored: false, - }, - ] - }, - fileContent: (path) => ({ type: "text", content: `contents:${path}` }), - pageMessages: () => ({ items: [] }), - }) - - await page.addInitScript( - ({ directory, server, sessionID }) => { - localStorage.setItem( - "settings.v3", - JSON.stringify({ general: { newLayoutDesigns: true, shouldDisplayTabsToast: false } }), - ) - localStorage.setItem( - "opencode.global.dat:server", - JSON.stringify({ - projects: { local: [{ worktree: directory, expanded: true }] }, - lastProject: { local: directory }, - }), - ) - localStorage.setItem( - "opencode.global.dat:layout", - JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), - ) - localStorage.setItem( - "opencode.global.dat:review-panel-v2", - JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }), - ) - localStorage.setItem( - "opencode.window.browser.dat:tabs", - JSON.stringify([{ type: "session", server, sessionId: sessionID }]), - ) - }, - { directory, server, sessionID }, - ) - - await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) - await expectSessionTitle(page, title) - - const panel = page.locator("#review-panel") - await panel.getByRole("button", { name: "Open file" }).click() - await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") - - const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]') - await expect(sidebar).toBeVisible() - - const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]') - await expect(frontendRow).toBeVisible() - await expect(frontendRow).toHaveAttribute("aria-expanded", "false") - await frontendRow.click() - await expect(frontendRow).toHaveAttribute("aria-expanded", "true") - - const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]') - await expect(appRow).toBeVisible() - await appRow.click() - await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "") - await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible() -}) diff --git a/packages/app/e2e/regression/project-picker-recent-search.spec.ts b/packages/app/e2e/regression/project-picker-recent-search.spec.ts deleted file mode 100644 index 2cdb0b4a03..0000000000 --- a/packages/app/e2e/regression/project-picker-recent-search.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { expect, test } from "@playwright/test" -import type { Page } from "@playwright/test" -import { fixture, pageMessages } from "../smoke/session-timeline.fixture" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectAppVisible } from "../utils/waits" - -const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"] -const worktrees = NAMES.map((name) => `/opencode-demo/${name}`) - -// The sixth project sits outside the five-item recent cap, so it is only reachable if the -// dialog hands every recent project to the list filter instead of a pre-truncated slice. -const OUTSIDE_CAP = "foxtrot-docs" - -// Dialog rows carry data-directory-path; the sidebar project list does not, so this -// scopes assertions to the picker instead of matching the sidebar entry of the same name. -const rows = (page: Page) => page.locator("[data-directory-path]") -const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`) - -async function openProjectDialog(page: Page) { - await mockOpenCodeServer(page, { - sessions: fixture.sessions, - provider: fixture.provider, - directory: fixture.directory, - project: fixture.project, - pageMessages, - fileList: () => [], - findFiles: () => [], - }) - await page.addInitScript((dirs) => { - localStorage.setItem( - "opencode.global.dat:server", - JSON.stringify({ - projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) }, - lastProject: {}, - }), - ) - }, worktrees) - await page.goto("/") - const add = page.getByRole("button", { name: "Add project" }).first() - await expectAppVisible(add) - await add.click() - await expect(rows(page)).toHaveCount(5) - return page.getByRole("textbox").last() -} - -test("searches every recent project, not just the five most recent", async ({ page }) => { - const search = await openProjectDialog(page) - await expect(row(page, OUTSIDE_CAP)).toHaveCount(0) - - await search.fill("foxtrot") - - await expect(row(page, OUTSIDE_CAP)).toHaveCount(1) -}) - -test("still caps the idle recent list at five projects", async ({ page }) => { - await openProjectDialog(page) - - await expect(row(page, NAMES[4])).toHaveCount(1) - await expect(row(page, OUTSIDE_CAP)).toHaveCount(0) -}) diff --git a/packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts b/packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts deleted file mode 100644 index 50dcc8820b..0000000000 --- a/packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { expect, test } from "@playwright/test" -import { base64Encode } from "@opencode-ai/core/util/encode" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectAppVisible } from "../utils/waits" - -const directory = "C:/OpenCode/PromptInputV2Editing" -const projectID = "proj_prompt_input_v2_editing" -const sessionID = "ses_prompt_input_v2_editing" - -test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => { - await mockOpenCodeServer(page, { - directory, - project: { - id: projectID, - worktree: directory, - vcs: "git", - name: "prompt-input-v2-editing", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { all: [], connected: [], default: {} }, - sessions: [ - { - id: sessionID, - slug: "prompt-input-v2-editing", - projectID, - directory, - title: "Prompt input V2 editing", - version: "dev", - time: { created: 1700000000000, updated: 1700000000000 }, - }, - ], - pageMessages: () => ({ items: [] }), - }) - await page.addInitScript(() => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - }) - - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - const composer = page.locator('[data-component="prompt-input-v2"]') - const input = composer.locator('[data-component="prompt-input"]') - await expectAppVisible(composer) - - await input.fill("keep me") - await composer.getByRole("button", { name: "Add images and files" }).click() - await page.getByRole("menuitem", { name: "Commands" }).click() - await page.locator('[data-suggestion-id="model.choose"]').click() - - await expect(input).toHaveText("keep me") -}) diff --git a/packages/app/e2e/regression/prompt-thinking-level.spec.ts b/packages/app/e2e/regression/prompt-thinking-level.spec.ts index 9315c347c0..4219699f28 100644 --- a/packages/app/e2e/regression/prompt-thinking-level.spec.ts +++ b/packages/app/e2e/regression/prompt-thinking-level.spec.ts @@ -54,15 +54,18 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => { }) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - const composer = page.locator('[data-component="prompt-input-v2"]') + const composer = page.locator('[data-component="session-composer"]') const input = composer.locator('[data-component="prompt-input"]') - const control = composer.getByRole("button", { name: "Choose model variant" }) + const control = composer.locator('[data-component="prompt-variant-control"]') await expectAppVisible(composer) await idleComposer(page) + await expect(control).toBeHidden() + + await composer.hover() await expect(control).toBeVisible() - await control.click() + await control.locator('[data-action="prompt-model-variant"]').click() const high = page.getByRole("menuitemradio", { name: "high" }) await expect(high).toBeVisible() await page.mouse.move(0, 0) diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts deleted file mode 100644 index 35a0aa44cd..0000000000 --- a/packages/app/e2e/regression/remote-session-settings.spec.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page, type Route } from "@playwright/test" -import { installSseTransport } from "../utils/sse-transport" -import { currentSession } from "../utils/mock-server" - -const serverA = "http://127.0.0.1:4096" -const serverB = "http://127.0.0.1:4097" -const directoryA = "C:/server-a" -const directoryB = "/home/server-b" -const sessionA = session("ses_server_a", directoryA, "Server A session") -const childSessionA = { ...session("ses_server_a_child", directoryA, "Server A child session"), parentID: sessionA.id } -const sessionB = session("ses_server_b", directoryB, "Server B session") - -test("session settings use the remote server context", async ({ page }) => { - const permissionRequests: string[] = [] - await mockServers(page, permissionRequests) - await configureServers(page) - - await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`) - await expect(page.getByText(sessionB.title).first()).toBeVisible() - await page.keyboard.press("Control+,") - - const dialog = page.locator(".settings-v2-dialog") - const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]') - const input = autoAccept.getByRole("switch") - await expect(autoAccept).toBeVisible() - await expect(input).toBeEnabled() - permissionRequests.length = 0 - await autoAccept.locator('[data-slot="switch-control"]').click() - await expect(input).toBeChecked() - await expect - .poll(() => - permissionRequests.some((request) => { - const url = new URL(request) - return url.origin === serverB && url.searchParams.get("directory") === directoryB - }), - ) - .toBe(true) - expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true) - - await dialog.getByRole("tab", { name: "Models" }).click() - await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled() - await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0) -}) - -test("auto-accept responds for an unfocused server session", async ({ page }) => { - const permissionRequests: string[] = [] - const permissionResponses: PermissionResponse[] = [] - const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { - server: serverA, - retry: 20, - }) - await mockServers(page, permissionRequests, permissionResponses) - await configureServers(page, [ - { type: "session", server: serverA, sessionId: sessionA.id }, - { type: "session", server: serverB, sessionId: sessionB.id }, - ]) - - const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` - await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`) - await expect(page.getByText(sessionA.title).first()).toBeVisible() - await page.keyboard.press("Control+,") - const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]') - await autoAccept.locator('[data-slot="switch-control"]').click() - await expect(autoAccept.getByRole("switch")).toBeChecked() - await expect - .poll(() => - permissionRequests.some((request) => { - const url = new URL(request) - return url.origin === serverA && url.searchParams.get("directory") === directoryA - }), - ) - .toBe(true) - await page.keyboard.press("Escape") - - await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click() - await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) - await expect(page.getByText(sessionB.title).first()).toBeVisible() - await transport.waitForConnection() - - await transport.send({ - directory: directoryA, - payload: { - id: "event-permission-background-a", - type: "permission.asked", - properties: { - id: "permission-background-a", - sessionID: sessionA.id, - permission: "bash", - patterns: ["git status"], - metadata: {}, - always: [], - }, - }, - }) - - await expect - .poll(() => permissionResponses) - .toEqual([ - { - origin: serverA, - directory: directoryA, - sessionID: sessionA.id, - permissionID: "permission-background-a", - body: { response: "once" }, - }, - ]) - - await transport.send({ - directory: directoryA, - payload: { - id: "event-permission-background-a-child", - type: "permission.asked", - properties: { - id: "permission-background-a-child", - sessionID: childSessionA.id, - permission: "bash", - patterns: ["git diff"], - metadata: {}, - always: [], - }, - }, - }) - - await expect - .poll(() => permissionResponses) - .toEqual([ - { - origin: serverA, - directory: directoryA, - sessionID: sessionA.id, - permissionID: "permission-background-a", - body: { response: "once" }, - }, - { - origin: serverA, - directory: directoryA, - sessionID: childSessionA.id, - permissionID: "permission-background-a-child", - body: { response: "once" }, - }, - ]) -}) - -type PermissionResponse = { - origin: string - directory?: string - sessionID: string - permissionID: string - body: unknown -} - -async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) { - await page.addInitScript( - ({ serverB, tabs }) => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) - localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(tabs)) - }, - { serverB, tabs }, - ) -} - -async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) { - await page.route("**/*", async (route) => { - const url = new URL(route.request().url()) - if (url.origin !== serverA && url.origin !== serverB) return route.fallback() - const remote = url.origin === serverB - const directory = remote ? directoryB : directoryA - const sessions = remote ? [sessionB] : [sessionA, childSessionA] - const requestDirectory = url.searchParams.get("directory") - const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/) - if (route.request().method() === "POST" && response) { - permissionResponses.push({ - origin: url.origin, - directory: requestDirectory ?? undefined, - sessionID: response[1]!, - permissionID: response[2]!, - body: route.request().postDataJSON(), - }) - return json(route, true) - } - if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) - if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") - return sse(route) - if (url.pathname === "/global/health") return json(route, { healthy: true }) - if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent") - return json(route, { data: [] }) - if (url.pathname === "/api/model/default") return json(route, { data: null }) - if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname)) - return json(route, { location: { directory }, data: [] }) - if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] }) - if (url.pathname === "/api/mcp/resource") - return json(route, { location: { directory }, data: { resources: [], templates: [] } }) - if (url.pathname === "/api/project") { - return json(route, [ - { - id: remote ? sessionB.projectID : "project-server-a", - worktree: directory, - vcs: "git", - time: { created: 1, updated: 1 }, - sandboxes: [], - }, - ]) - } - if (url.pathname === "/api/project/current") - return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory }) - if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) - if (url.pathname === "/api/session/active") return json(route, { data: {} }) - const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`) - if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) - if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`)) - return json(route, { data: [], cursor: {} }) - const current = sessions.find((session) => url.pathname === `/session/${session.id}`) - if (current) return json(route, current) - if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) - if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, []) - if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) - if (url.pathname === "/permission") { - permissionRequests.push(url.toString()) - return json(route, []) - } - if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname)) - return json(route, []) - if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) - if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a")) - if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) - if (url.pathname === "/project" || url.pathname === "/project/current") { - const project = { - id: remote ? sessionB.projectID : "project-server-a", - worktree: directory, - vcs: "git", - time: { created: 1, updated: 1 }, - sandboxes: [], - } - return json(route, url.pathname === "/project" ? [project] : project) - } - if (url.pathname === "/path") - return json(route, { - state: directory, - config: directory, - worktree: directory, - directory, - home: directory, - }) - if (url.pathname === "/api/path") - return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory }) - if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) - if (url.pathname === "/api/vcs") - return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } }) - if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] }) - return json(route, {}) - }) -} - -function session(id: string, directory: string, title: string) { - return { - id, - slug: id, - projectID: `project-${id}`, - directory, - title, - version: "dev", - time: { created: 1, updated: 1 }, - } -} - -function provider(id: string) { - const name = id === "server-b" ? "Server B" : "Server A" - return { - all: [ - { - id, - name: `${name} Provider`, - models: { - [id]: { - id, - name: `${name} Model`, - family: id, - release_date: "2026-01-01", - limit: { context: 200_000 }, - }, - }, - }, - ], - connected: [id], - default: { providerID: id, modelID: id }, - } -} - -function json(route: Route, body: unknown, status = 200) { - return route.fulfill({ - status, - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify(body), - }) -} - -function sse(route: Route) { - return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) -} diff --git a/packages/app/e2e/regression/remote-tab-busy.spec.ts b/packages/app/e2e/regression/remote-tab-busy.spec.ts index 2d9b1e2349..119fc7ee2d 100644 --- a/packages/app/e2e/regression/remote-tab-busy.spec.ts +++ b/packages/app/e2e/regression/remote-tab-busy.spec.ts @@ -1,6 +1,5 @@ import { expect, test, type Page, type Route } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" -import { currentSession } from "../utils/mock-server" const serverA = "http://127.0.0.1:4096" const serverB = "http://127.0.0.1:4097" @@ -58,15 +57,11 @@ async function mockServers(page: Page) { const current = url.origin === serverA ? sessionA : sessionB const directory = url.searchParams.get("directory") if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) - if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") - return sse(route, url.pathname === "/api/event") - if (url.pathname === "/global/health") return json(route, {}, 404) - if (url.pathname === "/api/health") return json(route, { pid: 1 }) - if (url.pathname === "/api/session/active") - return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} }) - if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) - if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) }) - if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} }) + if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) + if (url.pathname === "/global/health") return json(route, { healthy: true }) + if (url.pathname === "/session/status") + return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {}) + if (url.pathname === "/session") return json(route, [current]) if (url.pathname === `/session/${current.id}`) return json(route, current) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (url.pathname === `/session/${current.id}/message`) return json(route, []) @@ -95,20 +90,7 @@ async function mockServers(page: Page) { directory: current.directory, home: current.directory, }) - if (url.pathname === "/api/path") - return json(route, { - state: current.directory, - config: current.directory, - worktree: current.directory, - directory: current.directory, - home: current.directory, - }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) - if (url.pathname === "/api/vcs") - return json(route, { - location: { directory: current.directory }, - data: { branch: "main", defaultBranch: "main" }, - }) return json(route, {}) }) } @@ -122,10 +104,6 @@ function json(route: Route, body: unknown, status = 200) { }) } -function sse(route: Route, current: boolean) { - return route.fulfill({ - status: 200, - contentType: "text/event-stream", - body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n", - }) +function sse(route: Route) { + return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) } diff --git a/packages/app/e2e/regression/review-line-comment.spec.ts b/packages/app/e2e/regression/review-line-comment.spec.ts index 7850f7820a..042f926c53 100644 --- a/packages/app/e2e/regression/review-line-comment.spec.ts +++ b/packages/app/e2e/regression/review-line-comment.spec.ts @@ -84,7 +84,6 @@ test("stages a submitted line comment in the prompt context", async ({ page }) = async function openReview(page: Page) { await page.setViewportSize({ width: 700, height: 900 }) await mockOpenCodeServer(page, { - protocol: "v2", directory, project: { id: "proj_review_line_comment_regression", @@ -144,9 +143,9 @@ async function openReview(page: Page) { await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await expectSessionTitle(page, title) - const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff") + const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff") await page.getByRole("tab", { name: "Changes" }).click() - expect((await (await diffResponse).json()).data).toHaveLength(1) + expect(await (await diffResponse).json()).toHaveLength(1) const review = page.locator('[data-component="session-review"]') await expectAppVisible(review) diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts index 04e6d2cced..397c53f248 100644 --- a/packages/app/e2e/regression/review-open-file.spec.ts +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -91,17 +91,15 @@ test("opens and searches project files inline", async ({ page }) => { const panel = page.locator("#review-panel") const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]') - const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" }) const contextButton = page.getByRole("button", { name: "View context usage" }) await contextButton.click() await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") await panel.getByRole("button", { name: "Open file" }).click() await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") - await expect(sidebarToggle).toBeDisabled() await expect(sidebar).toBeVisible() await contextButton.click() await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") - await expect(sidebar).toBeHidden() + await expect(sidebar).toHaveCount(0) await panel.getByRole("button", { name: "Open file" }).click() const filter = panel.getByRole("combobox", { name: "Filter files" }) await expect(filter).toBeFocused() @@ -110,7 +108,6 @@ test("opens and searches project files inline", async ({ page }) => { await panel.getByRole("button", { name: "README.md" }).click() await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "") - await expect(sidebarToggle).toBeEnabled() await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible() await expect(sidebar).toHaveCount(0) @@ -125,17 +122,12 @@ test("opens and searches project files inline", async ({ page }) => { await expect(filter).toHaveAttribute("aria-activedescendant", resultID!) await filter.press("Enter") await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") - await expect(sidebarToggle).toBeEnabled() await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible() expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 }) await panel.getByRole("button", { name: "Open file" }).click() await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1) await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") - await expect(sidebarToggle).toBeDisabled() - await panel.locator("#session-side-panel-review-tab").click() - await expect(sidebarToggle).toBeEnabled() - await panel.getByRole("tab", { name: "Open file" }).click() await page.keyboard.press("Control+w") await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0) await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") diff --git a/packages/app/e2e/regression/review-state-persistence.spec.ts b/packages/app/e2e/regression/review-state-persistence.spec.ts index 0d6756201e..aa42f1bb51 100644 --- a/packages/app/e2e/regression/review-state-persistence.spec.ts +++ b/packages/app/e2e/regression/review-state-persistence.spec.ts @@ -46,7 +46,7 @@ test("restores review mode and selected file per session", async ({ page }) => { async function selectMode(page: Page, current: string, next: string) { await page.getByRole("button", { name: current }).click() - await page.getByRole("option", { name: next }).dispatchEvent("click") + await page.getByRole("option", { name: next }).click() } async function selectFile(page: Page, file: string) { @@ -65,7 +65,6 @@ async function switchSession(page: Page, title: string) { async function setup(page: Page) { await mockOpenCodeServer(page, { - protocol: "v1", directory, project: { id: projectID, diff --git a/packages/app/e2e/regression/review-tab-switch.spec.ts b/packages/app/e2e/regression/review-tab-switch.spec.ts index 8634166e51..c2ea406c5a 100644 --- a/packages/app/e2e/regression/review-tab-switch.spec.ts +++ b/packages/app/e2e/regression/review-tab-switch.spec.ts @@ -28,8 +28,8 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac await expectSessionTitle(page, titleA) await page.getByRole("button", { name: "Toggle review" }).click() - const reviewTab = page.locator("#session-side-panel-review-tab") - const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel") + const reviewTab = page.getByRole("tab", { name: /Review/ }) + const reviewTabPanel = page.getByRole("tabpanel", { name: /Review/ }) await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel") await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel") const review = page.locator('#review-panel [data-component="session-review-v2"]') diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts index 79b564820e..e3ba607b3d 100644 --- a/packages/app/e2e/regression/review-terminal-stacked.spec.ts +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -20,12 +20,10 @@ const branchDiffs = [ test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => { test.setTimeout(120_000) const events: Array<{ directory: string; payload: Record }> = [] - const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } } let detailVersion = 1 let detailFailures = 1 await page.setViewportSize({ width: 1400, height: 900 }) await mockOpenCodeServer(page, { - protocol: "v1", directory, project: { id: projectID, @@ -57,7 +55,7 @@ test("keeps the review tree and terminal sized when both panels are open", async time: { created: 1700000000000, updated: 1700000000000 }, }, ], - sessionStatus: () => sessionStatus, + sessionStatus: { [sessionID]: { type: "idle" } }, pageMessages: () => ({ items: [] }), events: () => events.splice(0, 1), eventRetry: 16, @@ -66,10 +64,7 @@ test("keeps the review tree and terminal sized when both panels are open", async route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ - branch: "review-pane-performance", - default_branch: "dev", - }), + body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }), }), ) await page.route("**/vcs/diff**", (route) => { @@ -91,51 +86,15 @@ test("keeps the review tree and terminal sized when both panels are open", async ), }) }) - await page.route("**/pty*", (route) => + await page.route("**/pty", (route) => route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ - location: { directory, project: { id: projectID, directory } }, - data: { - id: "pty_review_terminal", - title: "Terminal 1", - command: "cmd.exe", - args: [], - cwd: directory, - status: "running", - pid: 1, - }, - }), + body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }), }), ) - await page.route("**/pty/pty_review_terminal*", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - location: { directory, project: { id: projectID, directory } }, - data: { - id: "pty_review_terminal", - title: "Terminal 1", - command: "cmd.exe", - args: [], - cwd: directory, - status: "running", - pid: 1, - }, - }), - }), - ) - await page.route("**/pty/pty_review_terminal/connect-token*", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - location: { directory, project: { id: projectID, directory } }, - data: { ticket: "e2e-ticket", expires_in: 60 }, - }), - }), + await page.route("**/pty/pty_review_terminal", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), ) await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined) await page.addInitScript(() => { @@ -152,7 +111,7 @@ test("keeps the review tree and terminal sized when both panels are open", async await expectTree(page, 8, "git-0.ts") await selectMode(page, "Git changes", "Branch changes") - await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740") + await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible() await page.keyboard.press("Control+Backquote") await expect(page.locator("#terminal-panel")).toBeVisible() await expectTree(page, 2_773, "action.yml") @@ -184,7 +143,6 @@ test("keeps the review tree and terminal sized when both panels are open", async const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]') await expect(preview).toContainText("after-1") detailVersion = 2 - sessionStatus[sessionID] = { type: "busy" } events.push(statusEvent("busy")) await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() const refreshedDiff = page.waitForRequest((request) => { @@ -194,7 +152,6 @@ test("keeps the review tree and terminal sized when both panels are open", async url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true ) }) - sessionStatus[sessionID] = { type: "idle" } events.push(statusEvent("idle")) await refreshedDiff await expect(preview).toContainText("after-2") diff --git a/packages/app/e2e/regression/session-list-path-loading.spec.ts b/packages/app/e2e/regression/session-list-path-loading.spec.ts index 3319514df6..4a3855122a 100644 --- a/packages/app/e2e/regression/session-list-path-loading.spec.ts +++ b/packages/app/e2e/regression/session-list-path-loading.spec.ts @@ -16,8 +16,8 @@ test("shows loaded sessions before the directory path request resolves", async ( const pathBlocked = new Promise((resolve) => { releasePath = resolve }) - await page.route("**/api/path?*", async (route) => { - if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback() + await page.route("**/path?*", async (route) => { + if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback() await pathBlocked return route.fallback() }) diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index 5ea9d4f761..036eaaef42 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -1,7 +1,6 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" -import { installSseTransport } from "../utils/sse-transport" import { expectSessionTitle } from "../utils/waits" const directory = "C:/OpenCode/RequestDocks" @@ -42,8 +41,7 @@ test("shows a pending question dock", async ({ page }) => { const rejectRequests: string[] = [] page.on("request", (request) => { if (request.method() !== "POST") return - if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`) - rejectRequests.push(request.url()) + if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url()) }) await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click() @@ -65,9 +63,7 @@ test("shows a pending question dock", async ({ page }) => { await question.getByRole("radio", { name: /Minimal/ }).click() const reply = page.waitForRequest( - (request) => - request.method() === "POST" && - new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`, + (request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply", ) await question.getByRole("button", { name: "Submit" }).click() expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] }) @@ -100,69 +96,8 @@ test("shows a pending permission dock", async ({ page }) => { const reply = page.waitForRequest((request) => request.method() === "POST") await permission.getByRole("button", { name: "Allow once" }).click() const request = await reply - expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`) - expect(request.postDataJSON()).toEqual({ reply: "once" }) -}) - -test("restores the draft caret before typing after a request dock closes", async ({ page }) => { - const transport = await installSseTransport(page, { - server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, - retry: 20, - }) - await mockServer(page, { questions: [] }) - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - await transport.waitForConnection() - await expectSessionTitle(page, title) - - const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]') - const draft = "keep the caret at the end" - await editor.fill(draft) - await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))) - for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft") - const cursor = draft.length - 4 - await expect - .poll(() => - editor.evaluate((element) => { - const selection = window.getSelection() - if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1 - const range = selection.getRangeAt(0).cloneRange() - range.selectNodeContents(element) - range.setEnd(selection.anchorNode!, selection.anchorOffset) - return range.toString().length - }), - ) - .toBe(cursor) - await transport.send({ - directory, - payload: { - type: "question.asked", - properties: { - id: "question-caret", - sessionID, - questions: [ - { - header: "Continue", - question: "Continue?", - options: [{ label: "Yes", description: "Continue the session" }], - }, - ], - tool: { messageID: "message-caret", callID: "call-caret" }, - }, - }, - }) - const question = page.locator('[data-component="dock-prompt"][data-kind="question"]') - await expect(question).toBeVisible() - await expect(editor).toHaveCount(0) - - await transport.send({ - directory, - payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } }, - }) - await expect(question).toHaveCount(0) - await expect(editor).toBeVisible() - await page.keyboard.press("x") - - await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`) + expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`) + expect(request.postDataJSON()).toEqual({ response: "once" }) }) async function mockServer( @@ -173,7 +108,6 @@ async function mockServer( }, ) { await mockOpenCodeServer(page, { - protocol: "v2", directory, project: { id: projectID, diff --git a/packages/app/e2e/regression/session-timeline-history-root.spec.ts b/packages/app/e2e/regression/session-timeline-history-root.spec.ts index e5ef7998ea..15375cafed 100644 --- a/packages/app/e2e/regression/session-timeline-history-root.spec.ts +++ b/packages/app/e2e/regression/session-timeline-history-root.spec.ts @@ -17,14 +17,12 @@ import { mockOpenCodeServer } from "../utils/mock-server" import { installSseTransport } from "../utils/sse-transport" import { expectSessionTitle } from "../utils/waits" -const initialPageSize = 20 -const historyPageSize = 200 -const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) => +const assistants = Array.from({ length: 14 }, (_, index) => assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, parentID: userID, created: 1700000001000 + index * 1_000, - completed: index < initialPageSize, + completed: index < 13, }), ) const messages = [userMessage(), ...assistants] @@ -48,7 +46,7 @@ const scenarios = [ test.use({ viewport: { width: 646, height: 1385 } }) for (const scenario of scenarios) { - test(`keeps visible timeline content visible through ${scenario.name}`, async ({ page }) => { + test(`keeps the latest user turn visible through ${scenario.name}`, async ({ page }) => { const requests: { before?: string; phase: "start" | "end" }[] = [] const pages: { before?: string; limit: number }[] = [] const roots: { sessionID: string; messageID: string }[] = [] @@ -103,51 +101,36 @@ for (const scenario of scenarios) { } }, }) - await page.addInitScript(() => { - const visibleParts = () => { - const virtual = document.querySelector("[data-timeline-virtual-content]") - const viewport = virtual?.closest(".scroll-view__viewport") - const view = viewport?.getBoundingClientRect() - if (!viewport || !view) return [] - return [...viewport.querySelectorAll("[data-timeline-part-id]")] - .filter((part) => { - const rect = part.getBoundingClientRect() - return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom - }) - .flatMap((part) => (part.dataset.timelinePartId ? [part.dataset.timelinePartId] : [])) - } - const state = { - armed: false, - hidden: false, - visibleParts: [] as string[], - samples: 0, - stop: false, - arm() { - state.visibleParts = visibleParts() - state.armed = true - }, - } - ;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state - const sample = () => { - if (state.armed) { - const virtual = document.querySelector("[data-timeline-virtual-content]") - const viewport = virtual?.closest(".scroll-view__viewport") - const view = viewport?.getBoundingClientRect() - const visible = (partID: string) => { - const part = viewport?.querySelector(`[data-timeline-part-id="${CSS.escape(partID)}"]`) - const rect = part?.getBoundingClientRect() - return ( - !!rect && !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom - ) + await page.addInitScript( + ({ userPartID, lastPartID }) => { + const state = { armed: false, hidden: false, samples: 0, stop: false } + ;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state + const sample = () => { + if (state.armed) { + const virtual = document.querySelector("[data-timeline-virtual-content]") + const viewport = virtual?.closest(".scroll-view__viewport") + const view = viewport?.getBoundingClientRect() + const visible = (partID: string) => { + const part = viewport?.querySelector(`[data-timeline-part-id="${partID}"]`) + const rect = part?.getBoundingClientRect() + return ( + !!rect && + !!view && + rect.width > 0 && + rect.height > 0 && + rect.bottom > view.top && + rect.top < view.bottom + ) + } + if (!virtual || !visible(userPartID) || !visible(lastPartID)) state.hidden = true + state.samples++ } - if (!virtual || state.visibleParts.length === 0 || state.visibleParts.some((partID) => !visible(partID))) - state.hidden = true - state.samples++ + if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0)) } - if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0)) - } - requestAnimationFrame(() => setTimeout(sample, 0)) - }) + requestAnimationFrame(() => setTimeout(sample, 0)) + }, + { userPartID, lastPartID }, + ) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await transport.waitForConnection() @@ -160,28 +143,23 @@ for (const scenario of scenarios) { "messages:start:latest", "messages:end:latest", `message:${userID}`, - `messages:start:${messages.at(-initialPageSize)!.info.id}`, + `messages:start:${messages.at(-2)!.info.id}`, ]) - await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize) await page.evaluate(() => { ;( window as Window & { - __historyRootProbe?: { arm(): void } + __historyRootProbe?: { armed: boolean } } - ).__historyRootProbe!.arm() + ).__historyRootProbe!.armed = true }) await waitForProbeSamples(page, 0) - expect(await visibleContentHidden(page)).toBe(false) + expect(await historyRootHidden(page)).toBe(false) const beforeHistory = await probeSamples(page) history.resolve() - await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length) - await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2) + await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(14) await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() await waitForProbeSamples(page, beforeHistory) - expect(pages).toEqual([ - { before: undefined, limit: initialPageSize }, - { before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize }, - ]) + expect(pages[0]).toEqual({ before: undefined, limit: 2 }) expect(roots).toEqual([{ sessionID, messageID: userID }]) const message = messageUpdated(scenario.info) @@ -235,7 +213,7 @@ async function waitForProbeSamples(page: Page, after: number) { ) } -function visibleContentHidden(page: Page) { +function historyRootHidden(page: Page) { return page.evaluate( () => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden, ) diff --git a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts index b303071c87..3e2b171bca 100644 --- a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts @@ -32,23 +32,6 @@ for (const expanded of [false, true]) { }) } -test("shows and expands a running shell command without shimmering it", async ({ page }) => { - const id = "prt_shell_running_command" - const command = "sleep 10 && echo done" - await setupTimeline(page, { - messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })], - settings: { shellToolPartsExpanded: false }, - }) - - const tool = page.locator(`[data-timeline-part-id="${id}"]`) - await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true") - await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command) - await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0) - await tool.locator('[data-slot="collapsible-trigger"]').click() - await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true") - await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running") -}) - test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => { const reasoningID = "prt_reasoning_hidden" const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false }) diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts index b1aabcc32c..9fd2ca8d0b 100644 --- a/packages/app/e2e/regression/session-timeline-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-projection.spec.ts @@ -152,10 +152,7 @@ test.describe("session timeline projection", () => { parentID: "msg_2000_diff_next_user", created: 1700000011000, }) - await setupTimeline(page, { - messages: [user, assistantMessage(), nextUser, nextAssistant], - settings: { newLayoutDesigns: false }, - }) + await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] }) const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) await scroller.evaluate((element) => (element.scrollTop = 0)) diff --git a/packages/app/e2e/regression/session-timeline-transport.spec.ts b/packages/app/e2e/regression/session-timeline-transport.spec.ts index 778ff3a3af..850e966d0b 100644 --- a/packages/app/e2e/regression/session-timeline-transport.spec.ts +++ b/packages/app/e2e/regression/session-timeline-transport.spec.ts @@ -89,8 +89,8 @@ test("reconnects after a stream error", async ({ page }) => { expect((await timeline.transport.connections())[0]?.endedBy).toBe("error") }) -test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => { - const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" }) +test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => { + const timeline = await setupTimeline(page, { eventRetry: 10 }) const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), { id: "timeline-event-7", }) @@ -100,7 +100,7 @@ test("does not request replay when reconnecting the volatile V2 event stream", a const connection = await timeline.transport.waitForConnection({ after: first.connectionID }) expect(first.eventID).toBe("timeline-event-7") - expect(connection.headers["last-event-id"]).toBeUndefined() + expect(connection.headers["last-event-id"]).toBe("timeline-event-7") }) test("passes through non-event fetches", async ({ page }) => { diff --git a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts index 55e7121275..603c411d55 100644 --- a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts +++ b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts @@ -27,7 +27,6 @@ test("animates todo lifecycle without replaying it across session tabs", async ( test.setTimeout(90_000) const events: EventPayload[] = [] const todos: Record = { [sourceID]: [], [otherID]: [] } - const sessionStatus: Record = {} await mockOpenCodeServer(page, { directory, @@ -57,11 +56,9 @@ test("animates todo lifecycle without replaying it across session tabs", async ( default: { providerID: "opencode", modelID: "claude-opus-4-6" }, }, sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)], - sessionStatus: { [sourceID]: { type: "busy" } }, pageMessages: () => ({ items: [] }), events: () => events.splice(0, 1), eventRetry: 16, - sessionStatus: () => sessionStatus, todos: (sessionID) => todos[sessionID] ?? [], }) await configurePage(page) @@ -71,7 +68,6 @@ test("animates todo lifecycle without replaying it across session tabs", async ( const dock = page.locator('[data-component="session-todo-dock"]') await expect(dock).toHaveCount(0) - sessionStatus[sourceID] = { type: "busy" } events.push(statusEvent(sourceID, "busy")) await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts index 019cc156ec..19d2c29af0 100644 --- a/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -1,6 +1,6 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { expect, test, type Page } from "@playwright/test" -import { currentSession, mockOpenCodeServer } from "../utils/mock-server" +import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" const directory = "C:/OpenCode/SubagentNavigation" @@ -72,19 +72,16 @@ async function setup(page: Page, events?: () => EventPayload[]) { events, eventRetry: events ? 16 : undefined, }) - // The child session resolves by ID but is absent from the session list, + // The child session resolves via /session/:id but is absent from the /session list, // matching a subagent session that has not been loaded into the list cache yet. await page.route( - (url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"), + (url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"), (route) => route.fulfill({ status: 200, contentType: "application/json", headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify({ - data: [currentSession(session(parentID, parentTitle, 1700000000000))], - cursor: {}, - }), + body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]), }), ) await configurePage(page) diff --git a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts index b969b590d8..94afbc9a9d 100644 --- a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts +++ b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts @@ -1,12 +1,9 @@ import { expect, test, type Page, type Route } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" -import { currentSession } from "../utils/mock-server" const server = "http://127.0.0.1:4096" const sessionA = session("ses_tab_a", "Tab A session") const sessionB = session("ses_tab_b", "Tab B session") -const sessionC = session("ses_tab_c", "Tab C session") -const unresolvedSessionID = "ses_tab_unresolved" test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => { await mockServer(page) @@ -42,34 +39,6 @@ test("pressing mouse down on a tab navigates before mouse up", async ({ page }) await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) }) -test("keyboard navigation follows the visible tab order", async ({ page }) => { - await mockServer(page) - await page.addInitScript( - ({ server, sessionA, unresolved, sessionC }) => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - localStorage.setItem( - "opencode.window.browser.dat:tabs", - JSON.stringify([ - { type: "session", server, sessionId: sessionA }, - { type: "session", server, sessionId: unresolved }, - { type: "session", server, sessionId: sessionC }, - ]), - ) - }, - { server, sessionA: sessionA.id, unresolved: unresolvedSessionID, sessionC: sessionC.id }, - ) - - const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}` - const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}` - await page.goto(hrefA) - await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(2) - await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefC}"])`)).toBeVisible() - - await page.keyboard.press("Control+Alt+ArrowRight") - - await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) -}) - function session(id: string, title: string) { return { id, @@ -83,21 +52,13 @@ function session(id: string, title: string) { } async function mockServer(page: Page) { - const sessions = [sessionA, sessionB, sessionC] + const sessions = [sessionA, sessionB] await page.route("**/*", async (route) => { const url = new URL(route.request().url()) if (url.origin !== server) return route.fallback() - if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname)) - return new Promise(() => {}) - if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") - return sse(route) + if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) if (url.pathname === "/global/health") return json(route, { healthy: true }) - if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) - if (url.pathname === "/api/session/active") return json(route, { data: {} }) - const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`) - if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) - if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`)) - return json(route, { data: [], cursor: {} }) + if (url.pathname === "/session") return json(route, sessions) const byId = sessions.find((item) => url.pathname === `/session/${item.id}`) if (byId) return json(route, byId) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) @@ -105,7 +66,8 @@ async function mockServer(page: Page) { if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) return json(route, []) - if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) + if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname)) + return json(route, {}) if (url.pathname === "/provider") return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) @@ -127,20 +89,7 @@ async function mockServer(page: Page) { directory: sessionA.directory, home: sessionA.directory, }) - if (url.pathname === "/api/path") - return json(route, { - state: sessionA.directory, - config: sessionA.directory, - worktree: sessionA.directory, - directory: sessionA.directory, - home: sessionA.directory, - }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) - if (url.pathname === "/api/vcs") - return json(route, { - location: { directory: sessionA.directory }, - data: { branch: "main", defaultBranch: "main" }, - }) return json(route, {}) }) } diff --git a/packages/app/e2e/regression/terminal-composer-focus.spec.ts b/packages/app/e2e/regression/terminal-composer-focus.spec.ts index 99bf689085..2c2801d4b5 100644 --- a/packages/app/e2e/regression/terminal-composer-focus.spec.ts +++ b/packages/app/e2e/regression/terminal-composer-focus.spec.ts @@ -1,5 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "@playwright/test" +import { expect, test } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" @@ -7,13 +7,11 @@ const directory = "C:/OpenCode/TerminalComposerFocus" const projectID = "proj_terminal_composer_focus" const sessionID = "ses_terminal_composer_focus" const ptyID = "pty_terminal_composer_focus" -const newPtyID = "pty_terminal_composer_focus_new" test.use({ viewport: { width: 1440, height: 900 } }) -test.beforeEach(async ({ page }) => { +test("routes typing to the composer unless the open terminal is focused", async ({ page }) => { await mockOpenCodeServer(page, { - protocol: "v2", directory, project: { id: projectID, @@ -47,36 +45,29 @@ test.beforeEach(async ({ page }) => { ], pageMessages: () => ({ items: [] }), }) - await page.route("**/api/pty*", (route) => { - expect(new URL(route.request().url()).searchParams.get("location[directory]")).toBe(directory) - return route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }), - }) - }) - await page.route(`**/api/pty/${ptyID}*`, (route) => + await page.route("**/pty", (route) => route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }), + body: JSON.stringify({ id: ptyID, title: "Terminal 1" }), }), ) - await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => + await page.route(`**/pty/${ptyID}`, (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), + ) + await page.route(`**/pty/${ptyID}/connect-token*`, (route) => route.fulfill({ status: 200, contentType: "application/json", headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), + body: JSON.stringify({ ticket: "e2e-ticket" }), }), ) - await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined) + await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), () => undefined) await page.addInitScript(() => { localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) }) -}) -test("routes typing to the composer unless the open terminal is focused", async ({ page }) => { await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await expectSessionTitle(page, "Terminal composer focus") @@ -96,132 +87,3 @@ test("routes typing to the composer unless the open terminal is focused", async await expect(composer).toBeFocused() await expect(composer).toHaveText("a") }) - -test("keeps composer focus when a cached terminal finishes mounting", async ({ page }) => { - const ghostty = Promise.withResolvers() - const release = Promise.withResolvers() - const created = { count: 0 } - await page.route("**/api/pty*", (route) => { - created.count += 1 - return route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }), - }) - }) - await page.route(/ghostty-web/, async (route) => { - ghostty.resolve() - await release.promise - await route.continue() - }) - await seedCachedTerminal(page) - - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" }) - await expectSessionTitle(page, "Terminal composer focus") - - const composer = page.locator('[data-component="prompt-input"]') - const terminal = page.locator('[data-component="terminal"]') - await expect(terminal).toBeVisible() - expect(created.count).toBe(0) - await ghostty.promise - await composer.click() - await expect(composer).toBeFocused() - - release.resolve() - await expect(terminal.locator("textarea")).toHaveCount(1) - await page.waitForTimeout(300) - await expect(composer).toBeFocused() -}) - -test("keeps newer composer focus while an explicit terminal open finishes", async ({ page }) => { - const ghostty = Promise.withResolvers() - const release = Promise.withResolvers() - await page.route(/ghostty-web/, async (route) => { - ghostty.resolve() - await release.promise - await route.continue() - }) - - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - await expectSessionTitle(page, "Terminal composer focus") - - const composer = page.locator('[data-component="prompt-input"]') - const terminal = page.locator('[data-component="terminal"]') - await page.keyboard.press("Control+Backquote") - await expect(terminal).toBeVisible() - await ghostty.promise - await composer.click() - await expect(composer).toBeFocused() - - release.resolve() - await expect(terminal.locator("textarea")).toHaveCount(1) - await page.waitForTimeout(50) - await expect(composer).toBeFocused() -}) - -test("focuses a terminal created from the new-terminal button", async ({ page }) => { - const created = { count: 0 } - await page.route("**/api/pty*", (route) => { - created.count += 1 - const next = created.count === 1 ? ptyInfo(ptyID, "Terminal 1") : ptyInfo(newPtyID, "Terminal 2") - return route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ location: ptyLocation(), data: next }), - }) - }) - await page.route(`**/api/pty/${newPtyID}*`, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(newPtyID, "Terminal 2") }), - }), - ) - await page.route(`**/api/pty/${newPtyID}/connect-token*`, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), - }), - ) - await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined) - - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - await expectSessionTitle(page, "Terminal composer focus") - - const composer = page.locator('[data-component="prompt-input"]') - const terminal = page.locator('[data-component="terminal"]') - await page.keyboard.press("Control+Backquote") - await expect(terminal.locator("textarea")).toHaveCount(1) - await composer.click() - await expect(composer).toBeFocused() - - await page.getByRole("button", { name: "New terminal" }).click() - await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true") - await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true) -}) - -function seedCachedTerminal(page: Page) { - return page.addInitScript( - ({ terminalKey, ptyID }) => { - localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } })) - localStorage.setItem( - terminalKey, - JSON.stringify({ - active: ptyID, - all: [{ id: ptyID, title: "Terminal 1", titleNumber: 1 }], - }), - ) - }, - { terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID }, - ) -} - -function ptyLocation() { - return { directory, project: { id: projectID, directory } } -} - -function ptyInfo(id: string, title: string) { - return { id, title, command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 } -} diff --git a/packages/app/e2e/regression/terminal-hidden.spec.ts b/packages/app/e2e/regression/terminal-hidden.spec.ts index 8e08d60ff2..73821580af 100644 --- a/packages/app/e2e/regression/terminal-hidden.spec.ts +++ b/packages/app/e2e/regression/terminal-hidden.spec.ts @@ -10,7 +10,6 @@ const title = "Hidden terminal regression" test("unmounts the terminal panel while it is hidden", async ({ page }) => { await page.setViewportSize({ width: 1400, height: 900 }) await mockOpenCodeServer(page, { - protocol: "v2", directory, project: { id: projectID, @@ -44,53 +43,17 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => { ], pageMessages: () => ({ items: [] }), }) - await page.route("**/api/pty*", (route) => + await page.route("**/pty", (route) => route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ - location: { directory, project: { id: projectID, directory } }, - data: { - id: "pty_hidden_terminal", - title: "Terminal 1", - command: "cmd.exe", - args: [], - cwd: directory, - status: "running", - pid: 1, - }, - }), + body: JSON.stringify({ id: "pty_hidden_terminal", title: "Terminal 1" }), }), ) - await page.route("**/api/pty/pty_hidden_terminal*", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - location: { directory, project: { id: projectID, directory } }, - data: { - id: "pty_hidden_terminal", - title: "Terminal 1", - command: "cmd.exe", - args: [], - cwd: directory, - status: "running", - pid: 1, - }, - }), - }), + await page.route("**/pty/pty_hidden_terminal", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), ) - await page.route("**/api/pty/pty_hidden_terminal/connect-token*", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - location: { directory, project: { id: projectID, directory } }, - data: { ticket: "e2e-ticket", expires_in: 60 }, - }), - }), - ) - await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined) + await page.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await expectSessionTitle(page, title) diff --git a/packages/app/e2e/regression/terminal-tab-switch.spec.ts b/packages/app/e2e/regression/terminal-tab-switch.spec.ts index 165920753c..cbb72958ad 100644 --- a/packages/app/e2e/regression/terminal-tab-switch.spec.ts +++ b/packages/app/e2e/regression/terminal-tab-switch.spec.ts @@ -29,10 +29,6 @@ test("keeps the terminal session alive when switching session tabs in a workspac const terminal = page.locator('[data-component="terminal"]') await expect(terminal).toBeVisible() await expect.poll(() => connections.length).toBe(1) - const connection = new URL(connections[0]!) - expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`) - expect(connection.searchParams.get("location[directory]")).toBe(directory) - expect(connection.searchParams.get("ticket")).toBeNull() await writeProbe(page) await switchTab(page, titleB) @@ -66,7 +62,6 @@ async function readProbe(page: Page) { async function setup(page: Page) { await mockOpenCodeServer(page, { - protocol: "v2", directory, project: { id: projectID, @@ -90,33 +85,26 @@ async function setup(page: Page) { sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], pageMessages: () => ({ items: [] }), }) - await page.route("**/api/pty*", (route) => + await page.route("**/pty", (route) => route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }), + body: JSON.stringify({ id: ptyID, title: "Terminal 1" }), }), ) - await page.route(`**/api/pty/${ptyID}*`, (route) => + await page.route(`**/pty/${ptyID}`, (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), + ) + await page.route(`**/pty/${ptyID}/connect-token*`, (route) => route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }), - }), - ) - await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => { - expect(route.request().headers()["x-opencode-ticket"]).toBe("1") - const url = new URL(route.request().url()) - expect(url.searchParams.get("location[directory]")).toBe(directory) - return route.fulfill({ status: 200, contentType: "application/json", headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), - }) - }) + body: JSON.stringify({ ticket: "e2e-ticket" }), + }), + ) const connections: string[] = [] - await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => { + await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), (ws) => { connections.push(ws.url()) }) @@ -155,11 +143,3 @@ function session(id: string, title: string, created: number) { function sessionHref(sessionID: string) { return `/server/${base64Encode(server)}/session/${sessionID}` } - -function ptyLocation() { - return { directory, project: { id: projectID, directory } } -} - -function ptyInfo() { - return { id: ptyID, title: "Terminal 1", command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 } -} diff --git a/packages/app/e2e/reproduction/timeline-suspense/index.html b/packages/app/e2e/reproduction/timeline-suspense/index.html deleted file mode 100644 index 995008a9f7..0000000000 --- a/packages/app/e2e/reproduction/timeline-suspense/index.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - Timeline Suspense Reproduction - - - -
- - - diff --git a/packages/app/e2e/reproduction/timeline-suspense/main.tsx b/packages/app/e2e/reproduction/timeline-suspense/main.tsx deleted file mode 100644 index cdcd809be3..0000000000 --- a/packages/app/e2e/reproduction/timeline-suspense/main.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import { createResource, createSignal, For, onMount, Suspense } from "solid-js" -import { render } from "solid-js/web" -import { createVirtualizer, observeElementOffset, observeElementRect } from "@tanstack/solid-virtual" -import { observeElementOffsetReconnectAware } from "../../../src/pages/session/timeline/observe-element-offset" - -const rowCount = 2_000 -const rowHeight = 40 -const parameters = new URLSearchParams(location.search) -const resourceMode = parameters.get("resource") === "guard" ? "guard" : "baseline" -const reconnectMode = parameters.get("reconnect") === "candidate" ? "candidate" : "baseline" - -type MutationEvent = { - kind: "removed" | "added" - callbackTime: number - callbackFrame: number - routeConnectedInCallback: boolean - nativeOffsetInCallback: number -} - -type Snapshot = { - mode: { - resource: "baseline" | "guard" - reconnect: "baseline" | "candidate" - } - operation: { - sequence: number - phase: string - time: number - frame: number - } - resourceState: string - routeConnected: boolean - viewportConnected: boolean - viewportOwnedByRoute: boolean - sameRoute: boolean - sameViewport: boolean - sameSurface: boolean - sameMountedRows: boolean - nativeOffset: number - coreOffset: number - rangeStart: number - rangeEnd: number - indexes: number[] - domIndexes: number[] - logicalSurfaceHeight: number - renderedSurfaceHeight: number - viewportClientHeight: number - viewportScrollHeight: number - visibleRows: number - minimumRowTop: number - domScrollEvents: number - lastScrollTrusted: boolean - coreOffsetCallbackCalls: number - offsetCallbackSources: "observer"[] - rectObserverCallbacks: number - ignoredDetachedZeroRects: number - syntheticScrollDispatches: number - mutationEvents: MutationEvent[] -} - -declare global { - interface Window { - timelineSuspense: { - prepare: () => Promise - trigger: () => void - resolve: () => void - frames: (count?: number) => Promise - snapshot: () => Snapshot - } - } -} - -function App() { - const [refresh, setRefresh] = createSignal(false) - let resolveResource: (() => void) | undefined - const [resource] = createResource( - refresh, - (version) => - new Promise((resolve) => { - resolveResource = () => resolve(`settled-${version}`) - }), - { initialValue: "settled" }, - ) - - function Route() { - let route: HTMLElement | undefined - let viewport: HTMLDivElement | undefined - let surface: HTMLDivElement | undefined - let initialRoute: HTMLElement | undefined - let initialViewport: HTMLDivElement | undefined - let initialSurface: HTMLDivElement | undefined - let initialRows: HTMLElement[] = [] - let phase = "mounting" - let browserFrame = 0 - let snapshotSequence = 0 - let domScrollEvents = 0 - let lastScrollTrusted = false - let coreOffsetCallbackCalls = 0 - let rectObserverCallbacks = 0 - let ignoredDetachedZeroRects = 0 - const offsetCallbackSources: "observer"[] = [] - const mutationEvents: MutationEvent[] = [] - const virtualizer = createVirtualizer({ - count: rowCount, - getScrollElement: () => viewport ?? null, - estimateSize: () => rowHeight, - initialRect: { width: 900, height: 600 }, - overscan: 2, - observeElementRect: (instance, callback) => - observeElementRect(instance, (rect) => { - rectObserverCallbacks++ - // A fixed 600px viewport has no usable geometry while detached. Keep the last connected rect. - if (!instance.scrollElement?.isConnected && rect.height === 0) { - ignoredDetachedZeroRects++ - return - } - callback(rect) - }), - observeElementOffset: (instance, callback) => { - const deliver = (offset: number, isScrolling: boolean) => { - coreOffsetCallbackCalls++ - offsetCallbackSources.push("observer") - callback(offset, isScrolling) - } - if (reconnectMode === "candidate") return observeElementOffsetReconnectAware(instance, deliver) - return observeElementOffset(instance, deliver) - }, - }) - - const frames = async (count = 2) => { - for (let index = 0; index < count; index++) { - await new Promise((resolve) => requestAnimationFrame(() => resolve())) - } - } - const mountedRows = () => [...(surface?.querySelectorAll("[data-row-index]") ?? [])] - const snapshot = (): Snapshot => { - const rows = mountedRows() - const view = viewport?.getBoundingClientRect() - const visibleRows = - viewport?.isConnected && view - ? rows.filter((row) => { - const rect = row.getBoundingClientRect() - return rect.bottom > view.top && rect.top < view.bottom - }).length - : 0 - return { - mode: { resource: resourceMode, reconnect: reconnectMode }, - operation: { - sequence: ++snapshotSequence, - phase, - time: performance.now(), - frame: browserFrame, - }, - resourceState: resource.state, - routeConnected: route?.isConnected ?? false, - viewportConnected: viewport?.isConnected ?? false, - viewportOwnedByRoute: !!route && !!viewport && route.contains(viewport), - sameRoute: route === initialRoute, - sameViewport: viewport === initialViewport, - sameSurface: surface === initialSurface, - sameMountedRows: - initialRows.length > 0 && - initialRows.length === rows.length && - initialRows.every((row, index) => row === rows[index]), - nativeOffset: viewport?.scrollTop ?? -1, - coreOffset: virtualizer.scrollOffset ?? -1, - rangeStart: virtualizer.range?.startIndex ?? -1, - rangeEnd: virtualizer.range?.endIndex ?? -1, - indexes: virtualizer.getVirtualItems().map((item) => item.index), - domIndexes: rows.map((row) => Number(row.dataset.rowIndex)), - logicalSurfaceHeight: Number.parseFloat(surface?.style.height ?? "-1"), - renderedSurfaceHeight: surface?.getBoundingClientRect().height ?? -1, - viewportClientHeight: viewport?.clientHeight ?? -1, - viewportScrollHeight: viewport?.scrollHeight ?? -1, - visibleRows, - minimumRowTop: - rows.length && view ? Math.min(...rows.map((row) => row.getBoundingClientRect().top - view.top)) : -1, - domScrollEvents, - lastScrollTrusted, - coreOffsetCallbackCalls, - offsetCallbackSources: [...offsetCallbackSources], - rectObserverCallbacks, - ignoredDetachedZeroRects, - syntheticScrollDispatches: 0, - mutationEvents: mutationEvents.map((event) => ({ ...event })), - } - } - - onMount(() => { - if (!route || !viewport || !surface) throw new Error("Timeline fixture did not mount") - const routeRoot = route.parentElement - if (!routeRoot) throw new Error("Timeline route root did not mount") - initialRoute = route - initialViewport = viewport - initialSurface = surface - viewport.addEventListener("scroll", (event) => { - domScrollEvents++ - lastScrollTrusted = event.isTrusted - }) - const countFrames = () => { - browserFrame++ - requestAnimationFrame(countFrames) - } - requestAnimationFrame(countFrames) - new MutationObserver((records) => { - const callbackTime = performance.now() - records.forEach((record) => { - ;([...(record.removedNodes ?? [])] as Node[]).forEach((node) => { - if (node !== route) return - phase = "detached" - mutationEvents.push({ - kind: "removed", - callbackTime, - callbackFrame: browserFrame, - routeConnectedInCallback: route.isConnected, - nativeOffsetInCallback: viewport.scrollTop, - }) - }) - ;([...(record.addedNodes ?? [])] as Node[]).forEach((node) => { - if (node !== route) return - phase = "reinserted" - mutationEvents.push({ - kind: "added", - callbackTime, - callbackFrame: browserFrame, - routeConnectedInCallback: route.isConnected, - nativeOffsetInCallback: viewport.scrollTop, - }) - }) - }) - }).observe(routeRoot, { childList: true }) - window.timelineSuspense = { - prepare: async () => { - phase = "preparing" - await frames(2) - viewport.scrollTop = viewport.scrollHeight - await frames(3) - await new Promise((resolve) => setTimeout(resolve, 200)) - await frames(2) - initialRows = mountedRows() - phase = "prepared" - return snapshot() - }, - trigger: () => { - phase = "triggering" - setRefresh(true) - }, - resolve: () => { - if (!resolveResource) throw new Error("Resource is not pending") - phase = "resolving" - resolveResource() - }, - frames, - snapshot, - } - }) - - return ( -
- -
-
- - {(item) => ( -
- logical row {item.index} -
- )} -
-
-
-
- ) - } - - return ( -
- - - -
- ) -} - -render(() => , document.getElementById("root")!) diff --git a/packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts b/packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts deleted file mode 100644 index 7fff1737ae..0000000000 --- a/packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { defineConfig, devices } from "@playwright/test" - -const port = Number(process.env.PLAYWRIGHT_TIMELINE_SUSPENSE_PORT ?? 4317) - -export default defineConfig({ - testDir: ".", - testMatch: "timeline-suspense.repro.ts", - outputDir: "../../test-results/timeline-suspense", - fullyParallel: false, - workers: 1, - retries: 0, - reporter: "line", - timeout: 30_000, - expect: { - timeout: 10_000, - }, - webServer: { - command: `bunx vite --config vite.config.ts --host 127.0.0.1 --port ${port} --strictPort`, - cwd: import.meta.dirname, - url: `http://127.0.0.1:${port}`, - reuseExistingServer: false, - }, - use: { - baseURL: `http://127.0.0.1:${port}`, - trace: "retain-on-failure", - screenshot: "only-on-failure", - }, - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, - ], -}) diff --git a/packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts b/packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts deleted file mode 100644 index 3edae33a9b..0000000000 --- a/packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { expect, test, type Page } from "@playwright/test" - -test.beforeEach(async ({ page }) => { - page.on("pageerror", (error) => console.error(error)) - await page.goto("/") - await expect.poll(() => page.evaluate(() => !!window.timelineSuspense)).toBe(true) -}) - -test("desired: preserves visible timeline continuity across descendant resource suspension", async ({ page }) => { - await page.goto("/?reconnect=candidate") - await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.reconnect)).toBe("candidate") - const before = await prepare(page) - await triggerBaselineSuspension(page) - const pending = await page.evaluate(() => window.timelineSuspense.snapshot()) - expect(pending.nativeOffset).toBe(0) - expect(pending.coreOffset).toBe(before.coreOffset) - expect(pending.indexes).toEqual(before.indexes) - expect(pending.sameRoute).toBe(true) - expect(pending.sameViewport).toBe(true) - expect(pending.sameSurface).toBe(true) - expect(pending.sameMountedRows).toBe(true) - - await resolveSuspension(page) - await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().coreOffset)).toBe(0) - await page.waitForTimeout(250) - await page.evaluate(() => window.timelineSuspense.frames(2)) - const after = await page.evaluate(() => window.timelineSuspense.snapshot()) - expect(after.sameRoute).toBe(true) - expect(after.sameViewport).toBe(true) - expect(after.sameSurface).toBe(true) - expect(after.nativeOffset).toBe(0) - expect(after.coreOffset).toBe(0) - expect(after.rangeStart).toBeLessThan(10) - expect(after.visibleRows, diagnostic({ before, pending, after })).toBeGreaterThan(0) - expect(after.domScrollEvents).toBe(before.domScrollEvents) - expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls + 1) - expect(after.offsetCallbackSources.at(-1)).toBe("observer") - expect(after.syntheticScrollDispatches).toBe(0) -}) - -test("forensic: proves detached same-node viewport leaves TanStack's bottom range blank until a real scroll", async ({ - page, -}) => { - const before = await prepare(page) - const beforeRows = before.domIndexes - expect(before.mode).toEqual({ resource: "baseline", reconnect: "baseline" }) - expect(before.logicalSurfaceHeight).toBe(80_000) - expect(before.renderedSurfaceHeight).toBe(80_000) - expect(before.viewportClientHeight).toBe(600) - expect(before.viewportScrollHeight).toBe(80_000) - expect(before.rangeStart).toBeGreaterThan(1_900) - expect(before.nativeOffset).toBe(before.coreOffset) - expect(before.visibleRows).toBeGreaterThan(0) - - await triggerBaselineSuspension(page) - const pending = await page.evaluate(() => window.timelineSuspense.snapshot()) - expect(pending.resourceState).toBe("refreshing") - expect(pending.routeConnected).toBe(false) - expect(pending.viewportConnected).toBe(false) - expect(pending.viewportOwnedByRoute).toBe(true) - expect(pending.nativeOffset).toBe(0) - expect(pending.coreOffset).toBe(before.coreOffset) - expect(pending.rangeStart).toBe(before.rangeStart) - expect(pending.rangeEnd).toBe(before.rangeEnd) - expect(pending.indexes).toEqual(before.indexes) - expect(pending.domIndexes).toEqual(beforeRows) - expect(pending.sameMountedRows).toBe(true) - expect(pending.domScrollEvents).toBe(before.domScrollEvents) - expect(pending.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls) - expect(pending.ignoredDetachedZeroRects).toBeGreaterThan(before.ignoredDetachedZeroRects) - expect(pending.mutationEvents).toHaveLength(1) - expect(pending.mutationEvents[0]).toMatchObject({ - kind: "removed", - routeConnectedInCallback: false, - nativeOffsetInCallback: 0, - }) - expect(pending.mutationEvents[0]!.callbackTime).toBeLessThanOrEqual(pending.operation.time) - expect(pending.mutationEvents[0]!.callbackFrame).toBeLessThanOrEqual(pending.operation.frame) - - const after = await resolveSuspension(page) - expect(after.resourceState).toBe("ready") - expect(after.routeConnected).toBe(true) - expect(after.viewportConnected).toBe(true) - expect(after.viewportOwnedByRoute).toBe(true) - expect(after.sameRoute).toBe(true) - expect(after.sameViewport).toBe(true) - expect(after.sameSurface).toBe(true) - expect(after.sameMountedRows).toBe(true) - expect(after.nativeOffset).toBe(0) - expect(after.coreOffset).toBe(before.coreOffset) - expect(after.rangeStart).toBe(before.rangeStart) - expect(after.rangeEnd).toBe(before.rangeEnd) - expect(after.indexes).toEqual(before.indexes) - expect(after.domIndexes).toEqual(beforeRows) - expect(after.domScrollEvents).toBe(before.domScrollEvents) - expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls) - expect(after.mutationEvents).toHaveLength(2) - expect(after.mutationEvents[1]).toMatchObject({ - kind: "added", - routeConnectedInCallback: true, - nativeOffsetInCallback: 0, - }) - expect(after.mutationEvents[1]!.callbackTime).toBeLessThanOrEqual(after.operation.time) - expect(after.mutationEvents[1]!.callbackFrame).toBeLessThanOrEqual(after.operation.frame) - expect(after.visibleRows).toBe(0) - expect(after.minimumRowTop).toBeGreaterThan(50_000) - expect(after.syntheticScrollDispatches).toBe(0) - - await page.locator("[data-viewport]").hover() - await page.mouse.wheel(0, 80) - await expect - .poll(() => - page.evaluate(() => { - const value = window.timelineSuspense.snapshot() - return value.nativeOffset > 0 && value.coreOffset === value.nativeOffset - }), - ) - .toBe(true) - await page.evaluate(() => window.timelineSuspense.frames(2)) - const recovered = await page.evaluate(() => window.timelineSuspense.snapshot()) - expect(recovered.domScrollEvents).toBeGreaterThan(after.domScrollEvents) - expect(recovered.coreOffsetCallbackCalls).toBeGreaterThan(after.coreOffsetCallbackCalls) - expect(recovered.offsetCallbackSources.at(-1)).toBe("observer") - expect(recovered.lastScrollTrusted).toBe(true) - expect(recovered.rangeStart).toBeLessThan(10) - expect(recovered.visibleRows).toBeGreaterThan(0) -}) - -test("matrix: fixture-only settled-resource guard keeps the route connected", async ({ page }) => { - await page.goto("/?resource=guard") - await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.resource)).toBe("guard") - const before = await prepare(page) - - await page.evaluate(() => window.timelineSuspense.trigger()) - await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing") - await page.evaluate(() => window.timelineSuspense.frames(3)) - const pending = await page.evaluate(() => window.timelineSuspense.snapshot()) - expect(pending.routeConnected).toBe(true) - expect(pending.mutationEvents).toEqual([]) - expect(pending.nativeOffset).toBe(before.nativeOffset) - expect(pending.coreOffset).toBe(before.coreOffset) - expect(pending.visibleRows).toBeGreaterThan(0) - - const after = await resolveSuspension(page) - expect(after.routeConnected).toBe(true) - expect(after.nativeOffset).toBe(before.nativeOffset) - expect(after.coreOffset).toBe(before.coreOffset) - expect(after.visibleRows).toBeGreaterThan(0) -}) - -async function prepare(page: Page) { - const before = await page.evaluate(() => window.timelineSuspense.prepare()) - expect(before.routeConnected).toBe(true) - expect(before.viewportConnected).toBe(true) - expect(before.viewportOwnedByRoute).toBe(true) - expect(before.sameMountedRows).toBe(true) - expect(before.rangeStart).toBeGreaterThan(1_900) - expect(before.nativeOffset).toBe(before.coreOffset) - return before -} - -async function triggerBaselineSuspension(page: Page) { - await page.evaluate(() => window.timelineSuspense.trigger()) - await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing") - await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(false) - await page.evaluate(() => window.timelineSuspense.frames(3)) -} - -async function resolveSuspension(page: Page) { - await page.evaluate(() => window.timelineSuspense.resolve()) - await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("ready") - await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(true) - await page.evaluate(() => window.timelineSuspense.frames(3)) - return page.evaluate(() => window.timelineSuspense.snapshot()) -} - -function diagnostic(value: unknown) { - return JSON.stringify(value, null, 2) -} diff --git a/packages/app/e2e/reproduction/timeline-suspense/vite.config.ts b/packages/app/e2e/reproduction/timeline-suspense/vite.config.ts deleted file mode 100644 index efb75ab731..0000000000 --- a/packages/app/e2e/reproduction/timeline-suspense/vite.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "vite" -import solid from "vite-plugin-solid" - -export default defineConfig({ - root: import.meta.dirname, - plugins: [solid()], -}) diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index bdf3f55bdc..a73cc0ccdd 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -736,5 +736,5 @@ async function switchTitlebarSession(page: Page, sessionID: string, title: strin } async function expectSessionReady(page: Page) { - await expectAppVisible(page.getByRole("textbox", { name: "Prompt" })) + await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i })) } diff --git a/packages/app/e2e/tsconfig.json b/packages/app/e2e/tsconfig.json index 4a6046e4fa..53aacbda02 100644 --- a/packages/app/e2e/tsconfig.json +++ b/packages/app/e2e/tsconfig.json @@ -10,9 +10,6 @@ "./performance/timeline-stability/fixture.test.ts", "./performance/timeline-stability/fixture.ts", "./performance/unit/visual-stability.test.ts", - "./reproduction/timeline-suspense/**/*.ts", - "./reproduction/timeline-suspense/**/*.tsx", - "../src/pages/session/timeline/observe-element-offset.ts", "./regression/new-session-panel-corner.spec.ts", "./regression/session-timeline-context-resize.spec.ts", "./utils/**/*.ts" diff --git a/packages/app/e2e/user-story/model-selection-flow.spec.ts b/packages/app/e2e/user-story/model-selection-flow.spec.ts deleted file mode 100644 index 22b8bb41fe..0000000000 --- a/packages/app/e2e/user-story/model-selection-flow.spec.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { expect, test } from "@playwright/test" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectAppVisible } from "../utils/waits" - -const directory = "C:/OpenCode/NewProject" - -test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => { - let connectedGo = false - let pendingGo = false - const connections: Array<{ integrationID: string; body: unknown }> = [] - - await mockOpenCodeServer(page, { - directory, - project: { - id: "proj_model_selection_flow", - worktree: directory, - vcs: "git", - name: "NewProject", - time: { created: 1_700_000_000_000, updated: 1_700_000_000_000 }, - sandboxes: [], - }, - provider: () => ({ - all: [ - { - id: "opencode", - name: "OpenCode", - models: { - "free-model": { - id: "free-model", - name: "Free Model", - cost: { input: 0, output: 0 }, - limit: { context: 200_000 }, - }, - }, - }, - { - id: "opencode-go", - name: "OpenCode Go", - models: { - "go-model-1": { - id: "go-model-1", - name: "Go Model 1", - cost: { input: 1, output: 1 }, - limit: { context: 200_000 }, - }, - }, - }, - ], - connected: connectedGo ? ["opencode", "opencode-go"] : ["opencode"], - default: { providerID: "opencode", modelID: "free-model" }, - }), - integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] }, - onConnectKey: (input) => { - connections.push(input) - if (input.integrationID === "opencode-go") pendingGo = true - }, - onInstanceDispose: () => { - if (pendingGo) connectedGo = true - }, - sessions: [], - pageMessages: () => ({ items: [] }), - fileList: (path) => - path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }], - findFiles: () => ["NewProject"], - }) - await page.addInitScript(() => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } })) - }) - - await page.goto("/") - const addProject = page.locator('[data-action="home-add-project-row"]') - await expectAppVisible(addProject) - await addProject.click() - await page.locator("[data-directory-path]").click() - - await page.locator('[data-action="home-new-session"]').click() - await expectAppVisible(page.locator('[data-component="prompt-input-v2"]')) - - const modelControl = page.locator('[data-action="prompt-model"]') - await modelControl.click() - await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode") - - await page.locator('[data-provider-id="opencode-go"]').click() - await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key") - await page.locator('[data-action="provider-connect-submit"]').click() - await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0) - expect(connections).toEqual([{ integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } }]) - - await expect(modelControl).toHaveAttribute("data-control-type", "popover") - await modelControl.click() - const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]') - await expect(goModel).toBeVisible() - await goModel.click() - - await expect(modelControl).toContainText("Go Model 1") -}) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 76987421b6..e1946b8fba 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -4,11 +4,7 @@ const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/sta const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"]) export interface MockServerConfig { - protocol?: "v1" | "v2" - provider: unknown | (() => unknown) - integrationMethods?: Record - onConnectKey?: (input: { integrationID: string; body: unknown }) => void - onInstanceDispose?: () => void + provider: unknown directory: string project: unknown sessions: ({ id: string } & Record)[] @@ -27,13 +23,14 @@ export interface MockServerConfig { fileList?: (path: string) => unknown | Promise fileContent?: (path: string) => unknown | Promise findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown - sessionStatus?: Record | (() => Record) + sessionStatus?: unknown } export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const cursors = new Map() let nextCursor = 0 const staticRoutes: Record = { + "/provider": config.provider, "/path": { state: config.directory, config: config.directory, @@ -57,47 +54,14 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (url.port !== targetPort && url.port !== appPort) return route.fallback() const path = url.pathname - if (path === "/global/event" || path === "/event" || path === "/api/event") { - const events = config.events?.() - return sse( - route, - path === "/api/event" - ? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])] - : [ - ...(path === "/global/event" - ? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }] - : []), - ...(events ?? []), - ], - config.eventRetry, - ) - } - if (path === "/global/health") - return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true }) - if (path === "/api/health" && config.protocol === "v2") - return json(route, { healthy: true, version: "2.0.0", pid: 1 }) - if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true }) - if (path === "/provider") - return json(route, typeof config.provider === "function" ? config.provider() : config.provider) - if (path === "/provider/auth") return json(route, config.integrationMethods ?? {}) - const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1] - if (legacyAuth && route.request().method() === "PUT") { - config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() }) - return json(route, true) - } - if (path === "/instance/dispose" && route.request().method() === "POST") { - config.onInstanceDispose?.() - return json(route, true) - } + if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry) + if (path === "/global/health") return json(route, { healthy: true }) + if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false }) if (path === "/permission") return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) if (path === "/question") return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? [])) - if (path === "/session/status") - return json( - route, - typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}), - ) + if (path === "/session/status") return json(route, config.sessionStatus ?? {}) if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff) if (path === "/file" && config.fileList) return json(route, await config.fileList(url.searchParams.get("path") ?? "")) @@ -120,138 +84,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }, data: [], }) - if (path === "/api/agent") - return json(route, { - location: location(config), - data: [ - { - id: "build", - name: "Build", - mode: "primary", - hidden: false, - request: { settings: {}, headers: {}, body: {} }, - permissions: [], - }, - ], - }) - if (path === "/api/command") return json(route, { location: location(config), data: [] }) - if (path === "/api/mcp") return json(route, { location: location(config), data: [] }) - if (path === "/api/mcp/resource") - return json(route, { location: location(config), data: { resources: [], templates: [] } }) - const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1] - if (integration && route.request().method() === "GET") - return json(route, { - location: location(config), - data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] }, - }) - const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1] - if (integrationConnect && route.request().method() === "POST") { - config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() }) - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } - if (path === "/api/project") return json(route, [config.project]) - if (path === "/api/project/current") - return json(route, { id: (config.project as { id?: string }).id, directory: config.directory }) - if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project) - if (path === "/api/path") - return json(route, { - state: config.directory, - config: config.directory, - worktree: config.directory, - directory: config.directory, - home: "C:/OpenCode", - }) - if (path === "/api/permission/request") - return json(route, { - location: location(config), - data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map( - currentPermission, - ), - }) - if (path === "/api/question/request") - return json(route, { - location: location(config), - data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []), - }) - if (path === "/api/vcs") - return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } }) - if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] }) - if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] }) - if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] }) - if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path)) - return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }) if (emptyObject.has(path)) return json(route, {}) if (emptyList.has(path)) return json(route, []) - if (path === "/api/session") { - const directory = url.searchParams.get("directory") - const parentID = url.searchParams.get("parentID") - const limit = Number(url.searchParams.get("limit") ?? 50) - const offset = Number(url.searchParams.get("cursor") ?? 0) - const sessions = config.sessions - .filter((session) => !directory || session.directory === directory) - .filter((session) => parentID !== "null" || session.parentID === undefined) - .filter((session) => { - const search = url.searchParams.get("search")?.toLowerCase() - return ( - !search || - String(session.title ?? "") - .toLowerCase() - .includes(search) - ) - }) - const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions - const data = ordered.slice(offset, offset + limit) - const next = offset + limit < ordered.length ? String(offset + limit) : undefined - return json(route, { - data: data.map((session) => currentSession(session, config.directory)), - cursor: { next }, - }) - } - if (path === "/api/session/active") { - const statuses = (config.sessionStatus ?? {}) as Record - return json(route, { - data: Object.fromEntries( - Object.entries(statuses).flatMap(([id, status]) => - status.type === "idle" ? [] : [[id, { type: "running" }]], - ), - ), - }) - } - if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") { - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } - if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") { - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } - if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") { - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } - if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") { - return json(route, true) - } - if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") { - return json(route, true) - } - if ( - /^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) && - route.request().method() === "POST" - ) { - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } - if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") { - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } if (path in staticRoutes) return json(route, staticRoutes[path]) - const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/) - if (currentSessionMatch) { - const session = config.sessions.find((item) => item.id === currentSessionMatch[1]) - if (!session) return json(route, { error: "Session not found" }, undefined, 404) - return json(route, { - data: currentSession(session, config.directory), - }) - } - const sessionMatch = path.match(/^\/session\/([^/]+)$/) if (sessionMatch) { const session = config.sessions.find((s) => s.id === sessionMatch[1]) @@ -274,24 +110,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? []) if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) - const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/) - if (currentMessagesMatch) { - const token = url.searchParams.get("cursor") ?? undefined - const before = token ? cursors.get(token) : undefined - if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400) - config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" }) - await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before }) - if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) - const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before) - config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" }) - const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined - if (cursor) cursors.set(cursor, pageData.cursor!) - return json(route, { - data: pageData.items.map(currentMessage).reverse(), - cursor: { next: cursor }, - }) - } - const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) if (messagesMatch) { const token = url.searchParams.get("before") ?? undefined @@ -314,115 +132,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }) } -function location(config: MockServerConfig) { - return { - directory: config.directory, - project: { id: (config.project as { id?: string }).id, directory: config.directory }, - } -} - -function currentPermission(value: unknown) { - const permission = value as Record - if (permission.action) return permission - const tool = permission.tool as { messageID?: string; callID?: string } | undefined - return { - id: permission.id, - sessionID: permission.sessionID, - action: permission.permission, - resources: permission.patterns ?? [], - save: permission.always, - metadata: permission.metadata, - source: - tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined, - } -} - -export function currentSession(session: { id: string } & Record, fallbackDirectory?: string) { - const time = session.time && typeof session.time === "object" ? session.time : {} - return { - id: session.id, - parentID: session.parentID, - projectID: session.projectID ?? "project", - agent: session.agent ?? "build", - model: session.model ?? { id: "mock-model", providerID: "mock-provider" }, - cost: session.cost ?? 0, - tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { - created: "created" in time && typeof time.created === "number" ? time.created : 0, - updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0, - ...(session.time && typeof session.time === "object" && "archived" in session.time - ? { archived: session.time.archived } - : {}), - }, - title: session.title ?? session.id, - location: { - directory: typeof session.directory === "string" ? session.directory : fallbackDirectory, - ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}), - }, - subpath: session.path, - revert: session.revert, - } -} - -function currentMessage(value: unknown) { - const item = value as { - info: Record & { id: string; role: "user" | "assistant"; time: { created: number } } - parts: Array & { type: string }> - } - if (item.info.role === "user") { - return { - id: item.info.id, - type: "user", - time: item.info.time, - text: item.parts - .flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : [])) - .join("\n"), - } - } - return { - id: item.info.id, - type: "assistant", - time: item.info.time, - agent: item.info.agent ?? "build", - model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" }, - cost: item.info.cost, - tokens: item.info.tokens, - error: item.info.error, - content: item.parts.flatMap((part) => { - if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }] - if (part.type !== "tool") return [] - const state = part.state as Record - return [ - { - type: "tool", - id: part.id, - name: part.tool, - time: state.time ?? { created: item.info.time.created }, - state: - state.status === "pending" - ? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) } - : state.status === "completed" - ? { - status: "completed", - input: state.input ?? {}, - structured: state.metadata ?? {}, - content: [{ type: "text", text: state.output ?? "" }], - } - : state.status === "error" - ? { - status: "error", - input: state.input ?? {}, - structured: state.metadata ?? {}, - content: [], - error: { type: "ToolError", message: state.error ?? "Tool failed" }, - } - : { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] }, - }, - ] - }), - } -} - function json(route: Route, body: unknown, headers?: Record, status = 200) { return route.fulfill({ status, @@ -443,18 +152,3 @@ function sse(route: Route, events?: unknown[], retry?: number) { body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`, }) } - -function currentEvent(input: unknown) { - if (!input || typeof input !== "object" || !("payload" in input)) return input - const envelope = input as { directory?: string; payload?: unknown } - if (!envelope.payload || typeof envelope.payload !== "object") return input - const payload = envelope.payload as { id?: string; type?: string; properties?: unknown } - if (!payload.type) return input - return { - id: payload.id ?? `evt_mock_${Date.now()}`, - created: Date.now(), - type: payload.type, - data: payload.properties ?? {}, - location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined, - } -} diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index b0e3b74c6d..186962998d 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -3,7 +3,7 @@ import type { Page } from "@playwright/test" export type SseConnectionRecord = { id: number url: string - path: "/global/event" | "/event" | "/api/event" + path: "/global/event" | "/event" headers: Record openedAt: number endedAt?: number @@ -93,21 +93,6 @@ export async function installSseTransport( eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`, `data: ${JSON.stringify(payload)}\n\n`, ].join("") - const currentEvent = (input: unknown) => { - if (!input || typeof input !== "object" || !("payload" in input)) return input - const envelope = input as { directory?: string; payload?: unknown } - if (!envelope.payload || typeof envelope.payload !== "object") return input - const payload = envelope.payload as { id?: string; type?: string; properties?: unknown } - if (!payload.type) return input - return { - id: payload.id ?? `evt_mock_${Date.now()}`, - created: Date.now(), - type: payload.type, - data: payload.properties ?? {}, - location: - envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined, - } - } const acknowledge = ( connection: Connection, bytes: number, @@ -155,13 +140,15 @@ export async function installSseTransport( output.forEach((chunk) => connection.controller.enqueue(chunk)) return acknowledge(connection, input.bytes.length, output.length) } - const encoded = input.deliveries.map((delivery) => { - const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload - return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) } - }) + const encoded = input.deliveries.map((delivery) => ({ + delivery, + bytes: encoder.encode(frame(delivery.payload, delivery.options)), + })) encoded.forEach((item) => marker(item.delivery.options?.marker)) if (input.burst) { - const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join("")) + const bytes = encoder.encode( + encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""), + ) connection.controller.enqueue(bytes) return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id)) } @@ -174,11 +161,8 @@ export async function installSseTransport( const fetch = (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init) const url = new URL(request.url) - if ( - url.origin !== server || - (url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event") - ) - return originalFetch(request) + if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event")) + return originalFetch(input, init) const id = ++nextConnectionID const record = { @@ -193,18 +177,6 @@ export async function installSseTransport( record.controller = controller connections.push(record) if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`)) - if (url.pathname === "/api/event") - controller.enqueue( - encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })), - ) - if (url.pathname === "/global/event") - controller.enqueue( - encoder.encode( - frame({ - payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} }, - }), - ), - ) request.signal.addEventListener( "abort", () => { diff --git a/packages/app/package.json b/packages/app/package.json index 2dd4a05d86..510e2015db 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.18.11", + "version": "1.17.18", "description": "", "type": "module", "exports": { @@ -19,9 +19,9 @@ "build": "vite build", "serve": "vite preview", "test": "bun run test:unit && bun run test:browser", - "test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src", + "test:unit": "bun test --only-failures --preload ./happydom.ts ./src", "test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser", - "test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src", + "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", "test:e2e": "playwright test", "test:e2e:local": "playwright test", "test:e2e:ui": "playwright test --ui", @@ -53,7 +53,6 @@ "@dnd-kit/helpers": "0.5.0", "@dnd-kit/solid": "0.5.0", "@kobalte/core": "catalog:", - "@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", @@ -82,7 +81,7 @@ "diff": "catalog:", "effect": "catalog:", "fuzzysort": "catalog:", - "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", + "ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553", "luxon": "catalog:", "marked": "catalog:", "marked-shiki": "catalog:", diff --git a/packages/app/src/addons/serialize.test.ts b/packages/app/src/addons/serialize.test.ts index d45af34c43..6828e60f84 100644 --- a/packages/app/src/addons/serialize.test.ts +++ b/packages/app/src/addons/serialize.test.ts @@ -37,14 +37,6 @@ function writeAndWait(term: Terminal, data: string): Promise { } describe("SerializeAddon", () => { - test("preserves color scheme reporting mode", async () => { - const { term, addon } = createTerminal() - await writeAndWait(term, "\x1b[?2031h") - - expect(addon.serialize().startsWith("\x1b[?2031h")).toBe(true) - expect(addon.serialize({ excludeModes: true }).startsWith("\x1b[?2031h")).toBe(false) - }) - describe("ANSI color preservation", () => { test("should preserve text attributes (bold, italic, underline)", async () => { const { term, addon } = createTerminal() diff --git a/packages/app/src/addons/serialize.ts b/packages/app/src/addons/serialize.ts index 515153488c..3823fb443a 100644 --- a/packages/app/src/addons/serialize.ts +++ b/packages/app/src/addons/serialize.ts @@ -89,13 +89,6 @@ const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined = return { active, normal, alternate } } -const getTerminalMode = (value: ITerminalCore, mode: number) => { - if (!isRecord(value)) return false - const terminal = value.wasmTerm - if (!isRecord(terminal) || typeof terminal.getMode !== "function") return false - return terminal.getMode(mode) === true -} - // ============================================================================ // Types // ============================================================================ @@ -551,8 +544,7 @@ export class SerializeAddon implements ITerminalAddon { return "" } - let content = !options?.excludeModes && getTerminalMode(this._terminal, 2031) ? "\u001b[?2031h" : "" - content += options?.range + let content = options?.range ? this._serializeBufferByRange(normalBuffer, options.range, true) : this._serializeBufferByScrollback(normalBuffer, options?.scrollback) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index f47c432e42..86263d172d 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -9,16 +9,7 @@ import { Font } from "@opencode-ai/ui/font" import { Splash } from "@opencode-ai/ui/logo" import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" -import { - type BaseRouterProps, - Navigate, - Route, - Router, - useLocation, - useNavigate, - useParams, - useSearchParams, -} from "@solidjs/router" +import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { Effect } from "effect" import { base64Encode } from "@opencode-ai/core/util/encode" @@ -38,7 +29,6 @@ import { Show, } from "solid-js" import { Dynamic } from "solid-js/web" -import { makeEventListener } from "@solid-primitives/event-listener" import { CommandProvider, useCommand, type CommandOption } from "@/context/command" import { CommentsProvider } from "@/context/comments" import { FileProvider } from "@/context/file" @@ -67,8 +57,7 @@ import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } import { createSessionLineage } from "@/pages/session/session-lineage" import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" -import { NewHome } from "@/pages/home" -import { LegacyHome } from "@/pages/home/legacy-home" +import { NewHome, LegacyHome } from "@/pages/home" const NewSession = lazy(() => import("@/pages/new-session")) @@ -164,7 +153,8 @@ function LegacyTargetSessionRedirect() { } // Wraps the non-draft routes. They are gated on (and keyed to) the globally selected -// server via ServerKey, then provide the server-scoped shell for that server. +// server via ServerKey, then provide the server-scoped shell (Permission/Layout/ +// Notification/Models + the visual Layout) for that server. function SelectedServerProviders(props: ParentProps) { return ( @@ -217,7 +207,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) { - + @@ -225,7 +215,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) { - + @@ -237,30 +227,6 @@ function UiI18nBridge(props: ParentProps) { return {props.children} } -function LayoutCompatibility(props: ParentProps) { - const global = useGlobal() - const navigate = useNavigate() - const server = useServer() - const settings = useSettings() - - createEffect(() => { - if (settings.general.newLayoutDesigns()) return - const current = server.current - if (!current) return - const protocol = global.ensureServerCtx(current).sdk.protocolKind() - if (protocol !== "v2") return - const next = global.servers.list().find((s) => { - if (ServerConnection.key(s) === ServerConnection.key(current)) return false - return global.ensureServerCtx(s).sdk.protocolKind() !== "v2" - }) - if (!next) return - navigate("/") - queueMicrotask(() => server.setActive(ServerConnection.key(next))) - }) - - return <>{props.children} -} - declare global { interface Window { __OPENCODE__?: { @@ -343,21 +309,24 @@ function DesktopCommands() { // Server-scoped providers shared by the legacy shell and the top-level new shell. type ServerScopedShellProps = ParentProps<{ directory?: () => string | undefined + sessionID?: () => string | undefined serverScoped?: JSX.Element }> function ServerScopedProviders(props: ServerScopedShellProps) { return ( - - {props.serverScoped} - {props.children} - + + + {props.serverScoped} + {props.children} + + ) } function LegacyServerScopedShell(props: ServerScopedShellProps) { return ( - + {props.children} ) @@ -373,6 +342,14 @@ function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) { ) } +function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) { + return ( + + {props.children} + + ) +} + // The draft page only renders the prompt composer, so it drops TerminalProvider. // FileProvider and CommentsProvider stay because PromptInput uses file search and comment context. function DraftProviders(props: ParentProps) { @@ -582,15 +559,13 @@ export function AppInterface(props: { component={props.router ?? Router} root={(routerProps) => ( - - - - - {routerProps.children} - - - - + + + + {routerProps.children} + + + )} > diff --git a/packages/app/src/assets/help/home.png b/packages/app/src/assets/help/home.png deleted file mode 100644 index aeca6d977a..0000000000 Binary files a/packages/app/src/assets/help/home.png and /dev/null differ diff --git a/packages/app/src/assets/help/introducing-tabs.mp4 b/packages/app/src/assets/help/introducing-tabs.mp4 index fd46be1f46..2bcc8aeef6 100644 Binary files a/packages/app/src/assets/help/introducing-tabs.mp4 and b/packages/app/src/assets/help/introducing-tabs.mp4 differ diff --git a/packages/app/src/assets/help/tabs.png b/packages/app/src/assets/help/tabs.png deleted file mode 100644 index d2c6e68a3d..0000000000 Binary files a/packages/app/src/assets/help/tabs.png and /dev/null differ diff --git a/packages/app/src/components/command-palette.ts b/packages/app/src/components/command-palette.ts index 8014ea1c43..b61a61977e 100644 --- a/packages/app/src/components/command-palette.ts +++ b/packages/app/src/components/command-palette.ts @@ -1,20 +1,17 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" import { getFilename } from "@opencode-ai/core/util/path" -import type { Project } from "@opencode-ai/sdk/v2/client" -import type { SessionInfo } from "@opencode-ai/client/promise" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useNavigate } from "@solidjs/router" import { createMemo, onCleanup } from "solid-js" -import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command" +import { useCommand, type CommandOption } from "@/context/command" import { useFile } from "@/context/file" -import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" -import { useLayout, type LocalProject } from "@/context/layout" -import { ServerConnection } from "@/context/server" -import { useServerSDK } from "@/context/server-sdk" -import { useTabs } from "@/context/tabs" -import { displayName, projectForSession } from "@/pages/layout/helpers" +import { useLayout } from "@/context/layout" +import { useServerSDK, type ServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" import { createSessionTabs } from "@/pages/session/helpers" import { useSessionLayout } from "@/pages/session/session-layout" -import { normalizeSessionInfo } from "@/utils/session" +import { decode64 } from "@/utils/base64" export type CommandPaletteEntry = { id: string @@ -27,8 +24,6 @@ export type CommandPaletteEntry = { path?: string directory?: string sessionID?: string - server?: ServerConnection.Key - project?: LocalProject archived?: number updated?: number } @@ -80,25 +75,28 @@ export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => vo export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) { const command = useCommand() - const global = useGlobal() const language = useLanguage() + const layout = useLayout() const file = useFile() const dialog = useDialog() + const navigate = useNavigate() const serverSDK = useServerSDK()() - const serverCtx = global.ensureServerCtx(serverSDK.server) - const appTabs = useTabs() - const { tabs: sessionTabs } = useSessionLayout() + const serverSync = useServerSync() + const { params, tabs } = useSessionLayout() const openFile = createCommandPaletteFileOpener(props.onOpenFile) const state = { cleanup: undefined as (() => void) | void, committed: false } const filesOnly = () => props.filesOnly?.() ?? false const allowedCommands = createMemo(() => { if (filesOnly()) return [] - return commandPaletteOptions(command.options) + return command.options.filter( + (option) => + !option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open", + ) }) const commandEntries = createMemo(() => { const category = language.t("palette.group.commands") - return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category)) + return allowedCommands().map((option) => createCommandEntry(option, category)) }) const preferredCommandEntries = createMemo(() => { const all = allowedCommands() @@ -107,11 +105,11 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT) const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base const category = language.t("palette.group.commands") - return sorted.map((option) => createCommandPaletteCommandEntry(option, category)) + return sorted.map((option) => createCommandEntry(option, category)) }) const tabState = createSessionTabs({ - tabs: sessionTabs, + tabs, pathFromTab: file.pathFromTab, normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), }) @@ -142,11 +140,36 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on .map((path) => createCommandPaletteFileEntry(path, category)) }) - const sessions = createServerSessionEntries({ - server: ServerConnection.key(serverSDK.server), - opened: serverCtx.projects.list, - stored: () => serverCtx.sync.data.project, - load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }), + const projectDirectory = createMemo(() => decode64(params.dir) ?? "") + const project = createMemo(() => { + const directory = projectDirectory() + if (!directory) return undefined + return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) + }) + const workspaces = createMemo(() => { + const directory = projectDirectory() + const current = project() + if (!current) return directory ? [directory] : [] + const dirs = [current.worktree, ...(current.sandboxes ?? [])] + if (directory && !dirs.includes(directory)) return [...dirs, directory] + return dirs + }) + const homedir = createMemo(() => serverSync().data.path.home) + const sessions = createSessionEntries({ + workspaces, + label: (directory) => { + const current = project() + const kind = + current && directory === current.worktree + ? language.t("workspace.type.local") + : language.t("workspace.type.sandbox") + const [store] = serverSync().child(directory, { bootstrap: false }) + const home = homedir() + const path = home ? directory.replace(home, "~") : directory + const name = store.vcs?.branch ?? getFilename(directory) + return `${kind} : ${name || path}` + }, + load: (directory) => serverSDK.client.session.list({ directory, roots: true }), untitled: () => language.t("command.session.new"), category: () => language.t("command.category.session"), }) @@ -168,17 +191,8 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on return } if (item.type === "session") { - if (!item.sessionID || !item.server) return - const directory = item.project?.worktree ?? item.directory - if (directory) { - serverCtx.projects.open(directory) - serverCtx.projects.touch(directory) - } - const tab = appTabs.addSessionTab({ - server: item.server, - sessionId: item.sessionID, - }) - appTabs.select(tab) + if (!item.directory || !item.sessionID) return + navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`) return } if (!item.path) return @@ -204,7 +218,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on } } -export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry { +function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry { return { id: "command:" + option.id, type: "command", @@ -216,66 +230,96 @@ export function createCommandPaletteCommandEntry(option: CommandOption, category } } -export function createServerSessionEntries(props: { - server: ServerConnection.Key - opened: () => LocalProject[] - stored: () => Project[] - load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }> +function createSessionEntries(props: { + workspaces: () => string[] + label: (directory: string) => string + load: (directory: string) => ReturnType untitled: () => string category: () => string }) { - let abort: AbortController | undefined + const state: { + token: number + inflight: Promise | undefined + cached: CommandPaletteEntry[] | undefined + } = { token: 0, inflight: undefined, cached: undefined } - onCleanup(() => abort?.abort()) - - return async (text: string): Promise => { - const search = text.trim() - if (!search) { - abort?.abort() - return [] + return (text: string) => { + if (!text.trim()) { + state.token += 1 + state.inflight = undefined + state.cached = undefined + return [] as CommandPaletteEntry[] } - abort?.abort() - const current = new AbortController() - abort = current - await new Promise((resolve) => { - const timer = setTimeout(resolve, 100) - current.signal.addEventListener( - "abort", - () => { - clearTimeout(timer) - resolve() - }, - { once: true }, - ) - }) - if (current.signal.aborted) return [] - const opened = props.opened() - const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : []))) - const stored = props.stored().map((project) => ({ ...project, expanded: false })) - const storedByID = new Map(stored.map((project) => [project.id, project] as const)) - return props - .load(search, current.signal) - .then((result) => - result.data - .map(normalizeSessionInfo) - .filter((session) => !session.time.archived) - .map((session) => { - const project = - projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID) - return { - id: `session:${props.server}:${session.id}`, - type: "session" as const, - title: session.title || props.untitled(), - description: project ? displayName(project) : getFilename(session.directory), - category: props.category(), - directory: session.directory, - sessionID: session.id, - server: props.server, - project, - updated: session.time.updated, - } - }), - ) + if (state.cached) return state.cached + if (state.inflight) return state.inflight + + const current = state.token + const dirs = props.workspaces() + if (dirs.length === 0) return [] as CommandPaletteEntry[] + + state.inflight = Promise.all( + dirs.map((directory) => { + const description = props.label(directory) + return props + .load(directory) + .then((result) => + (result.data ?? []) + .filter((session) => !!session?.id) + .map((session) => ({ + id: session.id, + title: session.title ?? props.untitled(), + description, + directory, + archived: session.time?.archived, + updated: session.time?.updated, + })), + ) + .catch(() => [] as SessionEntryInput[]) + }), + ) + .then((results) => { + if (state.token !== current) return [] as CommandPaletteEntry[] + const seen = new Set() + const next = results + .flat() + .filter((item) => { + const key = `${item.directory}:${item.id}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + .map((item) => createSessionEntry(item, props.category())) + state.cached = next + return next + }) .catch(() => [] as CommandPaletteEntry[]) + .finally(() => { + state.inflight = undefined + }) + + return state.inflight + } +} + +type SessionEntryInput = { + directory: string + id: string + title: string + description: string + archived?: number + updated?: number +} + +function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry { + return { + id: `session:${input.directory}:${input.id}`, + type: "session", + title: input.title, + description: input.description, + category, + directory: input.directory, + sessionID: input.id, + archived: input.archived, + updated: input.updated, } } diff --git a/packages/app/src/components/debug-bar.tsx b/packages/app/src/components/debug-bar.tsx index adadeda303..e55e128b82 100644 --- a/packages/app/src/components/debug-bar.tsx +++ b/packages/app/src/components/debug-bar.tsx @@ -5,7 +5,6 @@ import { makeEventListener } from "@solid-primitives/event-listener" import { Tooltip } from "@opencode-ai/ui/tooltip" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { useLanguage } from "@/context/language" -import { usePlatform } from "@/context/platform" type Mem = Performance & { memory?: { @@ -66,7 +65,7 @@ function Cell(props: {
-
- {props.label} -
-
- {props.value} -
+ {props.label} +
+
+ {props.value}
) @@ -116,55 +107,8 @@ function Cell(props: { ) } -function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) { - const content = () => ( - - ) - - if (props.inline) { - return ( - - {content()} - - ) - } - - return ( - - {content()} - - ) -} - export function DebugBar(props: { inline?: boolean } = {}) { const language = useLanguage() - const platform = usePlatform() const location = useLocation() const routing = useIsRouting() const [state, setState] = createStore({ @@ -172,7 +116,6 @@ export function DebugBar(props: { inline?: boolean } = {}) { delay: undefined as number | undefined, fps: undefined as number | undefined, gap: undefined as number | undefined, - focus: false, heap: { limit: undefined as number | undefined, used: undefined as number | undefined, @@ -199,16 +142,6 @@ export function DebugBar(props: { inline?: boolean } = {}) { } const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`) const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na())) - const toggleFocus = async () => { - if (!platform.setForceFocus) return - const enabled = !state.focus - await platform.setForceFocus(enabled) - setState("focus", enabled) - } - - onCleanup(() => { - if (state.focus) void platform.setForceFocus?.(false).catch(() => undefined) - }) let prev = "" let start = 0 @@ -557,11 +490,8 @@ export function DebugBar(props: { inline?: boolean } = {}) { bad={bad(heap(), 0.8)} dim={state.heap.used === undefined} inline={props.inline} - wide={!platform.setForceFocus} + wide /> - {platform.setForceFocus && ( - void toggleFocus()} /> - )} ) diff --git a/packages/app/src/components/dialog-command-palette-v2.tsx b/packages/app/src/components/dialog-command-palette-v2.tsx index e996fd0be7..b1cafe1e7a 100644 --- a/packages/app/src/components/dialog-command-palette-v2.tsx +++ b/packages/app/src/components/dialog-command-palette-v2.tsx @@ -5,20 +5,13 @@ import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2" import { Icon } from "@opencode-ai/ui/v2/icon" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js" -import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command" -import { useGlobal } from "@/context/global" +import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js" +import { formatKeybindParts } from "@/context/command" import { useLanguage } from "@/context/language" -import { ServerConnection } from "@/context/server" -import { useTabs } from "@/context/tabs" -import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" import { getRelativeTime } from "@/utils/time" import { - createCommandPaletteCommandEntry, createCommandPaletteFileEntry, createCommandPaletteModel, - createServerSessionEntries, uniqueCommandPaletteEntries, type CommandPaletteEntry, } from "./command-palette" @@ -37,6 +30,9 @@ function matchesEntry(entry: CommandPaletteEntry, query: string) { export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) { const palette = createCommandPaletteModel(props) + const [query, setQuery] = createSignal("") + const [active, setActive] = createSignal(0) + const loadItems = async (text: string) => { const q = text.trim() if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()] @@ -45,104 +41,16 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v const category = palette.language.t("palette.group.files") return [ ...palette.commandEntries().filter((entry) => matchesEntry(entry, q)), - ...nextSessions, + ...nextSessions.filter((entry) => matchesEntry(entry, q)), ...files.map((path) => createCommandPaletteFileEntry(path, category)), ] } - return ( - - ) -} - -export function DialogHomeCommandPaletteV2(props: { - server: ServerConnection.Any - onSelectSession: (entry: CommandPaletteEntry) => void -}) { - const command = useCommand() - const dialog = useDialog() - const global = useGlobal() - const language = useLanguage() - const serverCtx = global.ensureServerCtx(props.server) - const state = { cleanup: undefined as (() => void) | void, committed: false } - const commandEntries = createMemo(() => { - const category = language.t("palette.group.commands") - return commandPaletteOptions(command.options).map((option) => createCommandPaletteCommandEntry(option, category)) - }) - const sessions = createServerSessionEntries({ - server: ServerConnection.key(props.server), - opened: serverCtx.projects.list, - stored: () => serverCtx.sync.data.project, - load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }), - untitled: () => language.t("command.session.new"), - category: () => language.t("command.category.session"), - }) - - const highlight = (item: CommandPaletteEntry | undefined) => { - state.cleanup?.() - state.cleanup = undefined - if (item?.type !== "command") return - state.cleanup = item.option?.onHighlight?.() - } - const select = (item: CommandPaletteEntry | undefined) => { - if (!item) return - state.committed = true - state.cleanup = undefined - dialog.close() - if (item.type === "command") { - item.option?.onSelect?.("palette") - return - } - if (item.type === "session") props.onSelectSession(item) - } - const loadItems = async (text: string) => { - const query = text.trim() - if (!query) return commandEntries().slice(0, 5) - return [...commandEntries().filter((entry) => matchesEntry(entry, query)), ...(await sessions(query))] - } - - onCleanup(() => { - if (state.committed) return - state.cleanup?.() - }) - - return ( - dialog.close()} - /> - ) -} - -function CommandPaletteView(props: { - placeholder: string - loadItems: (text: string) => CommandPaletteEntry[] | Promise - highlight: (item: CommandPaletteEntry | undefined) => void - select: (item: CommandPaletteEntry | undefined) => void - close: () => void -}) { - const language = useLanguage() - const tabs = useTabs() - const [query, setQuery] = createSignal("") - const [active, setActive] = createSignal(0) - - const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] }) + const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] }) // Render stale results while a new query loads to avoid flashing "Loading" per keystroke. const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? [])) const groupedEntries = createMemo(() => groups(visibleEntries())) const activeEntry = createMemo(() => visibleEntries()[active()]) - const openSessions = createMemo( - () => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))), - ) createEffect(() => { query() @@ -151,7 +59,7 @@ function CommandPaletteView(props: { }) createEffect(() => { - props.highlight(activeEntry()) + palette.highlight(activeEntry()) }) let resultsRef: HTMLDivElement | undefined @@ -178,12 +86,12 @@ function CommandPaletteView(props: { } if (event.key === "Enter") { event.preventDefault() - props.select(activeEntry()) + palette.select(activeEntry()) return } if (event.key === "Escape") { event.preventDefault() - props.close() + palette.close() } } @@ -197,7 +105,7 @@ function CommandPaletteView(props: { autocomplete="off" spellcheck={false} appearance="large" - placeholder={props.placeholder} + placeholder={palette.language.t("palette.search.placeholder")} leadingIcon={} onInput={(event) => setQuery(event.currentTarget.value)} onKeyDown={handleKeyDown} @@ -209,7 +117,7 @@ function CommandPaletteView(props: { when={visibleEntries().length > 0} fallback={
- {entries.loading ? language.t("common.loading") : language.t("palette.empty")} + {entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")}
} > @@ -224,14 +132,9 @@ function CommandPaletteView(props: { setActive(visibleEntries().findIndex((entry) => entry.id === item.id))} - onSelect={() => props.select(item)} + onSelect={() => palette.select(item)} /> )} @@ -250,19 +153,13 @@ function PaletteRow(props: { item: CommandPaletteEntry active: boolean language: ReturnType - sessionOpen: boolean onActive: () => void onSelect: () => void }) { - const session = () => - props.item.server && props.item.directory && props.item.sessionID - ? { server: props.item.server, directory: props.item.directory, sessionID: props.item.sessionID } - : undefined - return ( - ) -} - -function ProviderConnectionDialogStory(props) { - onCleanup(mockProviderAuth(props.provider, props.methods)) - const dialog = useDialog() - const controller = useProviderConnectController() - controller.select(props.provider) - const open = () => dialog.show(() => ) - - onMount(open) - - return ( - - ) -} - -function renderConnection(provider, methods) { - return () => ( - - - - ) -} - -export default { - title: "App/Dialogs/Connect Provider", - id: "app-dialog-connect-provider", -} - -export const V2 = { - render: () => ( - - - - ), -} - -export const ApiKey = { - render: renderConnection("openrouter", [{ type: "api", label: "API key" }]), -} - -export const OpenCodeZen = { - render: renderConnection("opencode", [{ type: "api", label: "API key" }]), -} - -export const LoginMethods = { - render: renderConnection("openai", [ - { type: "oauth", label: "ChatGPT Pro/Plus (browser)" }, - { type: "oauth", label: "ChatGPT Pro/Plus (headless)" }, - { type: "api", label: "API key" }, - ]), -} diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 0267b4b4ef..6499642b38 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -1,4 +1,4 @@ -import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise" +import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -9,9 +9,6 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Spinner } from "@opencode-ai/ui/spinner" import { Tag } from "@opencode-ai/ui/tag" import { TextField } from "@opencode-ai/ui/text-field" -import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" -import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" -import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { showToast } from "@/utils/toast" import { type Accessor, @@ -19,8 +16,6 @@ import { createEffect, createMemo, createResource, - createUniqueId, - For, Match, onCleanup, onMount, @@ -28,18 +23,14 @@ import { Switch, } from "solid-js" import { createStore, produce } from "solid-js/store" -import { useParams } from "@solidjs/router" -import { ExternalLink } from "@/components/external-link" +import { Link } from "@/components/link" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" -import { useSettings } from "@/context/settings" import { popularProviders, useProviders } from "@/hooks/use-providers" import { CustomProviderForm } from "./dialog-custom-provider" -import { decode64 } from "@/utils/base64" const CUSTOM_ID = "_custom" -type ConnectMethod = Extract export function useProviderConnectController(options: { onBack?: () => void } = {}) { const [store, setStore] = createStore({ selected: undefined as string | undefined }) @@ -59,22 +50,32 @@ export const DialogConnectProvider: Component<{ const fallback = useProviderConnectController() const controller = props.controller ?? fallback const language = useLanguage() - const settings = useSettings() - const newLayout = settings.general.newLayoutDesigns const reset = controller.back const back = { current: reset } - let focusHost: HTMLDivElement | undefined - const holdFocus = () => focusHost?.focus({ preventScroll: true }) const select = (provider?: string) => { back.current = reset controller.select(provider) } - function Content() { - return ( + return ( + + back.current()} + aria-label={language.t("common.goBack")} + /> + + } + > - + {(provider) => ( @@ -87,77 +88,15 @@ export const DialogConnectProvider: Component<{ )} - + - ) - } - - return ( - - back.current()} - aria-label={language.t("common.goBack")} - /> - - } - > - - - } - > - - - {language.t("command.provider.connect")}} - > - - - - -
- -
-
-
- + ) } -function ProviderPicker(props: { - directory?: Accessor - onSelect: (provider: string) => void - onPrepare?: () => void -}) { - const settings = useSettings() - if (settings.general.newLayoutDesigns()) - return - const providers = useProviders(() => props.directory?.()) +function ProviderPicker(props: { directory?: Accessor; onSelect: (provider: string) => void }) { + const providers = useProviders(props.directory) const language = useLanguage() const popularGroup = () => language.t("dialog.provider.group.popular") const otherGroup = () => language.t("dialog.provider.group.other") @@ -224,157 +163,6 @@ function ProviderPicker(props: { ) } -function ProviderPickerV2(props: { - directory?: Accessor - onSelect: (provider: string) => void - onPrepare?: () => void -}) { - const providers = useProviders(() => props.directory?.()) - const language = useLanguage() - const [store, setStore] = createStore({ - filter: "", - active: undefined as string | undefined, - connecting: undefined as string | undefined, - }) - const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"] - const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") }) - const all = createMemo(() => { - language.locale() - const query = store.filter.trim().toLowerCase() - const values = [custom(), ...providers.all().values()] - if (!query) return values - return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query)) - }) - const popular = createMemo(() => - all() - .filter((provider) => featured.includes(provider.id)) - .sort((a, b) => featured.indexOf(a.id) - featured.indexOf(b.id)), - ) - const other = createMemo(() => - all() - .filter((provider) => !featured.includes(provider.id)) - .sort((a, b) => { - if (a.id === CUSTOM_ID) return -1 - if (b.id === CUSTOM_ID) return 1 - return a.name.localeCompare(b.name) - }), - ) - const rows = createMemo(() => [...popular(), ...other()]) - let picker: HTMLDivElement | undefined - let search: HTMLInputElement | undefined - - onMount(() => search?.focus({ preventScroll: true })) - - const connect = (provider: string) => { - props.onPrepare?.() - props.onSelect(provider) - } - - const move = (event: KeyboardEvent, direction: number) => { - const items = rows() - if (items.length === 0) return - const index = items.findIndex((provider) => provider.id === store.active) - const next = index < 0 ? (direction > 0 ? 0 : items.length - 1) : (index + direction + items.length) % items.length - setStore("active", items[next].id) - picker - ?.querySelector(`[data-provider-id="${CSS.escape(items[next].id)}"]`) - ?.focus({ preventScroll: true }) - event.preventDefault() - } - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "ArrowDown") return move(event, 1) - if (event.key === "ArrowUp") return move(event, -1) - if (event.key !== "Enter" || !store.active) return - connect(store.active) - event.preventDefault() - } - - return ( -
-
- } - placeholder={language.t("dialog.provider.search.placeholder")} - value={store.filter} - onInput={(event) => { - setStore({ filter: event.currentTarget.value, active: undefined }) - }} - /> -
-
-
- - {(group) => ( - 0}> -
-
- {group.title} -
- - {(provider) => ( - - )} - -
-
- )} -
- -
- {language.t("dialog.provider.empty")} -
-
-
-
-
-
- ) -} - function ProviderConnection(props: { provider: string directory?: Accessor @@ -384,16 +172,8 @@ function ProviderConnection(props: { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() - const params = useParams() const language = useLanguage() - const settings = useSettings() - const newLayout = settings.general.newLayoutDesigns - const providers = useProviders(() => props.directory?.()) - const directory = () => props.directory?.() ?? decode64(params.dir) - const location = () => { - const value = directory() - return value ? { directory: value } : undefined - } + const providers = useProviders(props.directory) const alive = { value: true } const timer = { current: undefined as ReturnType | undefined } @@ -408,32 +188,28 @@ function ProviderConnection(props: { const provider = createMemo( () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!, ) - const fallback = createMemo(() => [ + const fallback = createMemo(() => [ { - type: "key" as const, + type: "api" as const, label: language.t("provider.connect.method.apiKey"), }, ]) - const [integration] = createResource( - () => ({ provider: props.provider, directory: directory() }), - (input) => - serverSDK() - .api.integration.get({ - integrationID: input.provider, - location: input.directory ? { directory: input.directory } : undefined, - }) - .then((result) => result.data), + const [auth] = createResource( + () => props.provider, + async () => { + const cached = serverSync().data.provider_auth[props.provider] + if (cached) return cached + const res = await serverSDK().client.provider.auth() + if (!alive.value) return fallback() + serverSync().set("provider_auth", res.data ?? {}) + return res.data?.[props.provider] ?? fallback() + }, ) - const loading = createMemo(() => integration.loading) - const methods = createMemo(() => { - const values = integration.latest?.methods.filter( - (method): method is ConnectMethod => method.type === "key" || method.type === "oauth", - ) - return values?.length ? values : fallback() - }) + const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider]) + const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()) const [store, setStore] = createStore({ methodIndex: undefined as undefined | number, - authorization: undefined as undefined | IntegrationOauthConnectOutput["data"], + authorization: undefined as undefined | ProviderAuthAuthorization, promptInputs: undefined as undefined | Record, state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", error: undefined as string | undefined, @@ -445,7 +221,7 @@ function ProviderConnection(props: { | { type: "auth.prompt" } | { type: "auth.inputs"; inputs: Record } | { type: "auth.pending" } - | { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] } + | { type: "auth.complete"; authorization: ProviderAuthAuthorization } | { type: "auth.error"; error: string } function dispatch(action: Action) { @@ -499,20 +275,10 @@ function ProviderConnection(props: { const methodLabel = (value?: { type?: string; label?: string }) => { if (!value) return "" - if (value.type === "key") return language.t("provider.connect.method.apiKey") + if (value.type === "api") return language.t("provider.connect.method.apiKey") return value.label ?? "" } - const methodDetails = (value?: { type?: string; label?: string }) => { - const label = methodLabel(value) - const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i) - const hint = suffix?.[1] - return { - label: suffix ? label.slice(0, -suffix[0].length) : label, - hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined, - } - } - function formatError(value: unknown, fallback: string): string { if (value && typeof value === "object" && "data" in value) { const data = (value as { data?: { message?: unknown } }).data @@ -540,22 +306,46 @@ function ProviderConnection(props: { const method = methods()[index] dispatch({ type: "method.select", index }) + if (method.type === "api" && method.prompts?.length) { + if (!inputs) { + dispatch({ type: "auth.prompt" }) + return + } + dispatch({ type: "auth.inputs", inputs }) + return + } + if (method.type === "oauth") { if (method.prompts?.length && !inputs) { dispatch({ type: "auth.prompt" }) return } dispatch({ type: "auth.pending" }) + const start = Date.now() await serverSDK() - .api.integration.oauth.connect({ - integrationID: props.provider, - methodID: method.id, - inputs: inputs ?? {}, - location: location(), - }) + .client.provider.oauth.authorize( + { + providerID: props.provider, + method: index, + inputs, + }, + { throwOnError: true }, + ) .then((x) => { if (!alive.value) return - dispatch({ type: "auth.complete", authorization: x.data }) + const elapsed = Date.now() - start + const delay = 1000 - elapsed + + if (delay > 0) { + if (timer.current !== undefined) clearTimeout(timer.current) + timer.current = setTimeout(() => { + timer.current = undefined + if (!alive.value) return + dispatch({ type: "auth.complete", authorization: x.data! }) + }, delay) + return + } + dispatch({ type: "auth.complete", authorization: x.data! }) }) .catch((e) => { if (!alive.value) return @@ -570,9 +360,9 @@ function ProviderConnection(props: { index: 0, }) - const prompts = createMemo(() => { + const prompts = createMemo>(() => { const value = method() - return value?.type === "oauth" ? (value.prompts ?? []) : [] + return value?.prompts ?? [] }) const matches = (prompt: NonNullable[number]>, value: Record) => { if (!prompt.when) return true @@ -603,6 +393,10 @@ function ProviderConnection(props: { setFormStore("index", next) return } + if (method()?.type === "api") { + dispatch({ type: "auth.inputs", inputs: value }) + return + } await selectMethod(store.methodIndex, value) } @@ -704,9 +498,7 @@ function ProviderConnection(props: { }) async function complete() { - await serverSync() - .refreshProviders() - .catch(() => undefined) + await serverSDK().client.global.dispose() dialog.close() showToast({ variant: "success", @@ -727,37 +519,6 @@ function ProviderConnection(props: { props.setBack(goBack) function MethodSelection() { - if (newLayout()) - return ( -
-
- {language.t("provider.connect.selectMethod", { provider: provider().name })} -
-
- - {(item, index) => { - const details = () => methodDetails(item) - return ( - - ) - }} - -
-
- ) - return ( <>
@@ -770,7 +531,7 @@ function ProviderConnection(props: { listRef = ref }} items={methods} - key={(m) => m?.label ?? m?.type} + key={(m) => m?.label} onSelect={async (selected, index) => { if (!selected) return void selectMethod(index) @@ -791,18 +552,11 @@ function ProviderConnection(props: { } function ApiAuthView() { - let apiKey: HTMLInputElement | undefined - const errorID = createUniqueId() const [formStore, setFormStore] = createStore({ value: "", error: undefined as string | undefined, }) - onMount(() => { - if (!newLayout()) return - apiKey?.focus({ preventScroll: true }) - }) - async function handleSubmit(e: SubmitEvent) { e.preventDefault() @@ -816,67 +570,17 @@ function ProviderConnection(props: { } setFormStore("error", undefined) - await serverSDK().api.integration.connect.key({ - integrationID: props.provider, - location: location(), - key: apiKey, + await serverSDK().client.auth.set({ + providerID: props.provider, + auth: { + type: "api", + key: apiKey, + ...(store.promptInputs ? { metadata: store.promptInputs } : {}), + }, }) await complete() } - if (newLayout()) - return ( -
- -
-
{language.t("provider.connect.opencodeZen.line1")}
-
{language.t("provider.connect.opencodeZen.line2")}
-
- {language.t("provider.connect.opencodeZen.visit.prefix")} - - {language.t("provider.connect.opencodeZen.visit.link")} - - {language.t("provider.connect.opencodeZen.visit.suffix")} -
-
-
-
- - - {(error) => ( - - )} - - - {language.t("common.continue")} - -
-
- ) - return (
@@ -886,9 +590,9 @@ function ProviderConnection(props: {
{language.t("provider.connect.opencodeZen.line2")}
{language.t("provider.connect.opencodeZen.visit.prefix")} - + {language.t("provider.connect.opencodeZen.visit.link")} - + {language.t("provider.connect.opencodeZen.visit.suffix")}
@@ -901,8 +605,7 @@ function ProviderConnection(props: {
{ - if (!newLayout()) return - codeInput?.focus({ preventScroll: true }) - }) - async function handleSubmit(e: SubmitEvent) { e.preventDefault() @@ -947,13 +643,12 @@ function ProviderConnection(props: { setFormStore("error", undefined) const result = await serverSDK() - .api.integration.oauth.complete({ - integrationID: props.provider, - attemptID: store.authorization!.attemptID, - location: location(), + .client.provider.oauth.callback({ + providerID: props.provider, + method: store.methodIndex, code, }) - .then(() => ({ ok: true as const })) + .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) .catch((error) => ({ ok: false as const, error })) if (result.ok) { await complete() @@ -962,59 +657,16 @@ function ProviderConnection(props: { setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid"))) } - if (newLayout()) - return ( -
-
- {language.t("provider.connect.oauth.code.visit.prefix")} - - {language.t("provider.connect.oauth.code.visit.link")} - - {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })} -
- - - - {(error) => ( - - )} - - - {language.t("common.continue")} - - -
- ) - return (
{language.t("provider.connect.oauth.code.visit.prefix")} - - {language.t("provider.connect.oauth.code.visit.link")} - + {language.t("provider.connect.oauth.code.visit.link")} {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
{ - const poll = async () => { - const authorization = store.authorization - if (!authorization || !alive.value) return + void (async () => { const result = await serverSDK() - .api.integration.oauth.status({ - integrationID: props.provider, - attemptID: authorization.attemptID, - location: location(), + .client.provider.oauth.callback({ + providerID: props.provider, + method: store.methodIndex, }) - .then((value) => ({ ok: true as const, status: value.data })) + .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) .catch((error) => ({ ok: false as const, error })) + if (!alive.value) return + if (!result.ok) { - dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) }) + const message = formatError(result.error, language.t("common.requestFailed")) + dispatch({ type: "auth.error", error: message }) return } - if (result.status.status === "complete") { - await complete() - return - } - if (result.status.status === "failed") { - dispatch({ type: "auth.error", error: result.status.message }) - return - } - if (result.status.status === "expired") { - dispatch({ type: "auth.error", error: language.t("common.requestFailed") }) - return - } - timer.current = setTimeout(poll, 1_000) - } - void poll() + + await complete() + })() }) return (
{language.t("provider.connect.oauth.auto.visit.prefix")} - - {language.t("provider.connect.oauth.auto.visit.link")} - + {language.t("provider.connect.oauth.auto.visit.link")} {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
-
- -
+
+
+ +
{language.t("provider.connect.title.anthropicProMax")} @@ -1121,12 +750,8 @@ function ProviderConnection(props: {
-
-
+
+
@@ -1158,15 +783,15 @@ function ProviderConnection(props: {
- + - + - + diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index e34e4c39b8..363a2e390a 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -8,7 +8,7 @@ import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@/utils/toast" import { batch, For } from "solid-js" import { createStore, produce } from "solid-js/store" -import { ExternalLink } from "@/components/external-link" +import { Link } from "@/components/link" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" @@ -40,7 +40,7 @@ export function DialogCustomProvider(props: Props) { ) } -export function CustomProviderForm(props: { autofocus?: boolean } = {}) { +export function CustomProviderForm() { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() @@ -131,7 +131,6 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) { const saveMutation = useMutation(() => ({ mutationFn: async (result: NonNullable>) => { - if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server") const disabledProviders = serverSync().data.config.disabled_providers ?? [] const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) @@ -185,15 +184,15 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {

{language.t("provider.custom.description.prefix")} - + {language.t("provider.custom.description.link")} - + {language.t("provider.custom.description.suffix")}

- - - {language.t("dialog.project.edit.title")} - - - - - {language.t("dialog.project.edit.name")} - model.setStore("name", event.currentTarget.value)} - /> - - -
-
- {language.t("dialog.project.edit.icon")} -
-
- - { - model.setIconInput(element) - }} - type="file" - accept="image/*" - class="hidden" - onChange={model.inputChange} - /> -
- {language.t("dialog.project.edit.icon.hint")} - {language.t("dialog.project.edit.icon.recommended")} -
-
-
- - -
-
- {language.t("dialog.project.edit.color")} -
-
- - {(color) => ( - - )} - -
-
-
- - - {language.t("dialog.project.edit.worktree.startup")} - {language.t("dialog.project.edit.worktree.startup.description")} - model.setStore("startup", event.currentTarget.value)} - /> - -
- - - {language.t("common.cancel")} - - - {model.save.isPending ? language.t("common.saving") : language.t("common.save")} - - - - - ) -} diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index 86a9630359..b861492af5 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -1,32 +1,123 @@ import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { TextField } from "@opencode-ai/ui/text-field" +import { useMutation } from "@tanstack/solid-query" import { Icon } from "@opencode-ai/ui/icon" -import { For, Show } from "solid-js" +import { createMemo, For, Show } from "solid-js" +import { createStore } from "solid-js/store" import { type LocalProject, getAvatarColors } from "@/context/layout" +import { getFilename } from "@opencode-ai/core/util/path" import { Avatar } from "@opencode-ai/ui/avatar" import { useLanguage } from "@/context/language" import { getProjectAvatarSource } from "@/pages/layout/helpers" import { ServerConnection } from "@/context/server" -import { createEditProjectModel } from "./edit-project" +import { useGlobal } from "@/context/global" const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) { + const dialog = useDialog() + const global = useGlobal() const language = useLanguage() - const model = createEditProjectModel(props) + const serverCtx = createMemo(() => global.ensureServerCtx(props.server)) + const serverSDK = () => serverCtx().sdk + const serverSync = () => serverCtx().sync + + const folderName = createMemo(() => getFilename(props.project.worktree)) + const defaultName = createMemo(() => props.project.name || folderName()) + + const [store, setStore] = createStore({ + name: defaultName(), + color: props.project.icon?.color, + iconOverride: props.project.icon?.override, + startup: props.project.commands?.start ?? "", + dragOver: false, + iconHover: false, + }) + + let iconInput: HTMLInputElement | undefined + + function handleFileSelect(file: File) { + if (!file.type.startsWith("image/")) return + const reader = new FileReader() + reader.onload = (e) => { + setStore("iconOverride", e.target?.result as string) + setStore("iconHover", false) + } + reader.readAsDataURL(file) + } + + function handleDrop(e: DragEvent) { + e.preventDefault() + setStore("dragOver", false) + const file = e.dataTransfer?.files[0] + if (file) handleFileSelect(file) + } + + function handleDragOver(e: DragEvent) { + e.preventDefault() + setStore("dragOver", true) + } + + function handleDragLeave() { + setStore("dragOver", false) + } + + function handleInputChange(e: Event) { + const input = e.target as HTMLInputElement + const file = input.files?.[0] + if (file) handleFileSelect(file) + } + + function clearIcon() { + setStore("iconOverride", "") + } + + const saveMutation = useMutation(() => ({ + mutationFn: async () => { + const name = store.name.trim() === folderName() ? "" : store.name.trim() + const start = store.startup.trim() + + if (props.project.id && props.project.id !== "global") { + await serverSDK().client.project.update({ + projectID: props.project.id, + directory: props.project.worktree, + name, + icon: { color: store.color || "", override: store.iconOverride || "" }, + commands: { start }, + }) + serverSync().project.icon(props.project.worktree, store.iconOverride || undefined) + dialog.close() + return + } + + serverSync().project.meta(props.project.worktree, { + name, + icon: { color: store.color || undefined, override: store.iconOverride || undefined }, + commands: { start: start || undefined }, + }) + dialog.close() + }, + })) + + function handleSubmit(e: SubmitEvent) { + e.preventDefault() + if (saveMutation.isPending) return + saveMutation.mutate() + } return ( -
+
model.setStore("name", v)} + placeholder={folderName()} + value={store.name} + onChange={(v) => setStore("name", v)} />
@@ -34,32 +125,38 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
model.setStore("iconHover", true)} - onMouseLeave={() => model.setStore("iconHover", false)} + onMouseEnter={() => setStore("iconHover", true)} + onMouseLeave={() => setStore("iconHover", false)} >
{ + if (store.iconOverride && store.iconHover) { + clearIcon() + } else { + iconInput?.click() + } }} - onDrop={model.drop} - onDragOver={model.dragOver} - onDragLeave={model.dragLeave} - onClick={model.iconClick} >
@@ -77,8 +174,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
@@ -86,8 +183,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
@@ -96,12 +193,12 @@ export function DialogEditProject(props: { project: LocalProject; server: Server { - model.setIconInput(el) + iconInput = el }} type="file" accept="image/*" class="hidden" - onChange={model.inputChange} + onChange={handleInputChange} />
{language.t("dialog.project.edit.icon.hint")} @@ -110,7 +207,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
- +
@@ -119,21 +216,21 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
- -
diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 5187d980ea..601f03084c 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -69,11 +69,15 @@ export const DialogFork: Component = () => { const dir = base64Encode(sdk().directory) sdk() - .api.session.fork({ sessionID, messageID: item.id }) + .client.session.fork({ sessionID, messageID: item.id }) .then((forked) => { + if (!forked.data) { + showToast({ title: language.t("common.requestFailed") }) + return + } dialog.close() - prompt.set(restored, undefined, { dir, id: forked.id }) - navigate(`/${dir}/session/${forked.id}`) + prompt.set(restored, undefined, { dir, id: forked.data.id }) + navigate(`/${dir}/session/${forked.data.id}`) }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) diff --git a/packages/app/src/components/dialog-select-directory-v2.tsx b/packages/app/src/components/dialog-select-directory-v2.tsx index f3376ad35f..69d46ddcb6 100644 --- a/packages/app/src/components/dialog-select-directory-v2.tsx +++ b/packages/app/src/components/dialog-select-directory-v2.tsx @@ -8,7 +8,6 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup, import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" import { ServerConnection } from "@/context/server" -import type { Path } from "@opencode-ai/sdk/v2/client" import { absoluteTreePath, activeTreeNavigation, @@ -29,7 +28,6 @@ import { } from "./directory-picker-domain" import "./dialog-select-directory-v2.css" import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" -import { getFilename } from "@opencode-ai/core/util/path" interface DialogSelectDirectoryV2Props { title?: string @@ -69,13 +67,11 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) const [fallbackPath] = createResource( () => (missingBase() ? true : undefined), - async (): Promise => { - if ((await sdk.protocol) !== "v1") return - return sdk.client.path + () => + sdk.client.path .get() .then((result) => result.data) - .catch(() => undefined) - }, + .catch(() => undefined), { initialValue: undefined }, ) const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") @@ -89,26 +85,18 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { ) const search = createDirectorySearch({ sdk, home, base: () => root() || start() }) const [suggestions] = createResource(input, async (value) => { - const cleaned = cleanPickerInput(value) - const typed = cleaned.replace(/\/+$/, "") + const typed = cleanPickerInput(value).replace(/\/+$/, "") const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "") - if (!cleaned || (root() && typed === current)) return { query: value, items: [] } + if (!typed || typed === current) return { query: value, items: [] } const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const })) if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) } - const base = pickerRoot(cleaned) || root() || start() - if (!base) return { query: value, items: directories.slice(0, 5) } - const files = await sdk.api.file - .find({ - location: { directory: base }, - query: pickerFileSearchQuery(base, value, home()), - type: "file", - limit: 20, - }) - .then((result) => result.data) + const files = await sdk.client.find + .files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 }) + .then((result) => result.data ?? []) .catch(() => []) const results = [ ...directories, - ...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })), + ...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })), ] return { query: value, @@ -127,14 +115,9 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { existing ?? loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => { if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined) - return sdk.api.file - .list({ location: { directory: absolute } }) - .then((result) => - result.data.map((entry) => ({ - name: getFilename(entry.path.replace(/[\\/]+$/, "")), - type: entry.type, - })), - ) + return sdk.client.file + .list({ directory: absolute, path: "" }) + .then((result) => result.data ?? []) .catch(() => undefined) }) listings.set(key, request) @@ -329,7 +312,6 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { {(suggestion, index) => ( - ) -} - -export default { - title: "App/Dialogs/Select Model", - id: "app-dialog-select-model", -} - -export const WithoutProviders = { - render: () => , -} diff --git a/packages/app/src/components/dialog-select-model-unpaid-v2.tsx b/packages/app/src/components/dialog-select-model-unpaid-v2.tsx index 2d0c3a7305..0ce9f18c16 100644 --- a/packages/app/src/components/dialog-select-model-unpaid-v2.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid-v2.tsx @@ -1,26 +1,23 @@ import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" import { Icon } from "@opencode-ai/ui/v2/icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { ScrollView } from "@opencode-ai/ui/scroll-view" import { Tag } from "@opencode-ai/ui/v2/badge-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useTheme } from "@opencode-ai/ui/theme" import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js" import { useLocal } from "@/context/local" -import { useProviders } from "@/hooks/use-providers" +import { popularProviders, useProviders } from "@/hooks/use-providers" import { decode64 } from "@/utils/base64" import { useLanguage } from "@/context/language" import { ModelTooltip } from "./model-tooltip" type ModelState = ReturnType["model"] -const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"] -const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "") export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => { const local = useLocal() const model = props.model ?? local.model const dialog = useDialog() - const theme = useTheme() const directory = () => decode64(local.slug()) const providers = useProviders(directory) const language = useLanguage() @@ -31,7 +28,6 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro }) const isFree = (item: ReturnType[number]) => item.provider.id === "opencode" && (!item.cost || item.cost.input === 0) - const freeModels = createMemo(() => model.list().filter(isFree)) const openProviders = (provider?: string) => { void import("./dialog-connect-provider").then((x) => { @@ -66,110 +62,111 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro }) return ( - + {language.t("dialog.model.select.title")} - -
-
-
-
- {language.t("dialog.model.unpaid.freeModels.title")} -
-
- - {(item) => ( - - } - > - - - )} - -
- -
-
-
-
- {language.t("dialog.model.unpaid.addMore.title")} +
+ + +
+
+
+
+ {language.t("dialog.model.unpaid.freeModels.title")}
-
- featuredProviders.includes(provider.id)) - .sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))} - > - {(provider) => ( + + {(item) => ( + } + > + + )} + +
+ +
+
+
+
+ {language.t("dialog.model.unpaid.addMore.title")} +
+
+
+ { + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) { + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + } + return a.name.localeCompare(b.name) + })} + > + {(provider) => ( + - )} - - + + )} + + +
-
+
) diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 9066f72434..4fb7891ec9 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -1,5 +1,15 @@ import { Popover as Kobalte } from "@kobalte/core/popover" -import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js" +import { + Component, + ComponentProps, + createEffect, + createMemo, + For, + JSX, + onCleanup, + Show, + ValidComponent, +} from "solid-js" import { createStore } from "solid-js/store" import { useLocal } from "@/context/local" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -19,7 +29,6 @@ import { ModelTooltip } from "./model-tooltip" import { useLanguage } from "@/context/language" import { decode64 } from "@/utils/base64" import { handleDocumentSearchKeydown } from "@/utils/search-keydown" -import { createMenuDismissController } from "@/utils/menu-dismiss-controller" import { createEventListener } from "@solid-primitives/event-listener" import { matchesModelSearch } from "./dialog-select-model-search" @@ -113,13 +122,14 @@ const ModelList: Component<{ } type ModelSelectorTriggerProps = Omit, "as" | "ref"> -type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element type Dismiss = "escape" | "outside" | "select" | "manage" | "provider" export function ModelSelectorPopover(props: { provider?: string model?: ModelState - trigger: ModelSelectorTrigger + children?: JSX.Element + triggerAs?: ValidComponent + triggerProps?: ModelSelectorTriggerProps onClose?: (cause: "escape" | "select") => void }) { const [store, setStore] = createStore<{ @@ -164,7 +174,9 @@ export function ModelSelectorPopover(props: { placement="top-start" gutter={4} > - + + {props.children} + void }) { + const model = props.model ?? useLocal().model + const language = useLanguage() const dialog = useDialog() - const controller = createModelSelectorController({ - model: props.model, - provider: () => props.provider, - onSelect: () => props.onClose?.(), - }) + const [store, setStore] = createStore({ open: false, search: "", active: "" }) + let searchRef: HTMLInputElement | undefined + let contentRef: HTMLDivElement | undefined + let restoreTrigger = true - return ( - { - void import("./dialog-manage-models").then((module) => { - void dialog.show(() => ) - }) - }} - onClose={() => props.onClose?.()} - /> - ) -} - -function createModelSelectorController(input: { - provider: () => string | undefined - model?: ModelState - onSelect: () => void -}) { - const model = input.model ?? useLocal().model const allModels = createMemo(() => model .list() .filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id })) - .filter((item) => (input.provider() ? item.provider.id === input.provider() : true)), + .filter((item) => (props.provider ? item.provider.id === props.provider : true)), ) + const models = createMemo(() => { + const search = store.search.trim() + const filtered = search + ? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name])) + : allModels() - return { - models: (search: string) => { - const query = search.trim() - const filtered = query - ? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name])) - : allModels() - return [...filtered].sort((a, b) => a.name.localeCompare(b.name)) - }, - groups: (models: ModelItem[]) => { - const byProvider = new Map() - for (const item of models) { - byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item]) - } - return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups) - }, - current: () => { - const value = model.current() - return value ? modelKey(value) : undefined - }, - select: (item: ModelItem) => { - model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true }) - input.onSelect() - }, - } -} - -function ModelSelectorPopoverV2View(props: { - trigger: ModelSelectorTrigger - models: (search: string) => ModelItem[] - groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[] - current: () => string | undefined - select: (item: ModelItem) => void - onManage: () => void - onClose: () => void -}) { - const language = useLanguage() - const [store, setStore] = createStore({ open: false, search: "", active: "" }) - let searchRef: HTMLInputElement | undefined - let contentRef: HTMLDivElement | undefined - const dismiss = createMenuDismissController(() => contentRef) - - const models = createMemo(() => props.models(store.search)) - const groups = createMemo(() => props.groups(models())) + return [...filtered].sort((a, b) => a.name.localeCompare(b.name)) + }) + const groups = createMemo(() => { + const byProvider = new Map() + for (const item of models()) { + byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item]) + } + return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups) + }) const keys = () => [...models().map(modelKey), manageKey] + const current = () => { + const value = model.current() + return value ? `${value.provider.id}:${value.id}` : undefined + } const initialActive = () => { - const selected = props.current() + const selected = current() const options = keys() if (selected && options.includes(selected)) return selected return options[0] ?? "" } const activeItem = () => store.active ? contentRef?.querySelector(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined + const afterClose = (callback: () => void) => { + const complete = () => { + if (contentRef?.isConnected) { + requestAnimationFrame(complete) + return + } + requestAnimationFrame(() => requestAnimationFrame(callback)) + } + requestAnimationFrame(complete) + } const setOpen = (open: boolean) => { if (open) { - dismiss.allowTriggerRestore() + restoreTrigger = true setStore({ open: true, active: initialActive() }) setTimeout(() => requestAnimationFrame(() => { @@ -331,15 +308,23 @@ function ModelSelectorPopoverV2View(props: { } setStore({ open: false, search: "", active: "" }) } + const select = (item: ModelItem) => { + model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true }) + props.onClose?.() + } const selectModel = (item: ModelItem) => { - dismiss.preventTriggerRestore() + restoreTrigger = false setOpen(false) - dismiss.afterClose(() => props.select(item)) + afterClose(() => select(item)) } const manage = () => { - dismiss.preventTriggerRestore() + restoreTrigger = false setOpen(false) - dismiss.afterClose(props.onManage) + afterClose(() => { + void import("./dialog-manage-models").then((x) => { + dialog.show(() => ) + }) + }) } const selectActive = () => { const item = models().find((item) => modelKey(item) === store.active) @@ -358,7 +343,10 @@ function ModelSelectorPopoverV2View(props: { queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" })) } const setSearch = (value: string) => { - const first = props.models(value)[0] + const search = value.trim() + const first = [...allModels()] + .sort((a, b) => a.name.localeCompare(b.name)) + .find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name])) setStore({ search: value, active: first ? modelKey(first) : manageKey }) } @@ -374,14 +362,18 @@ function ModelSelectorPopoverV2View(props: { return ( - + + {props.children} + (contentRef = element)} + ref={(el: HTMLDivElement) => (contentRef = el)} class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none" - onPointerDownOutside={dismiss.preventTriggerRestore} - onFocusOutside={dismiss.preventTriggerRestore} - onCloseAutoFocus={dismiss.onCloseAutoFocus} + onPointerDownOutside={() => (restoreTrigger = false)} + onFocusOutside={() => (restoreTrigger = false)} + onCloseAutoFocus={(event) => { + if (!restoreTrigger) event.preventDefault() + }} >
@@ -401,9 +393,9 @@ function ModelSelectorPopoverV2View(props: { event.stopPropagation() if (event.key === "Escape") { event.preventDefault() - dismiss.preventTriggerRestore() + restoreTrigger = false setOpen(false) - dismiss.afterClose(props.onClose) + afterClose(() => props.onClose?.()) return } if (event.altKey || event.metaKey) return @@ -453,7 +445,7 @@ function ModelSelectorPopoverV2View(props: { {group.items[0].provider.name} - + {(item) => ( { diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index aa16976228..0876906890 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -16,7 +16,6 @@ import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" -import { detectServerProtocol } from "@/utils/server-protocol" import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" import { useSettings } from "@/context/settings" import { useTabs } from "@/context/tabs" @@ -264,13 +263,6 @@ export function useServerManagementController(options: { onSelect?: () => void; setStore("addServer", { error: language.t("dialog.server.add.error") }) return } - if ( - !settings.general.newLayoutDesigns() && - (await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2" - ) { - setStore("addServer", { error: language.t("dialog.server.add.error") }) - return - } resetAdd() if (options.navigateOnAdd === false) { @@ -315,13 +307,6 @@ export function useServerManagementController(options: { onSelect?: () => void; setStore("editServer", { error: language.t("dialog.server.add.error") }) return } - if ( - !settings.general.newLayoutDesigns() && - (await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2" - ) { - setStore("editServer", { error: language.t("dialog.server.add.error") }) - return - } if (normalized === input.original.http.url) { server.add(conn) } else { @@ -359,10 +344,7 @@ export function useServerManagementController(options: { onSelect?: () => void; ) const sortedItems = createMemo(() => { - const raw = items() - const list = settings.general.newLayoutDesigns() - ? raw - : raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2") + const list = items() if (!list.length) return list const active = current() const order = new Map(list.map((url, index) => [url, index] as const)) diff --git a/packages/app/src/components/dialog-usage-exceeded.tsx b/packages/app/src/components/dialog-usage-exceeded.tsx index bf5da751e2..e428d4c2bb 100644 --- a/packages/app/src/components/dialog-usage-exceeded.tsx +++ b/packages/app/src/components/dialog-usage-exceeded.tsx @@ -17,7 +17,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) { const platform = usePlatform() const runAction = () => { - if (props.link) platform.openExternal(props.link) + if (props.link) platform.openLink(props.link) props.onClose?.() dialog.close() } diff --git a/packages/app/src/components/directory-picker-domain.test.ts b/packages/app/src/components/directory-picker-domain.test.ts index 1bc9af0833..5746410610 100644 --- a/packages/app/src/components/directory-picker-domain.test.ts +++ b/packages/app/src/components/directory-picker-domain.test.ts @@ -133,10 +133,10 @@ test("scopes file autocomplete to the current browser root", () => { test("resolves directory autocomplete from the current browser root", async () => { const directories: string[] = [] const sdk = { - api: { - file: { - find: (input: { location?: { directory?: string } }) => { - directories.push(input.location?.directory ?? "") + client: { + find: { + files: (input: { directory: string }) => { + directories.push(input.directory) return Promise.resolve({ data: [] }) }, }, @@ -152,29 +152,6 @@ test("resolves directory autocomplete from the current browser root", async () = expect(directories).toEqual(["/repo", "/repo/src"]) }) -test("searches from an absolute root without a default base", async () => { - const directories: string[] = [] - const sdk = { - api: { - file: { - list: (input: { location?: { directory?: string } }) => { - directories.push(input.location?.directory ?? "") - return Promise.resolve({ - data: [ - { path: "Users/", type: "directory" }, - { path: "tmp/", type: "directory" }, - ], - }) - }, - }, - }, - } as unknown as Parameters[0]["sdk"] - const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined }) - - expect(await search("/")).toEqual(["/Users", "/tmp"]) - expect(directories).toEqual(["/"]) -}) - test("identifies the next directory level to preload", () => { expect( preloadTreeDirectories("src/", [ diff --git a/packages/app/src/components/directory-picker-domain.ts b/packages/app/src/components/directory-picker-domain.ts index 9539ae1d01..9900265962 100644 --- a/packages/app/src/components/directory-picker-domain.ts +++ b/packages/app/src/components/directory-picker-domain.ts @@ -326,15 +326,15 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string let current = 0 const scoped = (value: string) => { - const raw = normalizePickerDrive(value) - const root = pickerRoot(raw) - if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) } const base = args.base() if (!base) return + const raw = normalizePickerDrive(value) if (!raw) return { directory: trimPickerPath(base), path: "" } const home = args.home() if (raw === "~") return { directory: trimPickerPath(home || base), path: "" } if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) } + const root = pickerRoot(raw) + if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) } return { directory: trimPickerPath(base), path: raw } } @@ -342,17 +342,14 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string const key = trimPickerPath(directory) const existing = cache.get(key) if (existing) return existing - const request = args.sdk.api.file - .list({ location: { directory: key } }) - .then((result) => result.data) + const request = args.sdk.client.file + .list({ directory: key, path: "" }) + .then((result) => result.data ?? []) .catch(() => []) .then((nodes) => nodes .filter((node) => node.type === "directory") - .map((node) => { - const relative = trimPickerPath(normalizePickerDrive(node.path)) - return { name: getFilename(relative), absolute: joinPickerPath(key, relative) } - }), + .map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })), ) cache.set(key, request) return request @@ -374,9 +371,9 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/") const query = normalizePickerDrive(input.path) if (!pathInput) { - const results = await args.sdk.api.file - .find({ location: { directory: input.directory }, query, type: "directory", limit: 50 }) - .then((result) => result.data.map((entry) => entry.path)) + const results = await args.sdk.client.find + .files({ directory: input.directory, query, type: "directory", limit: 50 }) + .then((result) => result.data ?? []) .catch(() => []) if (!active()) return [] return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50) diff --git a/packages/app/src/components/edit-project.ts b/packages/app/src/components/edit-project.ts deleted file mode 100644 index 0c5d576e93..0000000000 --- a/packages/app/src/components/edit-project.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { getFilename } from "@opencode-ai/core/util/path" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useMutation } from "@tanstack/solid-query" -import { normalizeProjectInfo } from "@/context/global-sync/utils" -import { createMemo } from "solid-js" -import { createStore } from "solid-js/store" -import { useGlobal } from "@/context/global" -import { type LocalProject } from "@/context/layout" -import { ServerConnection } from "@/context/server" - -export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) { - const dialog = useDialog() - const global = useGlobal() - const serverCtx = createMemo(() => global.ensureServerCtx(props.server)) - const folderName = createMemo(() => getFilename(props.project.worktree)) - const defaultName = createMemo(() => props.project.name || folderName()) - const [store, setStore] = createStore({ - name: defaultName(), - color: props.project.icon?.color, - iconOverride: props.project.icon?.override, - startup: props.project.commands?.start ?? "", - dragOver: false, - iconHover: false, - }) - let iconInput: HTMLInputElement | undefined - - function selectFile(file: File) { - if (!file.type.startsWith("image/")) return - const reader = new FileReader() - reader.onload = (event) => { - const result = event.target?.result - if (typeof result !== "string") return - setStore("iconOverride", result) - setStore("iconHover", false) - } - reader.readAsDataURL(file) - } - - function drop(event: DragEvent) { - event.preventDefault() - setStore("dragOver", false) - const file = event.dataTransfer?.files[0] - if (file) selectFile(file) - } - - function dragOver(event: DragEvent) { - event.preventDefault() - setStore("dragOver", true) - } - - function dragLeave() { - setStore("dragOver", false) - } - - function inputChange(event: Event) { - const file = (event.currentTarget as HTMLInputElement).files?.[0] - if (file) selectFile(file) - } - - function iconClick() { - if (store.iconOverride && store.iconHover) { - setStore("iconOverride", "") - return - } - iconInput?.click() - } - - const save = useMutation(() => ({ - mutationFn: async () => { - const name = store.name.trim() === folderName() ? "" : store.name.trim() - const start = store.startup.trim() - - if (props.project.id && props.project.id !== "global") { - if ((await serverCtx().sdk.protocol) !== "v1") return - const project = await serverCtx() - .sdk.client.project.update({ - projectID: props.project.id, - directory: props.project.worktree, - name, - icon: { color: store.color || "", override: store.iconOverride || "" }, - commands: { start }, - }) - .then((result) => result.data) - if (!project) return - // const project = await serverCtx().sdk.api.project.update({ - // projectID: props.project.id, - // name, - // icon: { color: store.color || "", override: store.iconOverride || "" }, - // commands: { start }, - // }) - serverCtx().sync.set("project", (items) => - items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)), - ) - serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined) - dialog.close() - return - } - - serverCtx().sync.project.meta(props.project.worktree, { - name, - icon: { color: store.color || undefined, override: store.iconOverride || undefined }, - commands: { start: start || undefined }, - }) - dialog.close() - }, - })) - - function submit(event: SubmitEvent) { - event.preventDefault() - if (save.isPending) return - save.mutate() - } - - return { - store, - setStore, - folderName, - defaultName, - save, - submit, - drop, - dragOver, - dragLeave, - inputChange, - iconClick, - close() { - dialog.close() - }, - setIconInput(input: HTMLInputElement) { - iconInput = input - }, - } -} diff --git a/packages/app/src/components/external-link.tsx b/packages/app/src/components/external-link.tsx deleted file mode 100644 index 133e752eab..0000000000 --- a/packages/app/src/components/external-link.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { ComponentProps, splitProps } from "solid-js" - -export interface ExternalLinkProps extends Omit, "href"> { - href: string -} - -export function ExternalLink(props: ExternalLinkProps) { - const [local, rest] = splitProps(props, ["href", "children", "class", "target", "rel"]) - - return ( - - {local.children} - - ) -} diff --git a/packages/app/src/components/file-tree-v2-model.test.ts b/packages/app/src/components/file-tree-v2-model.test.ts index f2a63fe86d..288f7112e9 100644 --- a/packages/app/src/components/file-tree-v2-model.test.ts +++ b/packages/app/src/components/file-tree-v2-model.test.ts @@ -1,9 +1,8 @@ import { describe, expect, test } from "bun:test" -import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model" -import type { FileNode } from "@opencode-ai/sdk/v2" +import { buildFileTreeV2Model, flattenFileTreeV2 } from "./file-tree-v2-model" -describe("buildFileTreeV2Model", () => { - test("builds a sorted tree and flattens expanded directories", () => { +describe("file tree v2 model", () => { + test("builds sorted depth-first rows", () => { const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"]) expect(model.total).toBe(8) @@ -19,7 +18,7 @@ describe("buildFileTreeV2Model", () => { ]) }) - test("skips children of collapsed directories", () => { + test("omits descendants of collapsed directories", () => { const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"]) expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([ @@ -29,46 +28,19 @@ describe("buildFileTreeV2Model", () => { ]) }) - test("normalizes duplicate and messy paths", () => { + test("normalizes separators and duplicate paths", () => { const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"]) const rows = flattenFileTreeV2(model, () => true) + expect(model.total).toBe(4) expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"]) expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts") }) - test("handles deeply nested paths", () => { - const file = Array.from({ length: 130 }, (_, index) => `d${index}`).join("/") + "/leaf.ts" + test("supports paths deeper than the legacy recursion limit", () => { + const file = `${Array.from({ length: 130 }, (_, index) => `dir-${index}`).join("/")}/file.ts` const model = buildFileTreeV2Model([file]) expect(flattenFileTreeV2(model, () => true)).toHaveLength(131) }) }) - -describe("flattenLiveFileTreeV2", () => { - test("flattens live children using original paths for nested lookups", () => { - const nodes: Record = { - "": [ - { name: "src", path: "src", absolute: "/repo/src", type: "directory", ignored: false }, - { name: "README.md", path: "README.md", absolute: "/repo/README.md", type: "file", ignored: false }, - ], - src: [ - { name: "a.ts", path: "src/a.ts", absolute: "/repo/src/a.ts", type: "file", ignored: false }, - { name: "lib", path: "src/lib", absolute: "/repo/src/lib", type: "directory", ignored: false }, - ], - "src/lib": [{ name: "b.ts", path: "src/lib/b.ts", absolute: "/repo/src/lib/b.ts", type: "file", ignored: false }], - } - - expect( - flattenLiveFileTreeV2( - (path) => nodes[path] ?? [], - (path) => path === "src", - ).map((row) => [row.node.path, row.node.originalPath, row.level]), - ).toEqual([ - ["src", "src", 0], - ["src/a.ts", "src/a.ts", 1], - ["src/lib", "src/lib", 1], - ["README.md", "README.md", 0], - ]) - }) -}) diff --git a/packages/app/src/components/file-tree-v2-model.ts b/packages/app/src/components/file-tree-v2-model.ts index 27783a9bb1..2127b6800f 100644 --- a/packages/app/src/components/file-tree-v2-model.ts +++ b/packages/app/src/components/file-tree-v2-model.ts @@ -75,33 +75,3 @@ export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: strin return rows } - -export function flattenLiveFileTreeV2( - children: (path: string) => readonly FileNode[], - expanded: (path: string) => boolean, -) { - const rows: FileTreeV2Row[] = [] - const stack = children("") - .toReversed() - .map((node) => ({ node: toLiveNode(node), level: 0 })) - - while (stack.length > 0) { - const row = stack.pop()! - rows.push(row) - if (row.node.type !== "directory" || !expanded(row.node.path)) continue - const nested = children(row.node.originalPath) - for (let index = nested.length - 1; index >= 0; index--) { - stack.push({ node: toLiveNode(nested[index]!), level: row.level + 1 }) - } - } - - return rows -} - -function toLiveNode(node: FileNode): FileTreeV2Node { - return { - ...node, - path: normalizeFileTreeV2Path(node.path), - originalPath: node.path, - } -} diff --git a/packages/app/src/components/file-tree-v2.tsx b/packages/app/src/components/file-tree-v2.tsx index 26cf49d411..c712a80443 100644 --- a/packages/app/src/components/file-tree-v2.tsx +++ b/packages/app/src/components/file-tree-v2.tsx @@ -16,13 +16,7 @@ import type { FileNode } from "@opencode-ai/sdk/v2" import { Icon } from "@opencode-ai/ui/v2/icon" import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree" import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" -import { - buildFileTreeV2Model, - flattenFileTreeV2, - flattenLiveFileTreeV2, - normalizeFileTreeV2Path, - type FileTreeV2Node, -} from "@/components/file-tree-v2-model" +import { buildFileTreeV2Model, flattenFileTreeV2, normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" import { virtualScrollElement } from "@/components/virtual-scroll-element" export type { Kind } from "@/components/file-tree" @@ -42,7 +36,7 @@ function guideLineLeft(level: number) { export const kindLabel = (kind: Kind) => { if (kind === "add") return "A" if (kind === "del") return "D" - return "M" + return "" } export const kindChange = (kind: Kind) => { @@ -74,7 +68,7 @@ const FileTreeNodeV2 = ( "class", "classList", ]) - const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path)) + const kind = () => local.kinds?.get(local.node.path) return ( - {(_, index) =>
} + {(_, index) => ( +
+ )} ) } @@ -127,18 +126,12 @@ export default function FileTreeV2(props: { kinds?: ReadonlyMap draggable?: boolean onFileClick?: (file: FileNode) => void - onFileDoubleClick?: (file: FileNode) => void }) { const file = useFile() - const live = () => props.allowed === undefined const draggable = () => props.draggable ?? true const active = () => normalizeFileTreeV2Path(props.active ?? "") - const model = createMemo(() => (live() ? undefined : buildFileTreeV2Model(props.allowed ?? []))) - const expanded = (path: string) => file.tree.state(path)?.expanded ?? !live() - const rows = createMemo(() => { - if (live()) return flattenLiveFileTreeV2((path) => file.tree.children(path), expanded) - return flattenFileTreeV2(model()!, expanded) - }) + const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? [])) + const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true)) const [root, setRoot] = createSignal() const [focused, setFocused] = createSignal() const virtualizer = createVirtualizer({ @@ -162,49 +155,16 @@ export default function FileTreeV2(props: { return [...indexes, index].sort((a, b) => a - b) }, }) - - createEffect(() => { - if (!live()) return - void file.tree.list("") - }) - - // Only scroll when the active path changes (or first appears in the tree). - // Do not re-scroll when expand/collapse reshuffles `rows()`. - let scrolledActive: string | undefined createEffect(() => { const path = active() - if (!path) { - scrolledActive = undefined - return - } + if (!path) return const index = rows().findIndex((row) => row.node.path === path) if (index < 0) return - if (scrolledActive === path) return - scrolledActive = path queueMicrotask(() => { - const next = rows().findIndex((row) => row.node.path === path) - if (next < 0) return - if (virtualizer.range && next >= virtualizer.range.startIndex && next <= virtualizer.range.endIndex) return - virtualizer.scrollToIndex(next, { align: "auto" }) + if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return + virtualizer.scrollToIndex(index, { align: "auto" }) }) }) - - const selectFile = (node: FileTreeV2Node, action?: (file: FileNode) => void) => { - action?.({ - ...node, - path: node.originalPath, - absolute: node.originalPath, - }) - } - - const toggleDirectory = (path: string, originalPath: string) => { - if (expanded(path)) { - file.tree.collapse(originalPath) - return - } - file.tree.expand(originalPath, live() ? undefined : { list: false }) - } - const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const))) const virtualItemByKey = createMemo( () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)), @@ -215,7 +175,7 @@ export default function FileTreeV2(props: {
@@ -249,8 +209,13 @@ export default function FileTreeV2(props: { class="relative" onFocus={() => setFocused(row().node.path)} onBlur={() => setFocused(undefined)} - onClick={() => selectFile(row().node, props.onFileClick)} - onDblClick={() => selectFile(row().node, props.onFileDoubleClick)} + onClick={() => + props.onFileClick?.({ + ...row().node, + path: row().node.originalPath, + absolute: row().node.originalPath, + }) + } > 0}> @@ -274,13 +239,17 @@ export default function FileTreeV2(props: { class="relative" onFocus={() => setFocused(row().node.path)} onBlur={() => setFocused(undefined)} - aria-expanded={expanded(row().node.path)} - onClick={() => toggleDirectory(row().node.path, row().node.originalPath)} + aria-expanded={file.tree.state(row().node.path)?.expanded ?? true} + onClick={() => + file.tree.state(row().node.path)?.expanded === false + ? file.tree.expand(row().node.path, { list: false }) + : file.tree.collapse(row().node.path) + } >
diff --git a/packages/app/src/components/help-button.tsx b/packages/app/src/components/help-button.tsx index 18dd727084..de156e1b33 100644 --- a/packages/app/src/components/help-button.tsx +++ b/packages/app/src/components/help-button.tsx @@ -1,35 +1,77 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { createSignal, Show } from "solid-js" +import { createStore } from "solid-js/store" import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer" import { usePlatform } from "@/context/platform" -import { useSettings } from "@/context/settings" import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4" -import homeImage from "@/assets/help/home.png" -import tabsImage from "@/assets/help/tabs.png" +import { Persist, persisted } from "@/utils/persist" + +const helpIcon = ( + +) + +const triggerClass = + "size-7 !rounded-full shrink-0 bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]" // TODO: wire to changelog / seen-state when available const showPopover = () => true +export function HelpButton() { + if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null + + const platform = usePlatform() + + return ( + { + event.preventDefault() + platform.openLink(event.currentTarget.href) + }} + > + {helpIcon} + + ) +} + // can remove this after the tabs rollout has been out for a while export function TabsInfoPopup() { - const settings = useSettings() - const platform = usePlatform() + if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null + + const [state, setState] = persisted(Persist.global("tabsInfoPopup"), createStore({ dismissed: false })) + // setState({ dismissed: false }) // for testing const [drawerOpen, setDrawerOpen] = createSignal(false) - const windows = () => platform.platform === "desktop" && platform.os === "windows" return ( - +
- - + +
+

+ June 16 +

} - class="absolute top-[10px] left-[-36px]" /> - -
-

- July 14 -

- - } - /> -
-
+

- Introducing Tabs + Introducing Tabs Navigation. +

+

+ We've introduced tabs as the primary navigation in OpenCode. Your most important session are now pinned at + the top of your screen at all times. No more hunting through menus or losing your place mid-session. Switch + contexts instantly, pick up exactly where you left off, and keep your focus where it belongs: on the + sessions.

-
-

OpenCode Desktop is now built around tabs.

- -

- Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when - you're starting something new, and close it when you're done. -

-

- Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something - memorable if you plan to keep them around. -

-

- You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab. -

- -

When you reopen the app, your tabs are still open.

-

- The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using - the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout - will become permanent in a few weeks. -

-
diff --git a/packages/app/src/components/link.tsx b/packages/app/src/components/link.tsx new file mode 100644 index 0000000000..85f7efc539 --- /dev/null +++ b/packages/app/src/components/link.tsx @@ -0,0 +1,26 @@ +import { ComponentProps, splitProps } from "solid-js" +import { usePlatform } from "@/context/platform" + +export interface LinkProps extends Omit, "href"> { + href: string +} + +export function Link(props: LinkProps) { + const platform = usePlatform() + const [local, rest] = splitProps(props, ["href", "children", "class"]) + + return ( + { + if (!local.href) return + event.preventDefault() + platform.openLink(local.href) + }} + {...rest} + > + {local.children} + + ) +} diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx deleted file mode 100644 index 96b99aaf4a..0000000000 --- a/packages/app/src/components/prompt-input-v2.tsx +++ /dev/null @@ -1,586 +0,0 @@ -import { ImagePreview } from "@opencode-ai/ui/image-preview" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" -import { Icon } from "@opencode-ai/ui/v2/icon" -import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" -import { createEffect, createMemo, on, Show } from "solid-js" -import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model" -import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" -import type { PromptInputProps } from "@/components/prompt-input/contracts" -import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history" -import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store" -import { promptDesignPlaceholder, promptPlaceholder } from "@/components/prompt-input/placeholder" -import { createPromptSubmit } from "@/components/prompt-input/submit" -import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" -import { useComments } from "@/context/comments" -import { useCommand } from "@/context/command" -import { useLanguage } from "@/context/language" -import { useLayout } from "@/context/layout" -import { usePermission } from "@/context/permission" -import { type ImageAttachmentPart, usePrompt } from "@/context/prompt" -import { usePlatform } from "@/context/platform" -import { useSDK } from "@/context/sdk" -import { useSync } from "@/context/sync" -import { createSessionTabs } from "@/pages/session/helpers" -import { showToast } from "@/utils/toast" -import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input" -import { - createPromptInputV2Controller, - createPromptInputV2State, - type PromptInputV2Interaction, -} from "@opencode-ai/session-ui/v2/prompt-input/interaction" - -export type PromptInputV2ComposerProps = { - class?: string - controller: PromptInputV2ComposerController - borderUnderlay?: boolean -} - -export type PromptInputV2ControllerProps = Omit -export type PromptInputV2ComposerController = PromptInputV2Interaction & { - readonly model: PromptInputProps["controls"]["model"] -} - -export function PromptInputV2Composer(props: PromptInputV2ComposerProps) { - const dialog = useDialog() - const command = useCommand() - const language = useLanguage() - - return ( -
- - dialog.show(() => ) - } - /> - } - /> -
- ) -} - -export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController { - const sdk = useSDK() - const sync = useSync() - const files = useFile() - const layout = useLayout() - const comments = useComments() - const dialog = useDialog() - const command = useCommand() - const permission = usePermission() - const language = useLanguage() - const platform = usePlatform() - const prompt = props.state ?? usePrompt() - let editor: HTMLDivElement | undefined - - const interaction = createPromptInputV2State() - const mode = () => interaction[0].mode - const history = props.history ?? createPersistedPromptInputHistory() - const tabs = () => props.controls.session.tabs - const activeFileTab = createSessionTabs({ - tabs, - pathFromTab: files.pathFromTab, - normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab), - }).activeFileTab - const recent = createMemo(() => { - const all = tabs().all() - const active = activeFileTab() - const order = active ? [active, ...all.filter((tab) => tab !== active)] : all - return order.reduce((result, tab) => { - const path = files.pathFromTab(tab) - if (!path || result.includes(path)) return result - return [...result, path] - }, []) - }) - const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined)) - const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? "")) - const attachments = createMemo(() => - prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), - ) - const commentCount = createMemo(() => { - if (mode() === "shell") return 0 - return prompt.context.items().filter((item) => !!item.comment?.trim()).length - }) - const blank = createMemo(() => { - const text = prompt - .current() - .map((part) => ("content" in part ? part.content : "")) - .join("") - return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0 - }) - const stopping = createMemo(() => working() && blank()) - const placeholder = createMemo(() => - promptPlaceholder({ - mode: mode(), - commentCount: commentCount(), - example: mode() === "shell" ? "git status" : "", - suggest: false, - t: (key, params) => language.t(key as Parameters[0], params as never), - }), - ) - const designPlaceholder = () => promptDesignPlaceholder(mode(), placeholder()) - - const historyComments = () => { - const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) - return prompt.context.items().flatMap((item) => { - const comment = item.comment?.trim() - if (!comment) return [] - const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined - const nextSelection = - selection ?? - (item.selection - ? ({ start: item.selection.startLine, end: item.selection.endLine } satisfies SelectedLineRange) - : undefined) - if (!nextSelection) return [] - return [ - { - id: item.commentID ?? item.key, - path: item.path, - selection: { ...nextSelection }, - comment, - time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(), - origin: item.commentOrigin, - preview: item.preview, - } satisfies PromptHistoryComment, - ] - }) - } - const restoreHistoryComments = (items: PromptHistoryComment[]) => { - comments.replace( - items.map((item) => ({ - id: item.id, - file: item.path, - selection: { ...item.selection }, - comment: item.comment, - time: item.time, - })), - ) - prompt.context.replaceComments( - items.map((item) => ({ - type: "file", - path: item.path, - selection: selectionFromLines(item.selection), - comment: item.comment, - commentID: item.id, - commentOrigin: item.origin, - preview: item.preview, - })), - ) - } - - const accepting = createMemo(() => { - const id = props.controls.session.id - if (!id) return permission.isAutoAcceptingDirectory(sdk().directory) - return permission.isAutoAccepting(id, sdk().directory) - }) - const submission = createPromptSubmit({ - prompt, - info, - imageAttachments: attachments, - commentCount, - autoAccept: accepting, - mode, - working, - editor: () => editor, - queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })), - promptLength, - addToHistory: (value, mode) => controller.addHistory(value, mode), - resetHistoryNavigation: () => controller.resetHistory(), - setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }), - setPopover: (popover) => { - if (!popover) controller.dispatch({ type: "popover.close" }) - }, - newSessionWorktree: () => props.newSessionWorktree, - onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, - shouldQueue: props.shouldQueue, - onQueue: props.onQueue, - onAbort: props.onAbort, - onSubmit: props.onSubmit, - model: props.controls.model.selection, - }) - - const referenceDescription = (reference: ReferenceInfo) => - reference.source.type === "git" ? reference.source.repository : reference.source.path - const references = createMemo(() => - sync() - .data.reference.filter((reference) => !reference.hidden) - .map((reference) => ({ - id: `reference:${reference.name}`, - kind: "reference" as const, - label: `@${reference.name}`, - path: reference.path, - description: reference.description ?? referenceDescription(reference), - mention: { - type: "file" as const, - path: reference.path, - content: `@${reference.name}`, - start: 0, - end: 0, - mime: "application/x-directory", - filename: reference.name, - }, - })), - ) - const resources = createMemo(() => - Object.values(sync().data.mcp_resource).map((resource) => ({ - id: `resource:${resource.server}:${resource.uri}`, - kind: "resource" as const, - label: `@${resource.name}`, - path: resource.uri, - description: resource.description, - mention: { - type: "file" as const, - path: resource.uri, - content: `@${resource.name}`, - start: 0, - end: 0, - mime: resource.mimeType ?? "text/plain", - filename: resource.name, - url: resource.uri, - source: { - type: "resource" as const, - text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 }, - clientName: resource.server, - uri: resource.uri, - }, - }, - resource, - })), - ) - const context = createMemo(() => [ - ...references(), - ...props.controls.agents.available - .filter((agent) => !agent.hidden && agent.mode !== "primary") - .map((agent) => ({ - id: `agent:${agent.name}`, - kind: "agent" as const, - label: `@${agent.name}`, - mention: { type: "agent" as const, name: agent.name, content: `@${agent.name}`, start: 0, end: 0 }, - })), - ...resources(), - ...recent().map((path) => ({ - id: `file:${path}`, - kind: "file" as const, - label: path, - path, - recent: true, - mention: { type: "file" as const, path, content: `@${path}`, start: 0, end: 0 }, - })), - ]) - const slashCommands = createMemo(() => [ - ...sync().data.command.map((item) => ({ - id: `custom.${item.name}`, - trigger: item.name, - title: item.name, - description: item.description, - type: "custom" as const, - })), - ...command.options - .filter((item) => !item.disabled && !item.id.startsWith("suggested.") && item.slash) - .map((item) => ({ - id: item.id, - trigger: item.slash!, - title: item.title, - description: item.description, - type: "builtin" as const, - })), - ]) - const commands = createMemo(() => - slashCommands().map((item) => ({ - id: item.id, - kind: "command", - label: `/${item.trigger}`, - trigger: item.trigger, - title: item.title, - description: item.description, - keybind: command.keybindParts(item.id), - })), - ) - const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()]) - const controller = createPromptInputV2Controller({ - store: () => prompt.capture().store, - state: interaction, - identity: () => prompt.capture(), - history: { - entries: (mode) => - history.entries(mode).map((value) => { - const entry = normalizePromptHistoryEntry(value) - return { prompt: entry.prompt, metadata: entry.comments } - }), - add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()), - capture: historyComments, - restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]), - }, - commands, - context, - searchContextFiles: async (query) => - (await files.searchFilesAndDirectories(query)).map((path) => ({ - id: `file:${path}`, - kind: "file", - label: path, - path, - mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 }, - })), - onContextRemove(item) { - if (item?.commentID) comments.remove(item.path, item.commentID) - }, - openAttachment: (attachment) => - dialog.show(() => ), - openContext(key) { - const item = controller.contextItem(key) - if (item) openComment(item, props, sync, layout, files, comments) - }, - onEditor(element) { - editor = element as HTMLDivElement - props.ref?.(editor) - }, - onSuggestionSelect(item) { - if (item.kind !== "command") return - const selected = slashCommands().find((entry) => entry.id === item.id) - if (!selected || selected.type === "custom") return - return () => command.trigger(selected.id, "slash") - }, - attachments: { - picker: platform.openAttachmentPickerDialog, - directory: () => sdk().directory, - isDialogActive: () => !!dialog.active, - warn: () => - showToast({ - title: language.t("prompt.toast.pasteUnsupported.title"), - description: language.t("prompt.toast.pasteUnsupported.description"), - }), - duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }), - onError: (error) => - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: error instanceof Error ? error.message : String(error), - }), - readClipboardImage: platform.readClipboardImage, - getPathForFile: platform.getPathForFile, - store: platform.draftStore?.putBlob, - }, - view: { - placeholder: designPlaceholder, - get agent() { - return props.controls.agents.visible && props.controls.agents.options.length > 0 - ? { - options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })), - current: () => props.controls.agents.current, - onSelect: (value: string) => props.controls.agents.select(value), - keybind: () => command.keybindParts("agent.cycle"), - } - : undefined - }, - variant: { - options: () => variants().map((value) => ({ id: value, label: value })), - current: () => props.controls.model.selection.variant.current() ?? "default", - onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value), - keybind: () => command.keybindParts("model.variant.cycle"), - }, - submit: { - stopping, - working, - onSubmit: () => void submission.handleSubmit(new Event("submit")), - onStop: () => void submission.abort(), - }, - }, - }) - Object.defineProperty(controller, "model", { get: () => props.controls.model }) - - command.register("prompt-input", () => [ - { - id: "file.attach", - title: language.t("prompt.action.attachFile"), - category: language.t("command.category.file"), - keybind: "mod+u", - disabled: controller.state.mode !== "normal", - onSelect: () => controller.attach(), - }, - { - id: "prompt.mode.shell", - title: language.t("command.prompt.mode.shell"), - category: language.t("command.category.session"), - keybind: "mod+shift+x", - disabled: controller.state.mode === "shell", - onSelect: () => controller.dispatch({ type: "mode.shell" }), - }, - { - id: "prompt.mode.normal", - title: language.t("command.prompt.mode.normal"), - category: language.t("command.category.session"), - keybind: "mod+shift+e", - disabled: controller.state.mode === "normal", - onSelect: () => controller.dispatch({ type: "mode.normal" }), - }, - ]) - - createEffect( - on( - () => props.edit?.id, - (id) => { - const edit = props.edit - if (!id || !edit) return - prompt.context.items().forEach((item) => prompt.context.remove(item.key)) - edit.context.forEach((item) => - prompt.context.add({ - type: item.type, - path: item.path, - selection: item.selection, - comment: item.comment, - commentID: item.commentID, - commentOrigin: item.commentOrigin, - preview: item.preview, - }), - ) - controller.dispatch({ type: "mode.normal" }) - controller.resetHistory() - prompt.set(edit.prompt, promptLength(edit.prompt)) - controller.restoreFocus() - props.onEditLoaded?.() - }, - { defer: true }, - ), - ) - - return controller as PromptInputV2ComposerController -} - -function PromptInputV2ModelControl(props: { - loading: boolean - paid: boolean - title: string - keybind: string[] - model: PromptInputV2ComposerController["model"]["selection"] - providerID?: string - modelName: string - onClose: () => void - onUnpaidClick: () => void -}) { - const shouldAnimate = createMemo((previous) => previous ?? props.loading) - const content = () => ( - <> - - {(providerID) => ( - - )} - - {props.modelName} - - - - - ) - return ( - - - {props.title} - - - } - > - - {content()} - - } - > - ( - - {content()} - - )} - onClose={props.onClose} - /> - - - - ) -} - -function openComment( - item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }, - props: PromptInputV2ControllerProps, - sync: ReturnType, - layout: ReturnType, - files: ReturnType, - comments: ReturnType, -) { - if (!item.commentID) return - const focus = { file: item.path, id: item.commentID } - comments.setActive(focus) - const queueFocus = (attempts = 6) => { - requestAnimationFrame(() => { - comments.setFocus({ ...focus }) - if (attempts <= 0) return - requestAnimationFrame(() => { - const current = comments.focus() - if (current?.file === focus.file && current.id === focus.id) queueFocus(attempts - 1) - }) - }) - } - const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined - const review = - item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path)) - if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() - if (review) { - layout.fileTree.setTab("changes") - props.controls.session.tabs.setActive("review") - queueFocus() - return - } - layout.fileTree.setTab("all") - const tab = files.tab(item.path) - void props.controls.session.tabs.open(tab) - props.controls.session.tabs.setActive(tab) - void Promise.resolve(files.load(item.path)).finally(() => queueFocus()) -} diff --git a/packages/app/src/components/prompt-input.stories.tsx b/packages/app/src/components/prompt-input.stories.tsx index 0b9c26ce41..362a741401 100644 --- a/packages/app/src/components/prompt-input.stories.tsx +++ b/packages/app/src/components/prompt-input.stories.tsx @@ -30,16 +30,8 @@ function PromptInputExample() { activeTab: undefined as string | undefined, reviewOpen: false, }) - const storyModel = { - id: "claude-3-7-sonnet", - name: "Claude 3.7 Sonnet", - provider: { id: "anthropic", name: "Anthropic" }, - } const model = { - current: () => storyModel, - list: () => [storyModel], - visible: () => true, - set: () => {}, + current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }), variant: { list: () => ["fast", "thinking"], current: () => controls.variant, @@ -75,6 +67,7 @@ function PromptInputExample() { open: () => setControls("reviewOpen", true), }, }, + newLayoutDesigns: true, } const addReviewComment = () => { const comment = controls.comments + 1 @@ -153,6 +146,7 @@ function PromptInputWithOpenDock() { }, reviewPanel: { opened: () => false, open: () => {} }, }, + newLayoutDesigns: true, } const state = { blocked: () => false, diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 08c1ee3489..bbb71e8722 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -13,11 +13,12 @@ import { Match, type JSX, } from "solid-js" +import { createStore, type SetStoreFunction, type Store } from "solid-js/store" +import type { useLocal } from "@/context/local" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" import { ContentPart, DEFAULT_PROMPT, - isCommentItem, isPromptEqual, Prompt, usePrompt, @@ -47,6 +48,7 @@ import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialo import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" import { useCommand } from "@/context/command" +import { Persist, persisted } from "@/utils/persist" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" @@ -57,34 +59,121 @@ import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files" import { canNavigateHistoryAtCursor, navigatePromptHistory, + prependHistoryEntry, type PromptHistoryComment, type PromptHistoryEntry, + type PromptHistoryStoredEntry, promptLength, } from "./prompt-input/history" -import { - createPersistedPromptInputHistory, - createPromptInputHistory, - type PromptInputHistory, -} from "./prompt-input/history-store" -import { - type PromptInputControls, - type PromptInputProps, - type PromptInputState, - type PromptInputSubmission, -} from "./prompt-input/contracts" -import { createPromptSubmit } from "./prompt-input/submit" +import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit" import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover" import { PromptContextItems } from "./prompt-input/context-items" import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptDragOverlay } from "./prompt-input/drag-overlay" -import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/placeholder" +import { promptPlaceholder } from "./prompt-input/placeholder" import { createPromptInputTransientState } from "./prompt-input/transient-state" import { showToast } from "@/utils/toast" import { ImagePreview } from "@opencode-ai/ui/image-preview" import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" -export { createPromptInputHistory } -export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission } +export type PromptInputState = ReturnType + +export type PromptInputHistory = { + entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[] + add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void +} + +export type PromptInputSubmission = { + abort: () => Promise | void + handleSubmit: (event: Event) => Promise | void +} + +export type PromptInputControls = { + agents: { + available: { name: string; hidden?: boolean; mode: string }[] + options: string[] + current: string + loading: boolean + visible: boolean + select: (name: string | undefined) => void + } + model: { + selection: ReturnType["model"] + paid: boolean + loading: boolean + } + session: { + id?: string + tabs: { + active: () => string | undefined + all: () => string[] + open: (tab: string) => void | Promise + setActive: (tab: string) => void + } + reviewPanel: { + opened: () => boolean + open: () => void + } + } + newLayoutDesigns: boolean +} + +export function createPromptInputHistory(): PromptInputHistory { + const [normal, setNormal] = createStore({ entries: [] }) + const [shell, setShell] = createStore({ entries: [] }) + return createPromptInputHistoryStore(normal, setNormal, shell, setShell) +} + +type PromptHistoryState = { entries: PromptHistoryStoredEntry[] } + +function createPromptInputHistoryStore( + normal: Store, + setNormal: SetStoreFunction, + shell: Store, + setShell: SetStoreFunction, +): PromptInputHistory { + return { + entries: (mode) => (mode === "shell" ? shell.entries : normal.entries), + add(prompt, mode, comments) { + const current = mode === "shell" ? shell : normal + const setCurrent = mode === "shell" ? setShell : setNormal + const next = prependHistoryEntry(current.entries, prompt, comments) + if (next === current.entries) return + setCurrent("entries", next) + }, + } +} + +function createPersistedPromptInputHistory() { + const [normal, setNormal] = persisted( + Persist.global("prompt-history", ["prompt-history.v1"]), + createStore({ entries: [] }), + ) + const [shell, setShell] = persisted( + Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]), + createStore({ entries: [] }), + ) + return createPromptInputHistoryStore(normal, setNormal, shell, setShell) +} + +export interface PromptInputProps { + class?: string + variant?: "dock" | "new-session" + state?: PromptInputState + history?: PromptInputHistory + submission?: PromptInputSubmission + controls: PromptInputControls + ref?: (el: HTMLDivElement) => void + newSessionWorktree?: string + onNewSessionWorktreeReset?: () => void + edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] } + onEditLoaded?: () => void + shouldQueue?: () => boolean + onQueue?: (draft: FollowupDraft) => void + onAbort?: () => void + onSubmit?: () => void + toolbar?: JSX.Element +} const EXAMPLES = [ "prompt.example.1", @@ -543,9 +632,7 @@ export const PromptInput: Component = (props) => { const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 const handleBlur = () => { - const cursor = currentCursor() - savedCursor = cursor - if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor) + savedCursor = currentCursor() closePopover() setComposing(false) } @@ -591,7 +678,7 @@ export const PromptInput: Component = (props) => { type: "resource", name: resource.name, uri: resource.uri, - client: resource.server, + client: resource.client, display: resource.name, description: resource.description, mime: resource.mimeType, @@ -709,7 +796,7 @@ export const PromptInput: Component = (props) => { title: cmd.name, description: cmd.description, type: "custom" as const, - // source: cmd.source, + source: cmd.source, })) return [...custom, ...builtin] @@ -1101,6 +1188,28 @@ export const PromptInput: Component = (props) => { return true } + const openCommands = () => { + const populated = prompt.dirty() || commentCount() > 0 + requestAnimationFrame(() => { + if (!populated) { + if (!addPart({ type: "text", content: "/", start: 0, end: 0 })) return + slashOnInput("") + setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" }) + return + } + slashOnInput("") + setStore({ popover: "slash", slashMenu: true, slashMenuQuery: "" }) + }) + } + + const openContext = () => { + requestAnimationFrame(() => { + if (!addPart({ type: "text", content: "@", start: 0, end: 0 })) return + atOnInput("") + setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" }) + }) + } + const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { history.add(prompt, mode, mode === "shell" ? [] : historyComments()) } @@ -1427,11 +1536,50 @@ export const PromptInput: Component = (props) => { (p) => p, ) + const designPlaceholder = () => { + if (store.mode === "shell") return placeholder() + return "Ask anything, / for commands, @ for context..." + } + + const modelControlState = createMemo(() => ({ + loading: providersLoading(), + shouldAnimate: providersShouldFadeIn(), + paid: props.controls.model.paid, + title: language.t("command.model.choose"), + keybind: command.keybindParts("model.choose"), + model: props.controls.model.selection, + providerID: props.controls.model.selection.current()?.provider?.id, + modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"), + newLayoutDesigns: props.controls.newLayoutDesigns, + style: control(), + onClose: restoreFocus, + onUnpaidClick: () => { + if (props.controls.newLayoutDesigns) { + dialog.show(() => ) + return + } + dialog.show(() => ) + }, + })) + + const newSession = () => props.variant === "new-session" const bindEditorRef = (el: HTMLDivElement) => { editorRef = el restoreEndOnFocus = true props.ref?.(el) } + const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0) + const agentControlState = createMemo(() => ({ + title: language.t("command.agent.cycle"), + keybind: command.keybindParts("agent.cycle"), + options: props.controls.agents.options, + current: props.controls.agents.current, + style: control(), + onSelect: (value) => { + props.controls.agents.select(value) + restoreFocus() + }, + })) return (
{(promptReady(), null)} @@ -1456,281 +1604,503 @@ export const PromptInput: Component = (props) => { onSlashMenuKeyDown={handleSlashMenuKeyDown} commandKeybind={command.keybind} commandKeybindParts={command.keybindParts} - newLayoutDesigns={false} + newLayoutDesigns={props.controls.newLayoutDesigns} t={(key) => language.t(key as Parameters[0])} /> - - - { - const active = comments.active() - return !!item.commentID && item.commentID === active?.id && item.path === active?.file - }} - openComment={openComment} - remove={(item) => { - if (item.commentID) comments.remove(item.path, item.commentID) - prompt.context.remove(item.key) - }} - newLayoutDesigns={false} - t={(key) => language.t(key as Parameters[0])} - /> - - dialog.show(() => ) - } - onRemove={removeAttachment} - removeLabel={language.t("prompt.attachment.remove")} - newLayoutDesigns={false} - /> -
{ - const target = e.target - if (!(target instanceof HTMLElement)) return - if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) { - return - } - editorRef?.focus() - }} - > -
(scrollRef = el)} - style={{ "scroll-padding-bottom": space }} - > -
+ +
+ -
- {placeholder()} -
-
- - - - - -
-
+ + { + const active = comments.active() + return !!item.commentID && item.commentID === active?.id && item.path === active?.file + }} + openComment={openComment} + remove={(item) => { + if (item.commentID) comments.remove(item.path, item.commentID) + prompt.context.remove(item.key) + }} + t={(key) => language.t(key as Parameters[0])} + /> + + dialog.show(() => ) + } + onRemove={removeAttachment} + removeLabel={language.t("prompt.attachment.remove")} + />
{ + const target = e.target + if (!(target instanceof HTMLElement)) return + if (target.closest('[data-action^="prompt-"]')) return + editorRef?.focus() }} > - - {language.t("prompt.mode.shell")} -
-
+ + + + + { + const active = comments.active() + return !!item.commentID && item.commentID === active?.id && item.path === active?.file + }} + openComment={openComment} + remove={(item) => { + if (item.commentID) comments.remove(item.path, item.commentID) + prompt.context.remove(item.key) + }} + t={(key) => language.t(key as Parameters[0])} + /> + + dialog.show(() => ) + } + onRemove={removeAttachment} + removeLabel={language.t("prompt.attachment.remove")} + /> +
{ + const target = e.target + if (!(target instanceof HTMLElement)) return + if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) { + return + } + editorRef?.focus() + }} + > +
(scrollRef = el)} + style={{ "scroll-padding-bottom": space }} + > +
+
+ {placeholder()} +
+
+ +