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/bun.lock b/bun.lock index e905ada8c5..950757385c 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.18.11", + "version": "1.18.0", "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.18.0", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -144,7 +143,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.18.11", + "version": "1.18.0", "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.18.0", "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.18.0", "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.18.0", "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.18.0", "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.18.0", "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.18.0", "bin": { "opencode": "./bin/opencode", }, @@ -303,7 +302,7 @@ "@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/mistral": "3.0.27", "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", @@ -339,7 +338,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.12.1", + "gitlab-ai-provider": "6.11.1", "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.18.0", "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.18.0", "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.18.0", "dependencies": { "effect": "catalog:", }, @@ -461,7 +460,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.18.11", + "version": "1.18.0", "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.18.0", "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.18.0", "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.18.0", "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.18.0", "bin": { "opencode": "./bin/opencode", }, @@ -578,7 +577,7 @@ "@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/mistral": "3.0.27", "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", @@ -632,7 +631,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.12.1", + "gitlab-ai-provider": "6.11.1", "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.18.0", "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.18.0", "dependencies": { "cross-spawn": "catalog:", }, @@ -781,7 +780,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.18.11", + "version": "1.18.0", "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.18.0", "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.18.0", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -853,7 +852,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.18.11", + "version": "1.18.0", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -887,7 +886,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.18.11", + "version": "1.18.0", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -906,7 +905,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.18.11", + "version": "1.18.0", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -938,7 +937,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 +946,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.18.11", + "version": "1.18.0", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -975,7 +973,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.18.11", + "version": "1.18.0", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1001,7 +999,6 @@ "remend": "catalog:", "shiki": "catalog:", "solid-list": "catalog:", - "solid-sonner": "catalog:", "strip-ansi": "7.1.2", }, "devDependencies": { @@ -1027,7 +1024,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.18.11", + "version": "1.18.0", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -1076,13 +1073,11 @@ "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", "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", "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", @@ -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", @@ -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=="], @@ -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.11.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-SJ6f5qa7P8md6lPrserryER3zerLkrezlnqqYQ2AbvDPpHLbwtbyk0FYJ5kNRcmbI80i/VMcsMBP0YIRdc3ucQ=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], @@ -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=="], @@ -5706,9 +5698,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,8 +6024,6 @@ "@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-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 +6032,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 +6042,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=="], @@ -6184,8 +6172,6 @@ "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=="], @@ -7002,8 +6988,6 @@ "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=="], @@ -7430,8 +7414,6 @@ "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=="], 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..9d85faba7f 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-F1luclnqCPQk9yxfmeSYGaM/nScf28yBu9K3Fv+Xd24=", + "aarch64-linux": "sha256-XW0XZnsCRkU3MFJH9TjMRYZHffzVy3cQyiNCkec2gl4=", + "aarch64-darwin": "sha256-bf8kvORs3Fs2UYLp3PekF+AJR7NKOcHb+fIQA79RtMk=", + "x86_64-darwin": "sha256-sBdQPkzd7JXNW6Lbi9JHiAsfHwdLwTKWY+uPeXAv2Nw=" } } diff --git a/package.json b/package.json index 2a8eef6356..cb8a1a5a27 100644 --- a/package.json +++ b/package.json @@ -40,9 +40,9 @@ "@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", + "@opentui/core": "0.4.3", + "@opentui/keymap": "0.4.3", + "@opentui/solid": "0.4.3", "@tanstack/solid-virtual": "3.13.32", "@shikijs/stream": "4.2.0", "ulid": "3.0.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,14 +143,12 @@ "@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", 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..5095d95db0 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(), 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/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 index 35a0aa44cd..c17ae5c1c6 100644 --- a/packages/app/e2e/regression/remote-session-settings.spec.ts +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -1,7 +1,6 @@ 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" @@ -18,7 +17,7 @@ test("session settings use the remote server context", async ({ page }) => { await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`) await expect(page.getByText(sessionB.title).first()).toBeVisible() - await page.keyboard.press("Control+,") + await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,") const dialog = page.locator(".settings-v2-dialog") const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]') @@ -59,7 +58,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => 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+,") + await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "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() @@ -181,36 +180,10 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR 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/event" || url.pathname === "/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: {} }) + if (url.pathname === "/session/status") return json(route, {}) + if (url.pathname === "/session") return json(route, sessions) 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) @@ -243,12 +216,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR 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, {}) }) } 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..25ebd3a37a 100644 --- a/packages/app/e2e/regression/review-open-file.spec.ts +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -133,7 +133,7 @@ test("opens and searches project files inline", async ({ page }) => { 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 panel.getByRole("tab", { name: /Review/ }).click() await expect(sidebarToggle).toBeEnabled() await panel.getByRole("tab", { name: "Open file" }).click() await page.keyboard.press("Control+w") 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-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts index 79b564820e..154bab48c4 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(() => { @@ -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..714d6ca96f 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -42,8 +42,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 +64,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,8 +97,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" }) + expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`) + expect(request.postDataJSON()).toEqual({ response: "once" }) }) test("restores the draft caret before typing after a request dock closes", async ({ page }) => { @@ -173,7 +170,6 @@ async function mockServer( }, ) { await mockOpenCodeServer(page, { - protocol: "v2", directory, project: { id: projectID, 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-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..f672602782 100644 --- a/packages/app/e2e/regression/terminal-composer-focus.spec.ts +++ b/packages/app/e2e/regression/terminal-composer-focus.spec.ts @@ -13,7 +13,6 @@ test.use({ viewport: { width: 1440, height: 900 } }) test.beforeEach(async ({ page }) => { await mockOpenCodeServer(page, { - protocol: "v2", directory, project: { id: projectID, @@ -47,30 +46,25 @@ 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 } })) }) @@ -101,12 +95,12 @@ test("keeps composer focus when a cached terminal finishes mounting", async ({ p const ghostty = Promise.withResolvers() const release = Promise.withResolvers() const created = { count: 0 } - await page.route("**/api/pty*", (route) => { + await page.route("**/pty", (route) => { created.count += 1 return 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(/ghostty-web/, async (route) => { @@ -161,31 +155,27 @@ test("keeps newer composer focus while an explicit terminal open finishes", asyn test("focuses a terminal created from the new-terminal button", async ({ page }) => { const created = { count: 0 } - await page.route("**/api/pty*", (route) => { + await page.route("**/pty", (route) => { created.count += 1 - const next = created.count === 1 ? ptyInfo(ptyID, "Terminal 1") : ptyInfo(newPtyID, "Terminal 2") + const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" } return route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ location: ptyLocation(), data: next }), + body: JSON.stringify(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(`**/pty/${newPtyID}`, (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), ) - await page.route(`**/api/pty/${newPtyID}/connect-token*`, (route) => + await page.route(`**/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 } }), + body: JSON.stringify({ ticket: "e2e-ticket" }), }), ) - await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined) + await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await expectSessionTitle(page, "Terminal composer focus") @@ -217,11 +207,3 @@ function seedCachedTerminal(page: Page) { { 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/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/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..2bfba5871a 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,19 @@ 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 === "/api/session") + return json(route, { + data: config.sessions.map((session) => v2Session(session, config.directory)), + cursor: {}, + }) + 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 +89,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 +115,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,37 +137,12 @@ 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) { +function v2Session(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: { @@ -359,67 +157,7 @@ export function currentSession(session: { id: string } & Record 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: [] }, - }, - ] - }), + ...(typeof session.path === "string" ? { subpath: session.path } : {}), } } @@ -443,18 +181,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..55420485f3 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,10 +161,7 @@ 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") - ) + if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event")) return originalFetch(request) const id = ++nextConnectionID @@ -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..20487778a8 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.18.0", "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..5592e2f4ea 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")) @@ -237,30 +226,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__?: { 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")}

{ 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 index 0c5d576e93..3ec999da06 100644 --- a/packages/app/src/components/edit-project.ts +++ b/packages/app/src/components/edit-project.ts @@ -1,7 +1,6 @@ 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" @@ -71,26 +70,13 @@ export function createEditProjectModel(props: { project: LocalProject; server: S 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)), - ) + await serverCtx().sdk.client.project.update({ + projectID: props.project.id, + directory: props.project.worktree, + name, + icon: { color: store.color || "", override: store.iconOverride || "" }, + commands: { start }, + }) serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined) dialog.close() return 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/help-button.tsx b/packages/app/src/components/help-button.tsx index 18dd727084..b242d6b561 100644 --- a/packages/app/src/components/help-button.tsx +++ b/packages/app/src/components/help-button.tsx @@ -8,21 +8,61 @@ import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4" import homeImage from "@/assets/help/home.png" import tabsImage from "@/assets/help/tabs.png" +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() const [drawerOpen, setDrawerOpen] = createSignal(false) - const windows = () => platform.platform === "desktop" && platform.os === "windows" return (
- - + +
+

+ July 14 +

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

- July 14 -

- - } - /> -

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..a820067a4e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -13,6 +13,8 @@ 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, @@ -47,6 +49,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 +60,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", @@ -591,7 +681,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 +799,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 +1191,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 +1539,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 +1607,517 @@ 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()} -
-
- - - - - -
-
+ + !isCommentItem(item))} + active={(item) => { + 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={props.controls.newLayoutDesigns} + t={(key) => language.t(key as Parameters[0])} + /> + + dialog.show(() => ) + } + onRemove={removeAttachment} + removeLabel={language.t("prompt.attachment.remove")} + newLayoutDesigns={props.controls.newLayoutDesigns} + comments={contextItems().filter(isCommentItem)} + commentActive={(item) => { + const active = comments.active() + return !!item.commentID && item.commentID === active?.id && item.path === active?.file + }} + onOpenComment={openComment} + onRemoveComment={(item) => { + if (item.commentID) comments.remove(item.path, item.commentID) + prompt.context.remove(item.key) + }} + />
{ + 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) + }} + newLayoutDesigns={props.controls.newLayoutDesigns} + t={(key) => language.t(key as Parameters[0])} + /> + + dialog.show(() => ) + } + onRemove={removeAttachment} + removeLabel={language.t("prompt.attachment.remove")} + newLayoutDesigns={props.controls.newLayoutDesigns} + /> +
{ + 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()} +
+
+ +
- {import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && state.debugTools && } + {import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && }
+
) @@ -2447,7 +2430,7 @@ function UpdateAvailableToast(props: { onCleanup(() => { if (toastId === undefined) return - dismissToast(toastId) + toaster.dismiss(toastId) }) return null diff --git a/packages/app/src/pages/layout/project-avatar-state.ts b/packages/app/src/pages/layout/project-avatar-state.ts index 236f6bd405..8e5dc38d67 100644 --- a/packages/app/src/pages/layout/project-avatar-state.ts +++ b/packages/app/src/pages/layout/project-avatar-state.ts @@ -13,6 +13,7 @@ export function useSessionTabAvatarState( const global = useGlobal() const notification = useNotification() const permission = usePermission() + const permissionState = createMemo(() => permission.ensureServerState(server())) const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server())) const sync = createMemo(() => { const conn = connection() @@ -21,10 +22,9 @@ export function useSessionTabAvatarState( const hasPermissions = createMemo(() => { const serverSync = sync() if (!serverSync) return false - const permissionState = permission.ensureServerState(server()) const [store] = serverSync.child(directory(), { bootstrap: false }) return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => { - return !permissionState.autoResponds(item, directory()) + return !permissionState().autoResponds(item, directory()) }) }) const hasQuestions = createMemo(() => { @@ -34,11 +34,9 @@ export function useSessionTabAvatarState( return !!sessionQuestionRequest(store.session, serverSync.session.data.question, sessionId()) }) const needsAttention = createMemo(() => hasPermissions() || hasQuestions()) - const notificationState = createMemo(() => { - if (!connection()) return - return notification.ensureServerState(server()) - }) - const unread = createMemo(() => needsAttention() || (notificationState()?.session.unseenCount(sessionId()) ?? 0) > 0) + const unread = createMemo( + () => needsAttention() || notification.ensureServerState(server()).session.unseenCount(sessionId()) > 0, + ) const loading = createMemo(() => { const serverSync = sync() if (!serverSync) return false diff --git a/packages/app/src/pages/layout/session-tab-avatar.tsx b/packages/app/src/pages/layout/session-tab-avatar.tsx index 0902173643..3c776c8671 100644 --- a/packages/app/src/pages/layout/session-tab-avatar.tsx +++ b/packages/app/src/pages/layout/session-tab-avatar.tsx @@ -19,34 +19,16 @@ export function SessionTabAvatar(props: { () => props.directory, () => props.sessionId, ) - return ( - - ) -} - -export function SessionTabAvatarView(props: { - project?: LocalProject - directory: string - revealProjectOnHover?: boolean - unread: boolean - loading: boolean -}) { const projectAvatar = () => ( ) return ( - + sdk().directory) + const openProviderSettings = useSettingsDialog("providers") + useSettingsCommand() + const route = useSessionKey() + const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() + const local = useLocal() + const model = createPromptModelSelection({ agent: local.agent.current }) + + useComposerCommands({ model }) + + let inputRef: HTMLDivElement | undefined + + const inputController = createPromptInputController({ + sessionKey: route.sessionKey, + sessionID: () => route.params.id, + queryOptions: serverSync().queryOptions, + model, }) - const project = createPromptProjectController({ - controls: draft.project.controls, - onDone: draft.input.restoreFocus, + const projectControls = createPromptProjectControls() + const projectController = createPromptProjectController({ + controls: projectControls, + onDone: () => inputRef?.focus(), }) - useNewSessionCommands({ - restoreFocus: draft.input.restoreFocus, - project: { - empty: project.empty, - open: () => project.setOpen(true), + + command.register("new-session", () => [ + { + id: "input.focus", + title: language.t("command.input.focus"), + category: language.t("command.category.view"), + keybind: "ctrl+l", + onSelect: () => inputRef?.focus(), }, + ]) + + const [store, setStore] = createStore<{ worktree?: string }>({}) + const rightMount = useTitlebarRightMount() + + const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git") + const newSessionWorktree = createMemo(() => { + if (!showWorkspaceBar()) return "main" + if (store.worktree) return store.worktree + const project = sync().project + if (project && sdk().directory !== project.worktree) return sdk().directory + return "main" }) + const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory) + const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch) + const selectedBranch = createMemo(() => { + const worktree = newSessionWorktree() + if (worktree === "main" || worktree === "create") return localBranch() + return serverSync().child(worktree)[0].vcs?.branch ?? localBranch() + }) + createEffect(() => { - if (!draft.prompt.ready()) return - draft.input.restoreFocus() + if (!prompt.ready()) return + untrack(() => { + const text = searchParams.prompt + if (!text) return + prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) + setSearchParams({ ...searchParams, prompt: undefined }) + }) + }) + + createEffect(() => { + if (!prompt.ready()) return + requestAnimationFrame(() => inputRef?.focus()) }) const ready = Promise.resolve() - const [suspendUntilPromptReady] = createResource( - () => draft.prompt.readyPromise() ?? ready, + const [promptReady] = createResource( + () => prompt.ready.promise ?? ready, (promise) => promise.then(() => true), ) return (
- {suspendUntilPromptReady()} - + + {(mount) => ( + + + + + + + + )} +
- +
+
+ +
+ + {language.t("prompt.loading")} +
+ } + > +
+ { + inputRef = el + }} + newSessionWorktree={newSessionWorktree()} + onNewSessionWorktreeReset={() => setStore("worktree", undefined)} + onSubmit={() => comments.clear()} + toolbar={ + + + + } + /> + +
+ + + + setStore( + "worktree", + value === "main" && sync().project?.worktree !== sdk().directory + ? sync().project?.worktree + : value, + ) + } + onDone={() => inputRef?.focus()} + /> + +
+
+
+ +
+ + serverSync().child(sdk().directory)[0].provider_ready} + connected={() => providers.paid().length > 0} + openProviders={openProviderSettings} + /> +
+
) } + +function ProviderTip(props: { ready: () => boolean; connected: () => boolean; openProviders: () => void }) { + const language = useLanguage() + const [persistedState, setPersistedState, , persistedReady] = persisted( + Persist.global("new-session.provider-tip"), + createStore({ dismissedAt: 0 }), + ) + const visible = createMemo( + () => + props.ready() && + persistedReady() && + !props.connected() && + Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration, + ) + + function dismiss() { + setPersistedState("dismissedAt", Date.now()) + } + + const [ref, setRef] = createSignal() + const presence = createPresence({ + show: () => visible(), + element: () => ref() ?? null, + }) + + return ( + +
+
+ + + + +
+
+
+ ) +} diff --git a/packages/app/src/pages/new-session/new-session-draft-controller.ts b/packages/app/src/pages/new-session/new-session-draft-controller.ts deleted file mode 100644 index bf22834e48..0000000000 --- a/packages/app/src/pages/new-session/new-session-draft-controller.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { useSearchParams } from "@solidjs/router" -import { createEffect, untrack } from "solid-js" -import { usePromptInputV2Controller } from "@/components/prompt-input-v2" -import { useComments } from "@/context/comments" -import { useLocal } from "@/context/local" -import { usePrompt } from "@/context/prompt" -import { useServerSync } from "@/context/server-sync" -import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer" -import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection" -import { useSessionKey } from "@/pages/session/session-layout" -import { useComposerCommands } from "@/pages/session/use-composer-commands" - -export function createNewSessionDraftController(workspace: { worktree: () => string; resetWorktree: () => void }) { - const prompt = usePrompt() - const serverSync = useServerSync() - const comments = useComments() - const local = useLocal() - const route = useSessionKey() - const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() - const model = createPromptModelSelection({ agent: () => local.agent.current() }) - - useComposerCommands({ model }) - - const controls = createPromptInputController({ - sessionKey: route.sessionKey, - sessionID: () => route.params.id, - queryOptions: serverSync().queryOptions, - model, - }) - const projectControls = createPromptProjectControls() - const input = usePromptInputV2Controller({ - get controls() { - return controls() - }, - get newSessionWorktree() { - return workspace.worktree() - }, - onNewSessionWorktreeReset: workspace.resetWorktree, - onSubmit: comments.clear, - }) - - createEffect(() => { - if (!prompt.ready()) return - untrack(() => { - const text = searchParams.prompt - if (!text) return - prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) - setSearchParams({ ...searchParams, prompt: undefined }) - }) - }) - - return { - input, - prompt: { - ready: prompt.ready, - readyPromise: () => prompt.ready.promise, - }, - project: { - controls: projectControls, - }, - } -} - -export type NewSessionDraftController = ReturnType diff --git a/packages/app/src/pages/new-session/new-session-view.tsx b/packages/app/src/pages/new-session/new-session-view.tsx deleted file mode 100644 index 5960e64335..0000000000 --- a/packages/app/src/pages/new-session/new-session-view.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { Tooltip } from "@opencode-ai/ui/tooltip" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2" -import { Show, createMemo, createSignal, type Accessor } from "solid-js" -import { createStore } from "solid-js/store" -import { Portal } from "solid-js/web" -import createPresence from "solid-presence" -import { PromptInputV2Composer } from "@/components/prompt-input-v2" -import { PromptGitStatus, PromptWorkspaceSelector } from "@/components/prompt-workspace-selector" -import { - PromptProjectAddButton, - PromptProjectSelector, - type PromptProjectController, -} from "@/components/prompt-project-selector" -import { StatusPopoverV2 } from "@/components/status-popover" -import { useLanguage } from "@/context/language" -import { useSDK } from "@/context/sdk" -import { useServerSync } from "@/context/server-sync" -import { useProviders } from "@/hooks/use-providers" -import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" -import { Persist, persisted } from "@/utils/persist" -import type { NewSessionDraftController } from "./new-session-draft-controller" -import type { NewSessionWorkspaceController } from "./new-session-workspace-controller" - -const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000 - -export function NewSessionView(props: { - input: NewSessionDraftController["input"] - project: PromptProjectController - workspace: NewSessionWorkspaceController -}) { - return ( -
-
-
-
- -
- - - - - -
- - - } - > - - -
-
-
-
-
- -
-
- ) -} - -export function NewSessionStatus(props: { mount: Accessor; visible: Accessor }) { - const language = useLanguage() - - return ( - - {(mount) => ( - - - - - - - - )} - - ) -} - -function ProviderTip() { - const language = useLanguage() - const dialog = useDialog() - const sdk = useSDK() - const serverSync = useServerSync() - const providers = useProviders(() => sdk().directory) - const [persistedState, setPersistedState, , persistedReady] = persisted( - Persist.global("new-session.provider-tip"), - createStore({ dismissedAt: 0 }), - ) - const visible = createMemo( - () => - serverSync().child(sdk().directory)[0].provider_ready && - persistedReady() && - providers.paid().length === 0 && - Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration, - ) - const [ref, setRef] = createSignal() - const presence = createPresence({ - show: visible, - element: () => ref() ?? null, - }) - const openProviders = () => { - void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => { - void dialog.show(() => sdk().directory} />) - }) - } - - return ( - -
-
- - - - -
-
-
- ) -} diff --git a/packages/app/src/pages/new-session/new-session-workspace-controller.test.ts b/packages/app/src/pages/new-session/new-session-workspace-controller.test.ts deleted file mode 100644 index 2a79ae77fa..0000000000 --- a/packages/app/src/pages/new-session/new-session-workspace-controller.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { - normalizeNewSessionWorktree, - resolveNewSessionBranch, - resolveNewSessionWorktree, -} from "./new-session-workspace-controller" - -describe("new session workspace selection", () => { - test("uses main when the workspace bar is unavailable", () => { - expect( - resolveNewSessionWorktree({ - enabled: false, - selected: "/project/feature", - directory: "/project/feature", - projectWorktree: "/project", - }), - ).toBe("main") - }) - - test("derives an existing worktree from the current directory", () => { - expect( - resolveNewSessionWorktree({ enabled: true, directory: "/project/feature", projectWorktree: "/project" }), - ).toBe("/project/feature") - expect(resolveNewSessionWorktree({ enabled: true, directory: "/project", projectWorktree: "/project" })).toBe( - "main", - ) - }) - - test("normalizes main to the project root outside the main worktree", () => { - expect(normalizeNewSessionWorktree("main", "/project/feature", "/project")).toBe("/project") - expect(normalizeNewSessionWorktree("main", "/project", "/project")).toBe("main") - }) - - test("falls back to the local branch for main, create, and unknown worktrees", () => { - const branch = (worktree: string) => (worktree === "/project/feature" ? "feature" : undefined) - expect(resolveNewSessionBranch({ worktree: "main", local: "dev", worktreeBranch: branch })).toBe("dev") - expect(resolveNewSessionBranch({ worktree: "create", local: "dev", worktreeBranch: branch })).toBe("dev") - expect(resolveNewSessionBranch({ worktree: "/project/feature", local: "dev", worktreeBranch: branch })).toBe( - "feature", - ) - expect(resolveNewSessionBranch({ worktree: "/missing", local: "dev", worktreeBranch: branch })).toBe("dev") - }) -}) diff --git a/packages/app/src/pages/new-session/new-session-workspace-controller.ts b/packages/app/src/pages/new-session/new-session-workspace-controller.ts deleted file mode 100644 index f3fc9b2708..0000000000 --- a/packages/app/src/pages/new-session/new-session-workspace-controller.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { createMemo, createSignal } from "solid-js" -import { useSDK } from "@/context/sdk" -import { useServerSync } from "@/context/server-sync" -import { useSync } from "@/context/sync" - -const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" - -export function resolveNewSessionWorktree(input: { - enabled: boolean - selected?: string - directory: string - projectWorktree?: string -}) { - if (!input.enabled) return "main" - if (input.selected) return input.selected - if (input.projectWorktree && input.directory !== input.projectWorktree) return input.directory - return "main" -} - -export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) { - if (value === "main" && projectWorktree !== directory) return projectWorktree - return value -} - -export function resolveNewSessionBranch(input: { - worktree: string - local?: string - worktreeBranch: (worktree: string) => string | undefined -}) { - if (input.worktree === "main" || input.worktree === "create") return input.local - return input.worktreeBranch(input.worktree) ?? input.local -} - -export function createNewSessionWorkspaceController() { - const sdk = useSDK() - const sync = useSync() - const serverSync = useServerSync() - const [worktree, setWorktree] = createSignal() - const visible = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git") - const value = createMemo(() => - resolveNewSessionWorktree({ - enabled: visible(), - selected: worktree(), - directory: sdk().directory, - projectWorktree: sync().project?.worktree, - }), - ) - const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory) - const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch) - const branch = createMemo(() => - resolveNewSessionBranch({ - worktree: value(), - local: localBranch(), - worktreeBranch: (worktree) => serverSync().child(worktree)[0].vcs?.branch, - }), - ) - - return { - selection: { - value, - reset: () => setWorktree(), - set: (worktree: string) => - setWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree)), - }, - project: { - root: projectRoot, - workspaces: () => sync().project?.sandboxes ?? [], - git: () => sync().project?.vcs === "git", - }, - bar: { - visible, - branch, - }, - } -} - -export type NewSessionWorkspaceController = ReturnType diff --git a/packages/app/src/pages/new-session/use-new-session-commands.tsx b/packages/app/src/pages/new-session/use-new-session-commands.tsx deleted file mode 100644 index a0d835f97f..0000000000 --- a/packages/app/src/pages/new-session/use-new-session-commands.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useSettingsCommand } from "@/components/settings-dialog" -import { useCommand } from "@/context/command" -import { useLanguage } from "@/context/language" - -export function useNewSessionCommands(input: { - restoreFocus: () => void - project: { - empty: () => boolean - open: () => void - } -}) { - const command = useCommand() - const dialog = useDialog() - const language = useLanguage() - - useSettingsCommand() - command.register("new-session", () => [ - { - id: "command.palette", - title: language.t("command.palette"), - hidden: true, - onSelect: async () => { - const { DialogSelectFile } = await import("@/components/dialog-select-file") - void dialog.show(() => ) - }, - }, - { - id: "input.focus", - title: language.t("command.input.focus"), - category: language.t("command.category.view"), - keybind: "ctrl+l", - onSelect: input.restoreFocus, - }, - { - id: "project.select", - title: language.t("session.new.project.search"), - category: language.t("command.category.project"), - keybind: "mod+shift+o", - disabled: input.project.empty(), - onSelect: input.project.open, - }, - ]) -} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index c6e3a5fcbb..cfa5498c2e 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -6,7 +6,6 @@ import { batch, ErrorBoundary, onCleanup, - Suspense, Show, Match, Switch, @@ -42,7 +41,6 @@ import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/r import { NewSessionView, SessionHeader } from "@/components/session" import { ErrorPage } from "@/pages/error" import { CommentsProvider, useComments } from "@/context/comments" -import { useCommand } from "@/context/command" import { DirectoryDataProvider } from "@/pages/directory-layout" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" @@ -59,7 +57,6 @@ import { useSync } from "@/context/sync" import { useTabs } from "@/context/tabs" import { TerminalProvider, useTerminal } from "@/context/terminal" import { PromptInput } from "@/components/prompt-input" -import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2" import { useSettingsCommand } from "@/components/settings-dialog" import { setCursorPosition } from "@/components/prompt-input/editor-dom" import { promptLength } from "@/components/prompt-input/history" @@ -365,7 +362,6 @@ export default function Page() { const platform = usePlatform() const prompt = usePrompt() const comments = useComments() - const command = useCommand() const terminal = useTerminal() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const location = useLocation() @@ -533,6 +529,7 @@ export default function Page() { const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) const isChildSession = createMemo(() => !!info()?.parentID) + const diffs = createMemo(() => (params.id ? list(sync().data.session_diff[params.id]) : [])) const canReview = createMemo(() => !!sync().project) const reviewTab = createMemo(() => isDesktop()) const tabState = createSessionTabs({ @@ -668,8 +665,7 @@ export default function Page() { const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") const wantsReview = createMemo(() => isDesktop() - ? desktopFileTreeOpen() || - (desktopReviewOpen() && (activeTab() === "review" || (newSessionDesign() && !!activeFileTab()))) + ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") : store.mobileTab === "changes", ) const vcsMode = createMemo(() => { @@ -690,8 +686,8 @@ export default function Page() { queryFn: mode ? () => sdk() - .api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode }) - .then((result) => result.data) + .client.vcs.diff({ mode }) + .then((result) => list(result.data)) .catch((error) => { console.debug("[session-review] failed to load vcs diff", { mode, error }) return [] @@ -738,12 +734,8 @@ export default function Page() { retry: 2, queryFn: () => sdk() - .api.vcs.diff({ - location: { directory: scope }, - mode: mode === "git" ? "working" : mode, - context, - }) - .then((result) => result.data), + .client.vcs.diff({ mode, directory: scope, context }) + .then((result) => result.data ?? []), }) .then((diffs) => diffs.find((diff) => diff.file === file)) @@ -950,11 +942,10 @@ export default function Page() { ) const stopVcs = sdk().event.listen((evt) => { - const details = evt.details as { type: string; properties?: unknown } - if (details.type !== "file.watcher.updated" && details.type !== "filesystem.changed") return + if (evt.details.type !== "file.watcher.updated") return const props = - typeof details.properties === "object" && details.properties - ? (details.properties as Record) + typeof evt.details.properties === "object" && evt.details.properties + ? (evt.details.properties as Record) : undefined const file = typeof props?.file === "string" ? props.file : undefined if (!file || file.startsWith(".git/")) return @@ -1144,14 +1135,6 @@ export default function Page() { review: reviewTab, fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id, }) - command.register("session-palette", () => [ - { - id: "command.palette", - title: language.t("command.palette"), - hidden: true, - onSelect: () => command.trigger("file.open", "palette"), - }, - ]) const openReviewFile = createOpenReviewFile({ showAllFiles, @@ -1469,6 +1452,44 @@ export default function Page() { requestAnimationFrame(() => attempt(0)) }) + createEffect(() => { + const id = params.id + if (!id) return + + if (!wantsReview()) return + if (sync().data.session_diff[id] !== undefined) return + if (sync().status === "loading") return + + void sync().session.diff(id) + }) + + createEffect( + on( + () => [sessionKey(), wantsReview()] as const, + ([key, wants]) => { + if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) + if (diffTimer !== undefined) window.clearTimeout(diffTimer) + diffFrame = undefined + diffTimer = undefined + if (!wants) return + + const id = params.id + if (!id) return + if (!untrack(() => sync().data.session_diff[id] !== undefined)) return + + diffFrame = requestAnimationFrame(() => { + diffFrame = undefined + diffTimer = window.setTimeout(() => { + diffTimer = undefined + if (sessionKey() !== key) return + void sync().session.diff(id, { force: true }) + }, 0) + }) + }, + { defer: true }, + ), + ) + let treeDir: string | undefined createEffect(() => { const dir = sdk().directory @@ -1724,7 +1745,7 @@ export default function Page() { setFollowup("failed", input.sessionID, undefined) const ok = await sendFollowupDraft({ - api: sdk().api.session, + client: sdk().client, sync: sync(), serverSync: serverSync(), draft: item, @@ -1820,13 +1841,13 @@ export default function Page() { const halt = (sessionID: string) => busy(sessionID) ? sdk() - .api.session.interrupt({ sessionID }) + .client.session.abort({ sessionID }) .catch(() => {}) : Promise.resolve() const revertMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; messageID: string }) => { - const session = sdk().api.session + const client = sdk().client const target = sync() const last = target.session.get(input.sessionID)?.revert const value = draft(input.messageID) @@ -1836,8 +1857,10 @@ export default function Page() { roll(input.sessionID, { messageID: input.messageID }, target) prompt.set(value) }, - request: () => halt(input.sessionID).then(() => session.revert.stage(input)), - complete: () => undefined, + request: () => halt(input.sessionID).then(() => client.session.revert(input)), + complete: (result) => { + if (result.data) merge(result.data, target) + }, rollback: () => roll(input.sessionID, last, target), fail, }) @@ -1849,7 +1872,7 @@ export default function Page() { const sessionID = params.id if (!sessionID) return - const session = sdk().api.session + const client = sdk().client const target = sync() const next = userMessages().find((item) => item.id > id) const last = target.session.get(sessionID)?.revert @@ -1866,9 +1889,11 @@ export default function Page() { }, request: () => !next - ? halt(sessionID).then(() => session.revert.clear({ sessionID })) - : halt(sessionID).then(() => session.revert.stage({ sessionID, messageID: next.id }).then(() => undefined)), - complete: () => undefined, + ? halt(sessionID).then(() => client.session.unrevert({ sessionID })) + : halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })), + complete: (result) => { + if (result.data) merge(result.data, target) + }, rollback: () => roll(sessionID, last, target), fail, }) @@ -2010,6 +2035,83 @@ export default function Page() { useUsageExceededDialogs() + const composerRegion = () => { + const controller = createSessionComposerRegionController({ + state: composer, + sessionKey, + sessionID: () => params.id, + prompt, + ready: () => !store.deferRender && messagesReady(), + centered, + todo: { + collapsed: () => view().todoCollapsed.get(), + onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()), + }, + followup: () => + params.id && !isChildSession() + ? { + items: followupDock(), + sending: sendingFollowup(), + onSend: (id) => void sendFollowup(params.id!, id, { manual: true }), + onEdit: editFollowup, + } + : undefined, + revert: () => + rolled().length > 0 + ? { + items: rolled(), + restoring: restoring(), + disabled: reverting(), + onRestore: restore, + } + : undefined, + onResponseSubmit: resumeScroll, + openParent: () => { + const id = info()?.parentID + if (!id) return + navigate( + params.serverKey + ? sessionHref(requireServerKey(params.serverKey), id) + : legacySessionHref(sdk().directory, id), + ) + }, + setPromptRef: (el) => { + inputRef = el + }, + setDockRef: (el) => { + promptDock = el + }, + }) + return ( + { + inputRef = el + }} + newSessionWorktree={newSessionWorktree()} + onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} + onSubmit={() => { + comments.clear() + resumeScroll() + }} + edit={editingFollowup()} + onEditLoaded={clearFollowupEdit} + shouldQueue={queueEnabled} + onQueue={queueFollowup} + onAbort={() => { + const id = params.id + if (!id) return + setFollowup("paused", id, true) + }} + /> + } + /> + ) + } + const mobileTabs = (compact = false, bottom = false) => (
- - {(_) => { - const controller = createSessionComposerRegionController({ - state: composer, - sessionKey, - sessionID: () => params.id, - prompt, - ready: () => !store.deferRender && messagesReady(), - centered, - todo: { - collapsed: () => view().todoCollapsed.get(), - onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()), - }, - followup: () => - params.id && !isChildSession() - ? { - items: followupDock(), - sending: sendingFollowup(), - onSend: (id) => void sendFollowup(params.id!, id, { manual: true }), - onEdit: editFollowup, - } - : undefined, - revert: () => - rolled().length > 0 - ? { - items: rolled(), - restoring: restoring(), - disabled: reverting(), - onRestore: restore, - } - : undefined, - onResponseSubmit: resumeScroll, - openParent: () => { - const id = info()?.parentID - if (!id) return - navigate( - params.serverKey - ? sessionHref(requireServerKey(params.serverKey), id) - : legacySessionHref(sdk().directory, id), - ) - }, - setPromptRef: (el) => { - inputRef = el - }, - setDockRef: (el) => { - promptDock = el - }, - }) - return ( - { - inputRef = el - }} - newSessionWorktree={newSessionWorktree()} - onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} - onSubmit={() => { - comments.clear() - resumeScroll() - }} - edit={editingFollowup()} - onEditLoaded={clearFollowupEdit} - shouldQueue={queueEnabled} - onQueue={queueFollowup} - onAbort={() => { - const id = params.id - if (!id) return - setFollowup("paused", id, true) - }} - /> - } - > - {(_) => { - const controller = usePromptInputV2Controller({ - get controls() { - return inputController() - }, - ref: (el) => { - inputRef = el - }, - get newSessionWorktree() { - return newSessionWorktree() - }, - onNewSessionWorktreeReset: () => setStore("newSessionWorktree", "main"), - onSubmit: () => { - comments.clear() - resumeScroll() - }, - get edit() { - return editingFollowup() - }, - onEditLoaded: clearFollowupEdit, - shouldQueue: queueEnabled, - onQueue: queueFollowup, - onAbort: () => { - const id = params.id - if (!id) return - setFollowup("paused", id, true) - }, - }) - return - }} - - } - /> - ) - }} -
+ {(_) => composerRegion()} {mobileTabs(true, true)} ) @@ -2298,53 +2287,49 @@ export default function Page() {
- - - +
- - hasReview() || reviewV2State.sidebarOpened()} - reviewCount={reviewCount} - reviewPanel={reviewPanelV2} - reviewSidebarToggle={(disabled) => ( - - )} - fileBrowserState={reviewV2State} - activeDiff={activeReviewFile()} - focusReviewDiff={focusReviewDiff} - reviewSnap={ui.reviewSnap} - size={size} - stacked={desktopV2PanelLayout().stacked} - /> - + hasReview() || reviewV2State.sidebarOpened()} + reviewCount={reviewCount} + reviewPanel={reviewPanelV2} + reviewSidebarToggle={(disabled) => ( + + )} + fileBrowserState={reviewV2State} + activeDiff={activeReviewFile()} + focusReviewDiff={focusReviewDiff} + reviewSnap={ui.reviewSnap} + size={size} + stacked={desktopV2PanelLayout().stacked} + />
diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts index a9b0070bc0..8d5d96719c 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -2,7 +2,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { createQuery } from "@tanstack/solid-query" import { useNavigate, useSearchParams } from "@solidjs/router" import { type Accessor, createMemo } from "solid-js" -import type { PromptInputControls } from "@/components/prompt-input/contracts" +import type { PromptInputControls } from "@/components/prompt-input" import type { PromptProjectControls } from "@/components/prompt-project-selector" import { useDirectoryPicker } from "@/components/directory-picker" import { useGlobal } from "@/context/global" @@ -12,6 +12,7 @@ import type { QueryOptionsApi } from "@/context/server-sync" import { useServerSDK } from "@/context/server-sdk" import { serverName, ServerConnection, useServer } from "@/context/server" import { useSDK } from "@/context/sdk" +import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTabs } from "@/context/tabs" import { useProviders } from "@/hooks/use-providers" @@ -25,39 +26,36 @@ export function createPromptInputController(input: { }) { const layout = useLayout() const local = useLocal() - const sdk = useSDK() + const providers = useProviders() + const settings = useSettings() const sync = useSync() - const providers = useProviders(() => sdk().directory) + const sdk = useSDK() const view = layout.view(input.sessionKey) const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory))) const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null)) const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory))) - return createMemo(() => { - return { - agents: { - available: sync().data.agent, - options: local.agent.list().map((agent) => agent.name), - current: local.agent.current()?.name ?? "", - loading: agentsQuery.isLoading, - visible: local.agent.visible(), - select: local.agent.set, - }, - model: { - selection: input.model ?? local.model, - paid: providers.paid().length > 0, - loading: - (local.agent.visible() && agentsQuery.isLoading) || - providersQuery.isLoading || - globalProvidersQuery.isLoading, - }, - session: { - id: input.sessionID(), - tabs: layout.tabs(input.sessionKey), - reviewPanel: view.reviewPanel, - }, - } - }) + return createMemo(() => ({ + agents: { + available: sync().data.agent, + options: local.agent.list().map((agent) => agent.name), + current: local.agent.current()?.name ?? "", + loading: agentsQuery.isLoading, + visible: settings.visibility.customAgents(), + select: local.agent.set, + }, + model: { + selection: input.model ?? local.model, + paid: providers.paid().length > 0, + loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading, + }, + session: { + id: input.sessionID(), + tabs: layout.tabs(input.sessionKey), + reviewPanel: view.reviewPanel, + }, + newLayoutDesigns: settings.general.newLayoutDesigns(), + })) } export function createPromptProjectControls() { diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index f54e0c9e4f..45f5e4cb26 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -82,7 +82,7 @@ export function createSessionComposerController(options?: { closeMs?: number | ( setStore("responding", perm.id) sdk() - .api.permission.reply({ sessionID: perm.sessionID, requestID: perm.id, reply: response }) + .client.permission.respond({ sessionID: perm.sessionID, permissionID: perm.id, response }) .catch((err: unknown) => { const description = err instanceof Error ? err.message : String(err) showToast({ title: language.t("common.requestFailed"), description }) diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index 941424e247..445a9f47a0 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -223,8 +223,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } const replyMutation = useMutation(() => ({ - mutationFn: (answers: QuestionAnswer[]) => - sdk().api.question.reply({ sessionID: props.request.sessionID, requestID: props.request.id, answers }), + mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }), onMutate: () => { props.onSubmit() }, @@ -236,7 +235,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit })) const rejectMutation = useMutation(() => ({ - mutationFn: () => sdk().api.question.reject({ sessionID: props.request.sessionID, requestID: props.request.id }), + mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }), onMutate: () => { props.onSubmit() }, diff --git a/packages/app/src/pages/session/composer/session-revert-dock.stories.tsx b/packages/app/src/pages/session/composer/session-revert-dock.stories.tsx index ce3fd26b02..dd030870a1 100644 --- a/packages/app/src/pages/session/composer/session-revert-dock.stories.tsx +++ b/packages/app/src/pages/session/composer/session-revert-dock.stories.tsx @@ -1,6 +1,5 @@ import { For } from "solid-js" import { createStore } from "solid-js/store" -import { DockShell } from "@opencode-ai/ui/dock-surface" import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock" import { SettingsProvider, useSettings } from "@/context/settings" @@ -79,17 +78,12 @@ function Stage(props: { count: number }) { {/* Reproduce the real composer stack: dock + card overlapping the dock's bottom by lift() = 18px */}
- Ask anything... - +
diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx index d44b490262..f68428fcda 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -8,10 +8,8 @@ import { TextReveal } from "@opencode-ai/ui/text-reveal" import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough" import { createResizeObserver } from "@solid-primitives/resize-observer" import { Index, createEffect, createMemo } from "solid-js" -import { Dynamic } from "solid-js/web" import { createStore } from "solid-js/store" import { useLanguage } from "@/context/language" -import { useSettings } from "@/context/settings" const doneToken = "\u0000done\u0000" const totalToken = "\u0000total\u0000" @@ -50,7 +48,6 @@ export function SessionTodoDock(props: { dockProgress: number }) { const language = useLanguage() - const settings = useSettings() const [store, setStore] = createStore({ height: 78, }) @@ -94,13 +91,8 @@ export function SessionTodoDock(props: { }) return ( -
- + ) } diff --git a/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx b/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx index 41d1f984bd..61cfaf931b 100644 --- a/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx +++ b/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx @@ -71,6 +71,7 @@ const controls = { tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} }, reviewPanel: { opened: () => false, open: () => {} }, }, + newLayoutDesigns: true, } const css = ` diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index b67b810a4b..044ebda7ae 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -24,12 +24,6 @@ import { getSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" -type SessionFileViewProps = { - tab: string -} - -const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions" - function FileCommentMenu(props: { moreLabel: string editLabel: string @@ -213,9 +207,8 @@ export function FileTabContent(props: { tab: string }) { ) } -export function SessionFileView(props: SessionFileViewProps) { +export function SessionFileView(props: { tab: string }) { const settings = useSettings() - return ( }> @@ -555,10 +548,10 @@ function SessionFileViewV2(props: { tab: string }) { }) } - const buildPreview = (filePath: string, lines: SelectedLineRange) => { + const buildPreview = (filePath: string, selection: FileSelection) => { const source = filePath === path() ? contents() : file.get(filePath)?.content?.content if (!source) return undefined - return selectionPreview(source, selectionFromLines(lines)) + return selectionPreview(source, selection) } const addCommentToContext = (input: { @@ -569,7 +562,7 @@ function SessionFileViewV2(props: { tab: string }) { origin?: "review" | "file" }) => { const selection = selectionFromLines(input.selection) - const preview = input.preview ?? buildPreview(input.file, input.selection) + const preview = input.preview ?? buildPreview(input.file, selection) const saved = comments.add({ file: input.file, @@ -594,7 +587,7 @@ function SessionFileViewV2(props: { tab: string }) { comment: string }) => { comments.update(input.file, input.id, input.comment) - const preview = input.file === path() ? buildPreview(input.file, input.selection) : undefined + const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined prompt.context.updateComment(input.file, input.id, { comment: input.comment, ...(preview ? { preview } : {}), @@ -635,7 +628,7 @@ function SessionFileViewV2(props: { tab: string }) { mention: { items: file.searchFilesAndDirectories, }, - getSide: selectionSide, + getSide: (range) => range.endSide ?? range.side ?? "additions", state: { opened: () => note.openedComment, setOpened: (id) => setNote("openedComment", id), @@ -764,7 +757,7 @@ function SessionFileViewV2(props: { tab: string }) { commentsUi.onLineNumberSelectionEnd(range) }} search={search} - class="select-text" + class="select-text [--opencode-diffs-bg:var(--v2-background-bg-base)]" media={{ mode: "auto", path: path(), diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index 1b65af7121..3854bf0276 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -1,7 +1,6 @@ import { createEffect, onCleanup, type JSX } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" -import type { FileDiffInfo } from "@opencode-ai/client/promise" import { SessionReview } from "@opencode-ai/session-ui/session-review" import type { SessionReviewCommentActions, @@ -15,7 +14,7 @@ import type { LineComment } from "@/context/comments" export type DiffStyle = "unified" | "split" -type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff +type ReviewDiff = SnapshotFileDiff | VcsFileDiff export interface SessionReviewTabProps { title?: JSX.Element diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 6f074fa203..184fa684ec 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -24,7 +24,6 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" -import type { FileDiffInfo } from "@opencode-ai/client/promise" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -57,17 +56,15 @@ import { setSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab" -type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff -type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff -const FILE_TREE_WIDTH_MIN = 240 +type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff -function renderDiff(value: ReviewDiff): value is RenderDiff { +function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { return typeof value.file === "string" } export function SessionSidePanel(props: { canReview: () => boolean - diffs: () => ReviewDiff[] + diffs: () => (SnapshotFileDiff | VcsFileDiff)[] diffsReady: () => boolean empty: () => string hasReview: () => boolean @@ -105,14 +102,13 @@ export function SessionSidePanel(props: { }), ) const open = createMemo(() => reviewOpen() || fileOpen()) - const fileTreeWidth = createMemo(() => Math.max(FILE_TREE_WIDTH_MIN, layout.fileTree.width())) const reviewTab = createMemo(() => isDesktop()) const panelWidth = createMemo(() => { if (!open()) return "0px" if (reviewOpen()) return "auto" - return `${fileTreeWidth()}px` + return `${layout.fileTree.width()}px` }) - const treeWidth = createMemo(() => (fileOpen() ? `${fileTreeWidth()}px` : "0px")) + const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) const diffs = createMemo(() => props.diffs().filter(renderDiff)) const diffFiles = createMemo(() => diffs().map((d) => d.file)) @@ -241,7 +237,6 @@ export function SessionSidePanel(props: { return active !== "review" && active !== "context" && active !== "empty" }) const openFileKeybind = createMemo(() => command.keybindParts("file.open")) - const closeTabKeybind = createMemo(() => command.keybindParts("tab.close")) const [store, setStore] = createStore({ activeDraggable: undefined as string | undefined, }) @@ -555,7 +550,7 @@ export function SessionSidePanel(props: { > {(toggle) => ( -
+
{toggle()(activeTab() === SESSION_OPEN_FILE_TAB)}
)} @@ -575,15 +570,9 @@ export function SessionSidePanel(props: { - {language.t("common.closeTab")} - 0}> - - - - } + @@ -594,7 +583,7 @@ export function SessionSidePanel(props: { onClick={() => tabs().close("context")} aria-label={language.t("common.closeTab")} /> - + } hideCloseButton onMiddleClick={() => tabs().close("context")} @@ -622,15 +611,9 @@ export function SessionSidePanel(props: { - {language.t("common.closeTab")} - 0}> - - - - } + @@ -641,7 +624,7 @@ export function SessionSidePanel(props: { onClick={() => tabs().close(SESSION_OPEN_FILE_TAB)} aria-label={language.t("common.closeTab")} /> - + } hideCloseButton onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)} @@ -847,8 +830,8 @@ export function SessionSidePanel(props: { { props.size.touch() diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index eca1541cea..2959e9f8a9 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -283,14 +283,6 @@ export function MessageTimeline(props: { return sync().data.session_status[id] ?? idle }) const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : [])) - const projectedMessages = createMemo(() => { - const id = sessionID() - if (!id) return [] - const visible = new Set(props.userMessages.map((message) => message.id)) - const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id - const messages = sync().data.session_message[id] ?? [] - return boundary ? messages.filter((message) => message.id < boundary) : messages - }) const info = createMemo(() => { const id = sessionID() if (!id) return @@ -333,7 +325,6 @@ export function MessageTimeline(props: { const projection = createTimelineProjection({ messages: sessionMessages, userMessages: () => props.userMessages, - sessionMessages: projectedMessages, parts: getMsgParts, status: sessionStatus, showReasoningSummaries: settings.general.showReasoningSummaries, @@ -646,7 +637,7 @@ export function MessageTimeline(props: { const viewShare = () => { const url = shareUrl() if (!url) return - platform.openExternal(url) + platform.openLink(url) } const errorMessage = (err: unknown) => { @@ -674,7 +665,7 @@ export function MessageTimeline(props: { const titleMutation = useMutation(() => ({ mutationFn: (input: { id: string; title: string }) => - sdk().api.session.rename({ sessionID: input.id, title: input.title }), + sdk().client.session.update({ sessionID: input.id, title: input.title }), onSuccess: (_, input) => { sync().set( produce((draft) => { @@ -812,14 +803,13 @@ export function MessageTimeline(props: { const archiveSession = async (sessionID: string) => { const session = sync().session.get(sessionID) if (!session) return - if ((await sdk().protocol) !== "v1") return const sessions = sync().data.session ?? [] const index = sessions.findIndex((s) => s.id === sessionID) const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) await sdk() - .client.session.update({ sessionID, directory: sdk().directory, time: { archived: Date.now() } }) + .client.session.update({ sessionID, time: { archived: Date.now() } }) .then(() => { sync().set( produce((draft) => { @@ -848,8 +838,8 @@ export function MessageTimeline(props: { const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) const result = await sdk() - .api.session.remove({ sessionID }) - .then(() => true) + .client.session.delete({ sessionID }) + .then((x) => x.data) .catch((err) => { showToast({ title: language.t("session.delete.failed.title"), diff --git a/packages/app/src/pages/session/timeline/projection.ts b/packages/app/src/pages/session/timeline/projection.ts index e30c936d73..ea8ea4f132 100644 --- a/packages/app/src/pages/session/timeline/projection.ts +++ b/packages/app/src/pages/session/timeline/projection.ts @@ -1,15 +1,16 @@ -import type { SessionMessageInfo } from "@opencode-ai/client/promise" +import { Binary } from "@opencode-ai/core/util/binary" import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2" -import { createMemo, type Accessor } from "solid-js" +import { createMemo, mapArray, type Accessor } from "solid-js" import { reuseTimelineRows } from "./row-reconciliation" import { Timeline, TimelineRow } from "./rows" export { reuseTimelineRows } from "./row-reconciliation" +const emptyAssistantMessages: AssistantMessage[] = [] + export function createTimelineProjection(input: { messages: Accessor userMessages: Accessor - sessionMessages: Accessor parts: (messageID: string) => Part[] status: Accessor showReasoningSummaries: Accessor @@ -29,20 +30,47 @@ export function createTimelineProjection(input: { }) return result }) - const projection = createMemo(() => - Timeline.constructSessionMessageRows( - input.sessionMessages(), - (messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined, - input.parts, - input.showReasoningSummaries(), - input.status().type, - input.inlineComments(), - input.userMessages(), + const activeMessageID = createMemo(() => { + const parentID = input + .messages() + .findLast( + (message): message is AssistantMessage => + message.role === "assistant" && typeof message.time.completed !== "number", + )?.parentID + if (parentID) { + const messages = input.messages() + const result = Binary.search(messages, parentID, (message) => message.id) + const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID) + if (message?.role === "user") return message.id + } + + if (input.status().type === "idle") return + return input.messages().findLast((message) => message.role === "user")?.id + }) + const messageRowMemos = createMemo( + mapArray(input.userMessages, (userMessage, indexAccessor) => + createMemo((previous: TimelineRow.TimelineRow[] | undefined) => + reuseTimelineRows( + previous, + Timeline.constructMessageRows( + userMessage, + input.parts, + assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages, + indexAccessor(), + input.showReasoningSummaries(), + input.status().type, + activeMessageID() === userMessage.id, + input.inlineComments(), + ), + ), + ), ), ) - const activeMessageID = createMemo(() => projection().activeMessageID) const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => - reuseTimelineRows(previous, projection().rows), + reuseTimelineRows( + previous, + messageRowMemos().flatMap((memo) => memo()), + ), ) const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const))) const messageRowIndex = createMemo(() => { diff --git a/packages/app/src/pages/session/timeline/rows-current.test.ts b/packages/app/src/pages/session/timeline/rows-current.test.ts deleted file mode 100644 index a5952a5e66..0000000000 --- a/packages/app/src/pages/session/timeline/rows-current.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { describe, expect, mock, test } from "bun:test" -import type { SessionMessageInfo } from "@opencode-ai/client/promise" -import { normalizeSessionMessages } from "@/utils/session-message" - -mock.module("@opencode-ai/session-ui/message-part", () => ({ - renderable: () => true, - groupParts: (refs: Array<{ messageID: string; part: { id: string } }>) => - refs.map((ref) => ({ - type: "part" as const, - key: ref.part.id, - ref: { messageID: ref.messageID, partID: ref.part.id }, - })), -})) - -const { Timeline, TimelineRow } = await import("./rows") - -describe("current session timeline rows", () => { - test("derives turns and tagged rows from chronological current messages", () => { - const source = [ - { id: "msg_1", type: "user", text: "first", time: { created: 1 } }, - { - id: "msg_2", - type: "assistant", - agent: "build", - model: { id: "model", providerID: "provider" }, - content: [{ type: "text", text: "answer" }], - time: { created: 2, completed: 3 }, - }, - { id: "msg_3", type: "user", text: "second", time: { created: 4 } }, - { - id: "msg_4", - type: "assistant", - agent: "build", - model: { id: "model", providerID: "provider" }, - content: [{ type: "reasoning", text: "working" }], - time: { created: 5 }, - }, - ] satisfies SessionMessageInfo[] - const normalized = normalizeSessionMessages("ses_1", source) - const messages = new Map(normalized.messages.map((message) => [message.id, message])) - - const result = Timeline.constructSessionMessageRows( - source, - (messageID) => messages.get(messageID), - (messageID) => normalized.parts.get(messageID) ?? [], - true, - "busy", - true, - normalized.messages.filter((message) => message.role === "user"), - ) - - expect(result.activeMessageID).toBe("msg_3") - expect(result.rows.map(TimelineRow.key)).toEqual([ - "user-message:msg_1", - "assistant-part:msg_1:msg_2:text:0", - "turn-gap:msg_3", - "user-message:msg_3", - "assistant-part:msg_3:msg_4:reasoning:0", - ]) - }) - - test("renders a current shell message as a standalone turn", () => { - const source = [ - { - id: "msg_shell", - type: "shell", - shellID: "shell_1", - command: "pwd", - status: "exited", - exit: 0, - output: { output: "/repo", cursor: 5, size: 5, truncated: false }, - time: { created: 1, completed: 2 }, - }, - ] satisfies SessionMessageInfo[] - const normalized = normalizeSessionMessages("ses_1", source) - const messages = new Map(normalized.messages.map((message) => [message.id, message])) - - const result = Timeline.constructSessionMessageRows( - source, - (messageID) => messages.get(messageID), - (messageID) => normalized.parts.get(messageID) ?? [], - true, - "idle", - true, - normalized.messages.filter((message) => message.role === "user"), - ) - - expect(result.activeMessageID).toBe("msg_shell") - expect(result.rows.map(TimelineRow.key)).toEqual([ - "user-message:msg_shell", - "assistant-part:msg_shell:msg_shell:tool", - ]) - }) - - test("keeps a projected parent missing from the source page before newer turns", () => { - const source = [ - { id: "msg_user_1", type: "user", text: "first question", time: { created: 1 } }, - { - id: "msg_assistant_1", - type: "assistant", - agent: "build", - model: { id: "model", providerID: "provider" }, - content: [{ type: "text", text: "first answer" }], - time: { created: 2, completed: 3 }, - }, - { id: "msg_user_2", type: "user", text: "second question", time: { created: 4 } }, - { - id: "msg_assistant_2", - type: "assistant", - agent: "build", - model: { id: "model", providerID: "provider" }, - content: [{ type: "text", text: "second answer" }], - time: { created: 5, completed: 6 }, - }, - ] satisfies SessionMessageInfo[] - const normalized = normalizeSessionMessages("ses_1", source) - const messages = new Map(normalized.messages.map((message) => [message.id, message])) - - const result = Timeline.constructSessionMessageRows( - source.slice(1), - (messageID) => messages.get(messageID), - (messageID) => normalized.parts.get(messageID) ?? [], - true, - "idle", - true, - normalized.messages.filter((message) => message.role === "user"), - ) - - expect(result.rows.map(TimelineRow.key)).toEqual([ - "user-message:msg_user_1", - "assistant-part:msg_user_1:msg_assistant_1:text:0", - "turn-gap:msg_user_2", - "user-message:msg_user_2", - "assistant-part:msg_user_2:msg_assistant_2:text:0", - ]) - }) - - test("renders an optimistic user turn and thinking before the protocol message arrives", () => { - const source = [ - { id: "msg_1", type: "user", text: "existing", time: { created: 1 } }, - ] satisfies SessionMessageInfo[] - const normalized = normalizeSessionMessages("ses_1", source) - const optimistic = { - id: "msg_2", - sessionID: "ses_1", - role: "user" as const, - time: { created: 2 }, - agent: "build", - model: { modelID: "model", providerID: "provider" }, - } - const result = Timeline.constructSessionMessageRows( - source, - (messageID) => - messageID === optimistic.id ? optimistic : normalized.messages.find((message) => message.id === messageID), - () => [], - true, - "busy", - true, - [...normalized.messages.filter((message) => message.role === "user"), optimistic], - ) - - expect(result.activeMessageID).toBe(optimistic.id) - expect(result.rows.map(TimelineRow.key)).toEqual([ - "user-message:msg_1", - "turn-gap:msg_2", - "user-message:msg_2", - "thinking:msg_2", - ]) - }) - - test("removes a failed assistant error when the turn continues streaming", () => { - const source = [ - { id: "msg_user", type: "user", text: "recover", time: { created: 1 } }, - { - id: "msg_failed", - type: "assistant", - agent: "build", - model: { id: "model", providerID: "provider" }, - content: [], - error: { type: "ProviderError", message: "temporary failure" }, - time: { created: 2, completed: 3 }, - }, - { - id: "msg_recovery", - type: "assistant", - agent: "build", - model: { id: "model", providerID: "provider" }, - content: [{ type: "text", text: "streaming again" }], - time: { created: 4 }, - }, - ] satisfies SessionMessageInfo[] - const normalized = normalizeSessionMessages("ses_1", source) - const messages = new Map(normalized.messages.map((message) => [message.id, message])) - - const result = Timeline.constructSessionMessageRows( - source, - (messageID) => messages.get(messageID), - (messageID) => normalized.parts.get(messageID) ?? [], - true, - "busy", - true, - normalized.messages.filter((message) => message.role === "user"), - ) - - expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart"]) - }) -}) diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index 879646e86a..72cd9a180c 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -1,9 +1,7 @@ import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" -import type { SessionMessageInfo } from "@opencode-ai/client/promise" -import { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2" +import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2" import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part" import { TimelineRow, type SummaryDiff } from "./timeline-row" -import { uniqueSummaryDiffs } from "./summary-diffs" export { TimelineRow, type SummaryDiff } from "./timeline-row" @@ -32,71 +30,6 @@ export type TimelineRowMap = { } export namespace Timeline { - export function constructSessionMessageRows( - messages: SessionMessageInfo[], - getMessage: (messageID: string) => UserMessage | AssistantMessage | undefined, - getMessageParts: (messageID: string) => Part[], - showReasoning: boolean, - status: SessionStatus["type"], - inlineComments: boolean, - projectedUserMessages: UserMessage[], - ) { - const turns: { user: UserMessage; assistants: AssistantMessage[] }[] = [] - const turnByUserID = new Map() - messages.forEach((message) => { - const projected = getMessage(message.id) - if (message.type === "shell" && projected?.role === "user") { - const assistant = getMessage(`${message.id}:assistant`) - const turn = { user: projected, assistants: assistant?.role === "assistant" ? [assistant] : [] } - turns.push(turn) - turnByUserID.set(projected.id, turn) - return - } - if (projected?.role === "user") { - if (turnByUserID.has(projected.id)) return - const turn = { user: projected, assistants: [] } - turns.push(turn) - turnByUserID.set(projected.id, turn) - return - } - if (projected?.role !== "assistant") return - const existing = turnByUserID.get(projected.parentID) - if (existing) { - existing.assistants.push(projected) - return - } - const user = getMessage(projected.parentID) - if (user?.role !== "user") return - const turn = { user, assistants: [projected] } - turns.push(turn) - turnByUserID.set(user.id, turn) - }) - const latestUserMessageID = turns.at(-1)?.user.id - projectedUserMessages.forEach((user) => { - if (turnByUserID.has(user.id)) return - if (latestUserMessageID && user.id < latestUserMessageID) return - const turn = { user, assistants: [] } - turns.push(turn) - turnByUserID.set(user.id, turn) - }) - const activeMessageID = turns.at(-1)?.user.id - return { - activeMessageID, - rows: turns.flatMap((turn, index) => - constructMessageRows( - turn.user, - getMessageParts, - turn.assistants, - index, - showReasoning, - status, - turn.user.id === activeMessageID, - inlineComments, - ), - ), - } - } - export function constructMessageRows( userMessage: UserMessage, getMessageParts: (messageID: string) => Part[], @@ -116,8 +49,7 @@ export namespace Timeline { const compaction = userParts.some((p) => p.type === "compaction") const interruptedMessageIndex = assistantMessages.findIndex((m) => m.error?.name === "MessageAbortedError") const interrupted = interruptedMessageIndex !== -1 - const latestError = assistantMessages.at(-1)?.error - const error = latestError?.name === "MessageAbortedError" ? undefined : latestError + const error = assistantMessages.find((m) => m.error && m.error.name !== "MessageAbortedError")?.error const assistantPartRefs = assistantMessages.flatMap((message, messageIndex) => getMessageParts(message.id) @@ -205,7 +137,14 @@ export namespace Timeline { if (isActive && status === "retry") rows.push(new TimelineRow.Retry({ userMessageID: userMessage.id })) - const diffs = uniqueSummaryDiffs(userMessage.summary?.diffs) + const diffs = (userMessage.summary?.diffs ?? []) + .reduceRight((result, diff) => { + if (!isSummaryDiff(diff)) return result + if (result.some((item) => item.file === diff.file)) return result + result.push(diff) + return result + }, []) + .reverse() if (diffs.length > 0 && (status === "idle" || !isActive)) { rows.push( new TimelineRow.DiffSummary({ @@ -230,6 +169,10 @@ export namespace Timeline { return rows } + function isSummaryDiff(value: SnapshotFileDiff): value is SummaryDiff { + return typeof value.file === "string" + } + function reasoningHeading(text: string) { const markdown = text.replace(/\r\n?/g, "\n") const html = markdown.match(/]*>([\s\S]*?)<\/h[1-6]>/i) diff --git a/packages/app/src/pages/session/timeline/summary-diffs.test.ts b/packages/app/src/pages/session/timeline/summary-diffs.test.ts deleted file mode 100644 index 9b66bf6771..0000000000 --- a/packages/app/src/pages/session/timeline/summary-diffs.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" -import { uniqueSummaryDiffs } from "./summary-diffs" - -const diff = (file: string, additions: number) => - ({ - file, - additions, - deletions: 0, - }) satisfies SnapshotFileDiff - -describe("uniqueSummaryDiffs", () => { - test("drops entries without files and preserves unique input", () => { - const alpha = diff("alpha.ts", 1) - const beta = diff("beta.ts", 1) - const invalid = { additions: 1, deletions: 0 } satisfies SnapshotFileDiff - - expect(uniqueSummaryDiffs(undefined)).toEqual([]) - expect(uniqueSummaryDiffs([])).toEqual([]) - expect(uniqueSummaryDiffs([invalid])).toEqual([]) - - const result = uniqueSummaryDiffs([alpha, invalid, beta]) - expect(result).toEqual([alpha, beta]) - expect(result[0]).toBe(alpha) - expect(result[1]).toBe(beta) - }) - - test("keeps the last diff per file in the legacy display order", () => { - const oldAlpha = diff("alpha.ts", 1) - const oldBeta = diff("beta.ts", 1) - const newAlpha = diff("alpha.ts", 2) - const charlie = diff("charlie.ts", 1) - const newBeta = diff("beta.ts", 2) - - const result = uniqueSummaryDiffs([oldAlpha, oldBeta, newAlpha, charlie, newBeta]) - - expect(result).toEqual([newAlpha, charlie, newBeta]) - expect(result[0]).toBe(newAlpha) - expect(result[1]).toBe(charlie) - expect(result[2]).toBe(newBeta) - }) -}) diff --git a/packages/app/src/pages/session/timeline/summary-diffs.ts b/packages/app/src/pages/session/timeline/summary-diffs.ts deleted file mode 100644 index 2df37af5a6..0000000000 --- a/packages/app/src/pages/session/timeline/summary-diffs.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" -import type { SummaryDiff } from "./timeline-row" - -export function uniqueSummaryDiffs(diffs: SnapshotFileDiff[] | undefined) { - const files = new Set() - return (diffs ?? []) - .reduceRight((result, diff) => { - if (!isSummaryDiff(diff)) return result - const file = diff.file - if (files.has(file)) return result - files.add(file) - result.push(diff) - return result - }, []) - .reverse() -} - -function isSummaryDiff(diff: SnapshotFileDiff): diff is SummaryDiff { - return typeof diff.file === "string" -} diff --git a/packages/app/src/pages/session/use-composer-commands.tsx b/packages/app/src/pages/session/use-composer-commands.tsx index e7e51489ee..9b75c7bf4c 100644 --- a/packages/app/src/pages/session/use-composer-commands.tsx +++ b/packages/app/src/pages/session/use-composer-commands.tsx @@ -1,6 +1,7 @@ import { useCommand, type CommandOption } from "@/context/command" import { useLanguage } from "@/context/language" import { useLocal, type ModelSelection } from "@/context/local" +import { useSettings } from "@/context/settings" import { useDialog } from "@opencode-ai/ui/context/dialog" import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom" import { useSessionLayout } from "./session-layout" @@ -18,6 +19,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { const dialog = useDialog() const language = useLanguage() const local = useLocal() + const settings = useSettings() const { sessionKey } = useSessionLayout() const sessionOwnership = createSessionOwnership(sessionKey) const model = input.model ?? local.model @@ -68,7 +70,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { description: language.t("command.agent.cycle.description"), keybind: "mod+.", slash: "agent", - disabled: !local.agent.visible(), + disabled: !settings.visibility.customAgents(), onSelect: () => local.agent.move(1), }), agentCommand({ @@ -76,7 +78,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { title: language.t("command.agent.cycle.reverse"), description: language.t("command.agent.cycle.reverse.description"), keybind: "shift+mod+.", - disabled: !local.agent.visible(), + disabled: !settings.visibility.customAgents(), onSelect: () => local.agent.move(-1), }), ]) diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 12dd96a5e6..8ad6cb6072 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -5,6 +5,7 @@ import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-b import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" +import { useLocal } from "@/context/local" import { usePermission } from "@/context/permission" import { usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" @@ -18,7 +19,6 @@ import { extractPromptFromParts } from "@/utils/prompt" import { UserMessage } from "@opencode-ai/sdk/v2" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionOwnership } from "./session-ownership" -import { useLocal } from "@/context/local" export type SessionCommandContext = { navigateMessageByOffset: (offset: number) => void @@ -40,6 +40,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const dialog = useDialog() const file = useFile() const language = useLanguage() + const local = useLocal() const permission = usePermission() const prompt = usePrompt() const sdk = useSDK() @@ -47,7 +48,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sync = useSync() const terminal = useTerminal() const layout = useLayout() - const local = useLocal() const navigate = useNavigate() const { params, sessionKey, tabs, view } = useSessionLayout() const sessionOwnership = createSessionOwnership(sessionKey) @@ -306,7 +306,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sessionID = params.id if (!sessionID) return const owner = sessionOwnership.capture() - const session = sdk().api.session + const client = sdk().client const directory = sdk().directory const promptSession = prompt.capture() const revert = info()?.revert?.messageID @@ -316,13 +316,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const parts = sync().data.part[message.id] if (sync().data.session_working(sessionID)) { - await session.interrupt({ sessionID }).catch(() => {}) + await client.session.abort({ sessionID }).catch(() => {}) } await runCommand({ owner, prompt: promptSession, - request: () => session.revert.stage({ sessionID, messageID: message.id }), + request: () => client.session.revert({ sessionID, messageID: message.id }), updatePrompt: (promptSession) => { if (parts) promptSession.set(extractPromptFromParts(parts, { directory })) }, @@ -334,7 +334,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sessionID = params.id if (!sessionID) return const owner = sessionOwnership.capture() - const session = sdk().api.session + const client = sdk().client const messages = userMessages() const promptSession = prompt.capture() @@ -346,7 +346,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { await runCommand({ owner, prompt: promptSession, - request: () => session.revert.clear({ sessionID }), + request: () => client.session.unrevert({ sessionID }), updatePrompt: (promptSession) => promptSession.reset(), updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)), }) @@ -356,7 +356,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { await runCommand({ owner, prompt: promptSession, - request: () => session.revert.stage({ sessionID, messageID: next.id }), + request: () => client.session.revert({ sessionID, messageID: next.id }), updatePrompt: () => undefined, updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)), }) @@ -375,9 +375,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => { return } - await sdk().api.session.compact({ + await sdk().client.session.summarize({ sessionID, - model: { providerID: model.provider.id, modelID: model.id }, + modelID: model.id, + providerID: model.provider.id, }) } @@ -467,7 +468,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { id: "file.open", title: language.t("command.file.open"), description: language.t("palette.search.placeholder"), - keybind: "mod+p", + keybind: "mod+k,mod+p", slash: "open", onSelect: openFile, }), diff --git a/packages/app/src/pages/session/v2/review-diff-kinds.ts b/packages/app/src/pages/session/v2/review-diff-kinds.ts index d3adb1f2ff..49cec334bc 100644 --- a/packages/app/src/pages/session/v2/review-diff-kinds.ts +++ b/packages/app/src/pages/session/v2/review-diff-kinds.ts @@ -1,15 +1,14 @@ import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" -import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { Kind } from "@/components/file-tree-v2" import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" -export type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff +export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff export function normalizePath(p: string) { return normalizeFileTreeV2Path(p) } -export function filterRenderableDiff(value: FileDiffInfo | SnapshotFileDiff | VcsFileDiff): value is RenderDiff { +export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { return typeof value.file === "string" } diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx index fcd6bbb79f..4f0cf612e1 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -1,6 +1,5 @@ import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" -import type { FileDiffInfo } from "@opencode-ai/client/promise" import { SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX, SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, @@ -31,7 +30,7 @@ import { import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2" -type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff +type ReviewDiff = SnapshotFileDiff | VcsFileDiff export type ReviewPanelV2Props = { title?: JSX.Element diff --git a/packages/app/src/utils/diffs.test.ts b/packages/app/src/utils/diffs.test.ts index a3d25f4279..5fbca469b7 100644 --- a/packages/app/src/utils/diffs.test.ts +++ b/packages/app/src/utils/diffs.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from "bun:test" import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" -import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { Message } from "@opencode-ai/sdk/v2/client" import { diffs, message } from "./diffs" @@ -10,7 +9,7 @@ const item = { additions: 1, deletions: 1, status: "modified", -} satisfies FileDiffInfo & SnapshotFileDiff +} satisfies SnapshotFileDiff describe("diffs", () => { test("keeps valid arrays", () => { diff --git a/packages/app/src/utils/diffs.ts b/packages/app/src/utils/diffs.ts index a8eec75a9a..0cb2504fbe 100644 --- a/packages/app/src/utils/diffs.ts +++ b/packages/app/src/utils/diffs.ts @@ -1,8 +1,7 @@ import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" -import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { Message } from "@opencode-ai/sdk/v2/client" -type Diff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff +type Diff = SnapshotFileDiff | VcsFileDiff function diff(value: unknown): value is Diff { if (!value || typeof value !== "object" || Array.isArray(value)) return false diff --git a/packages/app/src/utils/draft-store.ts b/packages/app/src/utils/draft-store.ts deleted file mode 100644 index cd0895f52c..0000000000 --- a/packages/app/src/utils/draft-store.ts +++ /dev/null @@ -1,171 +0,0 @@ -import type { AsyncStorage } from "@solid-primitives/storage" - -export type BlobReference = { id: string; url: string } - -type Driver = { - get(key: string): Promise - set(key: string, value: string): Promise - remove(key: string): Promise - putBlob(blob: Blob): Promise - getBlob(id: string): Promise -} - -export type DraftStore = AsyncStorage & { putBlob(blob: Blob): Promise } -const urls = new Map() - -function blobUrl(id: string, blob: Blob) { - const existing = urls.get(id) - if (existing) return existing - const url = URL.createObjectURL(blob) - urls.set(id, url) - return url -} - -async function blobID(blob: Blob) { - const id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer()))) - .map((byte) => byte.toString(16).padStart(2, "0")) - .join("") - return id -} - -export async function createBlobReference(blob: Blob): Promise { - const id = await blobID(blob) - return { id, url: blobUrl(id, blob) } -} - -export function createDraftStore(driver: Driver): DraftStore { - const versions = new Map() - const putBlob = async (blob: Blob) => { - const id = await driver.putBlob(blob) - return { id, url: blobUrl(id, blob) } - } - const encode = async (value: unknown): Promise => { - if (Array.isArray(value)) return Promise.all(value.map(encode)) - if (!value || typeof value !== "object") return value - const item = value as Record - if (item.type === "image" && typeof item.dataUrl === "string") { - const blob = await fetch(item.dataUrl).then((response) => response.blob()) - const { dataUrl: _, ...rest } = item - return { ...rest, blob: { id: await driver.putBlob(blob) } } - } - if ("blob" in item && item.blob && typeof item.blob === "object") { - const blob = item.blob as Record - if (typeof blob.id === "string" && blob.id.startsWith("data:")) { - const data = await fetch(blob.id).then((response) => response.blob()) - return { ...item, blob: { id: await driver.putBlob(data) } } - } - return { ...item, blob: { id: blob.id } } - } - return Object.fromEntries( - await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await encode(entry)])), - ) - } - const decode = async (value: unknown): Promise => { - if (Array.isArray(value)) return Promise.all(value.map(decode)) - if (!value || typeof value !== "object") return value - const item = value as Record - if (item.blob && typeof item.blob === "object") { - const ref = item.blob as Record - if (typeof ref.id === "string") { - const blob = await driver.getBlob(ref.id) - if (blob) return { ...item, blob: { id: ref.id, url: blobUrl(ref.id, blob) } } - } - } - return Object.fromEntries( - await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await decode(entry)])), - ) - } - return { - getItem: async (key) => { - const value = await driver.get(key) - return value === null ? null : JSON.stringify(await decode(JSON.parse(value))) - }, - setItem: async (key, value) => { - const version = (versions.get(key) ?? 0) + 1 - versions.set(key, version) - const encoded = JSON.stringify(await encode(JSON.parse(value))) - if (versions.get(key) === version) await driver.set(key, encoded) - }, - removeItem: async (key) => { - versions.set(key, (versions.get(key) ?? 0) + 1) - await driver.remove(key) - }, - putBlob, - } -} - -export function createBrowserDraftStore(): DraftStore { - const request = indexedDB.open("opencode-drafts", 1) - request.addEventListener("upgradeneeded", () => { - request.result.createObjectStore("documents") - request.result.createObjectStore("blobs") - }) - const db = new Promise((resolve, reject) => { - request.addEventListener("success", () => { - const database = request.result - const transaction = database.transaction(["documents", "blobs"], "readwrite") - const documents = transaction.objectStore("documents").getAll() - documents.addEventListener("success", () => { - const used = new Set() - JSON.parse(`[${documents.result.join(",")}]`, (_key, item) => { - if (item?.blob && typeof item.blob.id === "string") used.add(item.blob.id) - return item - }) - const blobs = transaction.objectStore("blobs").openKeyCursor() - blobs.addEventListener("success", () => { - const cursor = blobs.result - if (!cursor) return - if (!used.has(String(cursor.key))) cursor.delete() - cursor.continue() - }) - }) - transaction.addEventListener("complete", () => resolve(database)) - transaction.addEventListener("abort", () => resolve(database)) - }) - request.addEventListener("error", () => reject(request.error)) - }) - const get = async (store: string, key: string) => { - const result = (await db).transaction(store).objectStore(store).get(key) - return new Promise((resolve, reject) => { - result.addEventListener("success", () => resolve(result.result)) - result.addEventListener("error", () => reject(result.error)) - }) - } - const write = async (store: string, key: string, value?: unknown) => { - const transaction = (await db).transaction(store, "readwrite") - if (value === undefined) transaction.objectStore(store).delete(key) - else transaction.objectStore(store).put(value, key) - return new Promise((resolve, reject) => { - transaction.addEventListener("complete", () => resolve()) - transaction.addEventListener("error", () => reject(transaction.error)) - }) - } - return createDraftStore({ - get: async (key) => ((await get("documents", key)) as string | undefined) ?? null, - set: (key, value) => write("documents", key, value), - remove: (key) => write("documents", key), - putBlob: async (blob) => { - const id = await blobID(blob) - await write("blobs", id, blob) - return id - }, - getBlob: async (id) => ((await get("blobs", id)) as Blob | undefined) ?? null, - }) -} - -export async function blobDataUrl(blob: BlobReference, mime: string) { - const data = await fetch(blob.url).then((response) => response.blob()) - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.addEventListener("error", () => reject(reader.error)) - reader.addEventListener("load", () => { - const value = typeof reader.result === "string" ? reader.result : "" - resolve(`data:${mime};base64,${value.slice(value.indexOf(",") + 1)}`) - }) - reader.readAsDataURL(data) - }) -} - -export function createLegacyBlobReference(dataUrl: string): BlobReference { - return { id: dataUrl, url: dataUrl } -} diff --git a/packages/app/src/utils/menu-dismiss-controller.ts b/packages/app/src/utils/menu-dismiss-controller.ts deleted file mode 100644 index 0a3009eb71..0000000000 --- a/packages/app/src/utils/menu-dismiss-controller.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** Coordinates focus restoration and actions that must run after menu content unmounts. */ -export function createMenuDismissController(content: () => HTMLElement | undefined) { - let restoreTrigger = true - - return { - /** Allows the menu primitive to restore focus to its trigger when closing. */ - allowTriggerRestore() { - restoreTrigger = true - }, - /** Keeps focus at its current or next destination instead of returning it to the trigger. */ - preventTriggerRestore() { - restoreTrigger = false - }, - /** Applies the current restoration policy during the menu primitive's close-focus event. */ - onCloseAutoFocus(event: Event) { - if (!restoreTrigger) event.preventDefault() - }, - /** Runs an action after the menu unmounts and its focus-close work has settled. */ - afterClose(callback: () => void) { - const complete = () => { - if (content()?.isConnected) { - requestAnimationFrame(complete) - return - } - requestAnimationFrame(() => requestAnimationFrame(callback)) - } - requestAnimationFrame(complete) - }, - } -} diff --git a/packages/app/src/utils/notification-click.test.ts b/packages/app/src/utils/notification-click.test.ts new file mode 100644 index 0000000000..fa81b0e025 --- /dev/null +++ b/packages/app/src/utils/notification-click.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { handleNotificationClick, setNavigate } from "./notification-click" + +describe("notification click", () => { + afterEach(() => { + setNavigate(undefined as any) + }) + + test("navigates via registered navigate function", () => { + const calls: string[] = [] + setNavigate((href) => calls.push(href)) + handleNotificationClick("/abc/session/123") + expect(calls).toEqual(["/abc/session/123"]) + }) + + test("does not navigate when href is missing", () => { + const calls: string[] = [] + setNavigate((href) => calls.push(href)) + handleNotificationClick(undefined) + expect(calls).toEqual([]) + }) + + test("falls back to location.assign without registered navigate", () => { + handleNotificationClick("/abc/session/123") + // falls back to window.location.assign — no error thrown + }) +}) diff --git a/packages/app/src/utils/notification-click.ts b/packages/app/src/utils/notification-click.ts new file mode 100644 index 0000000000..316b278206 --- /dev/null +++ b/packages/app/src/utils/notification-click.ts @@ -0,0 +1,13 @@ +let nav: ((href: string) => void) | undefined + +export const setNavigate = (fn: (href: string) => void) => { + nav = fn +} + +export const handleNotificationClick = (href?: string) => { + window.focus() + if (!href) return + if (nav) return nav(href) + console.warn("notification-click: navigate function not set, falling back to window.location.assign") + window.location.assign(href) +} diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index a7742c399e..a2daae4866 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -15,7 +15,6 @@ type PersistedWithReady = [ ] type PersistTarget = { - draft?: boolean storage?: string scope?: "window" legacyStorageNames?: string[] @@ -296,14 +295,6 @@ async function removeAsync(storage: AsyncStorage, key: string) { } catch {} } -function toAsyncStorage(storage: SyncStorage | AsyncStorage): AsyncStorage { - return { - getItem: async (key) => storage.getItem(key), - setItem: async (key, value) => storage.setItem(key, value), - removeItem: async (key) => storage.removeItem(key), - } -} - async function migrateLegacyAsync(input: { current: AsyncStorage legacyStore?: AsyncStorage @@ -522,9 +513,6 @@ export const Persist = { if (session) return Persist.serverSession(scope, dir, session, key, legacy) return Persist.serverWorkspace(scope, dir, key, legacy) }, - prompt(target: PersistTarget): PersistTarget { - return { ...target, draft: true } - }, } function resolveTarget(target: PersistTarget, platform: Platform): PersistTarget { @@ -538,12 +526,9 @@ function resolveTarget(target: PersistTarget, platform: Platform): PersistTarget } export function removePersisted( - target: { draft?: boolean; storage?: string; legacyStorageNames?: string[]; key: string }, + target: { storage?: string; legacyStorageNames?: string[]; key: string }, platform?: Platform, ) { - if (target.draft && platform?.draftStore) { - void platform.draftStore.removeItem(`${target.storage ?? "default"}:${target.key}`) - } const isDesktop = platform?.platform === "desktop" && !!platform.storage if (isDesktop) { @@ -576,17 +561,8 @@ export function persisted( const legacy = config.legacy ?? [] const isDesktop = platform.platform === "desktop" && !!platform.storage - const draft = config.draft ? platform.draftStore : undefined const currentStorage = (() => { - if (draft) { - const prefix = `${config.storage ?? "default"}:` - return { - getItem: (key: string) => draft.getItem(prefix + key), - setItem: (key: string, value: string) => draft.setItem(prefix + key, value), - removeItem: (key: string) => draft.removeItem(prefix + key), - } satisfies AsyncStorage - } if (isDesktop) return platform.storage?.(config.storage) if (!config.storage) return localStorageDirect() return localStorageWithPrefix(config.storage) @@ -601,7 +577,7 @@ export function persisted( const legacyStorageNames = config.legacyStorageNames ?? [] const storage = (() => { - if (!isDesktop && !draft) { + if (!isDesktop) { const current = currentStorage as SyncStorage const legacyStore = legacyStorage as SyncStorage const legacyStores = legacyStorageNames.map(localStorageWithPrefix) @@ -633,26 +609,15 @@ export function persisted( const current = currentStorage as AsyncStorage const legacyStore = legacyStorage as AsyncStorage | undefined - const oldCurrent = draft - ? isDesktop - ? platform.storage?.(config.storage) - : config.storage - ? localStorageWithPrefix(config.storage) - : localStorageDirect() - : undefined - const legacyStores = [ - oldCurrent, - ...legacyStorageNames.map((name) => (isDesktop ? platform.storage?.(name) : localStorageWithPrefix(name))), - ] + const legacyStores = legacyStorageNames + .map((name) => platform.storage?.(name) as AsyncStorage | undefined) .filter((x) => !!x) - .map(toAsyncStorage) - let draftLatest: string | undefined const api: AsyncStorage = { getItem: async (key) => { const value = await readCurrentAsync({ storage: current, key, defaults, migrate: config.migrate }) if (value !== undefined) return value - const migrated = await migrateLegacyAsync({ + return migrateLegacyAsync({ current, legacyStore, stores: legacyStores, @@ -661,15 +626,8 @@ export function persisted( defaults, migrate: config.migrate, }) - if (draftLatest === undefined) { - if (draft && migrated !== null) return (await current.getItem(key)) ?? migrated - return migrated - } - await current.setItem(key, draftLatest) - return draftLatest }, setItem: async (key, value) => { - if (draft) draftLatest = value await current.setItem(key, value) }, removeItem: async (key) => { diff --git a/packages/app/src/utils/prompt.test.ts b/packages/app/src/utils/prompt.test.ts index 8b86d43b90..1ecaf02c97 100644 --- a/packages/app/src/utils/prompt.test.ts +++ b/packages/app/src/utils/prompt.test.ts @@ -37,18 +37,8 @@ describe("extractPromptFromParts", () => { expect(result).toHaveLength(3) expect(result[0]).toMatchObject({ type: "text", content: "check these" }) expect(result.slice(1)).toMatchObject([ - { - type: "image", - filename: "a.png", - mime: "image/png", - blob: expect.objectContaining({ id: expect.any(String) }), - }, - { - type: "image", - filename: "b.pdf", - mime: "application/pdf", - blob: expect.objectContaining({ id: expect.any(String) }), - }, + { type: "image", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" }, + { type: "image", filename: "b.pdf", mime: "application/pdf", dataUrl: "data:application/pdf;base64,BBB" }, ]) }) }) diff --git a/packages/app/src/utils/prompt.ts b/packages/app/src/utils/prompt.ts index 67d32086bb..35aec0071a 100644 --- a/packages/app/src/utils/prompt.ts +++ b/packages/app/src/utils/prompt.ts @@ -1,6 +1,5 @@ import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@opencode-ai/sdk/v2" import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" -import { createLegacyBlobReference } from "@/utils/draft-store" type Inline = | { @@ -108,7 +107,7 @@ export function extractPromptFromParts(parts: Part[], opts?: { directory?: strin id: filePart.id, filename: filePart.filename ?? attachmentName, mime: filePart.mime, - blob: createLegacyBlobReference(filePart.url), + dataUrl: filePart.url, }) } } diff --git a/packages/app/src/utils/server-compat.test.ts b/packages/app/src/utils/server-compat.test.ts deleted file mode 100644 index 52e5ec6e3b..0000000000 --- a/packages/app/src/utils/server-compat.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { createApiForServer, createSdkForServer } from "./server" -import { createCompatibleApi } from "./server-compat" - -function setup( - protocol: "v1" | "v2" | Promise<"v1" | "v2">, - responses?: { vcs?: { branch: string; default_branch: string } }, -) { - const requests: Request[] = [] - const fetcher = Object.assign( - async (input: string | URL | Request, init?: RequestInit) => { - const request = new Request(input, init) - requests.push(request) - if (request.method === "PATCH") { - return Response.json({ - id: "ses_1", - slug: "ses_1", - projectID: "project", - directory: "/repo", - title: "Session", - version: "1", - time: { created: 1, updated: 1 }, - }) - } - if (request.method === "POST" && request.url.endsWith("/prompt_async")) - return new Response(undefined, { status: 204 }) - if (request.method === "POST" && request.url.endsWith("/prompt")) { - return Response.json({ - admittedSeq: 1, - id: "msg_1", - sessionID: "ses_1", - timeCreated: 1, - type: "user", - data: { text: "hello" }, - delivery: "steer", - }) - } - if (request.method === "GET" && new URL(request.url).pathname === "/vcs") - return Response.json(responses?.vcs ?? {}) - if (request.method === "GET") return Response.json([]) - return new Response(undefined, { status: 204 }) - }, - { preconnect: globalThis.fetch.preconnect }, - ) - const server = { url: "http://localhost:4096" } - const api = createCompatibleApi({ - protocol: typeof protocol === "string" ? Promise.resolve(protocol) : protocol, - current: createApiForServer({ server, fetch: fetcher }), - legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }), - directory: "/repo", - }) - return { api, requests } -} - -describe("createCompatibleApi", () => { - /* - test("routes V1 archive through the legacy session update", async () => { - const { api, requests } = setup("v1") - await api.session.archive({ sessionID: "ses_1", directory: "/repo" }) - - const url = new URL(requests[0]!.url) - expect(url.pathname).toBe("/session/ses_1") - expect(requests[0]!.headers.get("x-opencode-directory")).toBe("%2Frepo") - expect(requests[0]!.method).toBe("PATCH") - expect(await requests[0]!.json()).toMatchObject({ time: { archived: expect.any(Number) } }) - }) - */ - - test("converts current prompts to the V1 prompt contract", async () => { - const { api, requests } = setup("v1") - await api.session.prompt({ - sessionID: "ses_1", - id: "msg_1", - text: "hello @src/index.ts", - agent: "build", - model: { providerID: "provider", modelID: "model" }, - files: [ - { uri: "file:///repo/src/index.ts", name: "index.ts", mention: { text: "@src/index.ts", start: 6, end: 19 } }, - { uri: "data:text/plain;base64,aGVsbG8=", name: "notes.txt" }, - ], - }) - - expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/prompt_async") - const body = await requests[0]!.json() - expect(body).toMatchObject({ - messageID: "msg_1", - agent: "build", - model: { providerID: "provider", modelID: "model" }, - parts: [ - { type: "text", text: "hello @src/index.ts" }, - { - type: "file", - mime: "text/plain", - url: "file:///repo/src/index.ts", - filename: "index.ts", - source: { - type: "file", - text: { value: "@src/index.ts", start: 6, end: 19 }, - path: "file:///repo/src/index.ts", - }, - }, - { - type: "file", - mime: "text/plain", - url: "data:text/plain;base64,aGVsbG8=", - filename: "notes.txt", - }, - ], - }) - expect(body.parts[2]).not.toHaveProperty("source") - }) - - test("preserves original parts for V1 optimistic reconciliation", async () => { - const { api, requests } = setup("v1") - await api.session.prompt({ - sessionID: "ses_1", - id: "msg_1", - text: "look", - files: [{ uri: "data:image/png;base64,AAAA", name: "image.png" }], - legacyParts: [ - { id: "prt_text", type: "text", text: "look" }, - { id: "prt_image", type: "file", mime: "image/png", url: "data:image/png;base64,AAAA", filename: "image.png" }, - ], - }) - - expect((await requests[0]!.json()).parts).toEqual([ - { id: "prt_text", type: "text", text: "look" }, - { id: "prt_image", type: "file", mime: "image/png", url: "data:image/png;base64,AAAA", filename: "image.png" }, - ]) - }) - - test("resolves protocol detection once across implementation methods", async () => { - let detections = 0 - const resolved = Promise.resolve<"v1" | "v2">("v2") - const protocol = new Proxy(resolved, { - get(target, property) { - if (property !== "then") return Reflect.get(target, property, target) - detections++ - return target.then.bind(target) - }, - }) - const { api } = setup(protocol) - - await api.session.list() - await api.session.list() - - expect(detections).toBe(1) - }) - - /* - test("keeps V2 session actions on the current API", async () => { - const { api, requests } = setup("v2") - await api.session.archive({ sessionID: "ses_1" }) - - expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive") - expect(requests[0]!.method).toBe("POST") - }) - */ - - test("uses the global V1 session search endpoint", async () => { - const { api, requests } = setup("v1") - await api.session.list({ parentID: null, search: "session", limit: 50 }) - - expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session") - }) - - /* - test("projects the V1 default branch", async () => { - const { api } = setup("v1", { vcs: { branch: "feature", default_branch: "dev" } }) - - expect(await api.vcs.get({ location: { directory: "/repo" } })).toMatchObject({ - data: { branch: "feature", defaultBranch: "dev" }, - }) - }) - */ - - test("translates current file searches to the V1 dirs parameter", async () => { - const { api, requests } = setup("v1") - await api.file.find({ location: { directory: "/repo" }, query: "src", type: "file", limit: 20 }) - - const url = new URL(requests[0]!.url) - expect(url.pathname).toBe("/find/file") - expect(url.searchParams.get("dirs")).toBe("false") - expect(url.searchParams.get("limit")).toBe("20") - }) - - test("routes V1 permission replies through the requested directory", async () => { - const { api, requests } = setup("v1") - await api.permission.reply({ - sessionID: "ses_1", - requestID: "permission_1", - reply: "once", - location: { directory: "/other" }, - }) - - expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/permissions/permission_1") - expect(new URL(requests[0]!.url).searchParams.get("directory")).toBe("/other") - }) - - test("disposes the V1 instance after connecting a provider", async () => { - const { api, requests } = setup("v1") - - await api.integration.connect.key({ - integrationID: "openrouter", - key: "secret", - location: { directory: "/repo" }, - }) - - expect(requests.map((request) => new URL(request.url).pathname)).toEqual([ - "/auth/openrouter", - "/instance/dispose", - "/instance/dispose", - ]) - expect(requests[1]!.headers.get("x-opencode-directory")).toBe("%2Frepo") - expect(requests[2]!.headers.get("x-opencode-directory")).toBeNull() - }) - - test("disposes the V1 instance after completing provider OAuth", async () => { - const { api, requests } = setup("v1") - - await api.integration.oauth.complete({ - integrationID: "openrouter", - attemptID: "openrouter:0", - code: "code", - location: { directory: "/repo" }, - }) - - expect(requests.map((request) => new URL(request.url).pathname)).toEqual([ - "/provider/openrouter/oauth/callback", - "/instance/dispose", - "/instance/dispose", - ]) - expect(requests[1]!.headers.get("x-opencode-directory")).toBe("%2Frepo") - expect(requests[2]!.headers.get("x-opencode-directory")).toBeNull() - }) -}) diff --git a/packages/app/src/utils/server-compat.ts b/packages/app/src/utils/server-compat.ts deleted file mode 100644 index 1df1338b71..0000000000 --- a/packages/app/src/utils/server-compat.ts +++ /dev/null @@ -1,518 +0,0 @@ -import type { ServerApi } from "./server" -import type { ServerProtocol } from "./server-protocol" -import type { AgentPartInput, FilePartInput, OpencodeClient, Session, TextPartInput } from "@opencode-ai/sdk/v2/client" -import type { - Project, - ProjectCurrent, - SessionApi, - SessionCommandInput, - SessionCommandOutput, - SessionCompactInput, - SessionCompactOutput, - SessionInfo, - SessionPromptInput, - SessionPromptOutput, - SessionShellInput, - SessionShellOutput, -} from "@opencode-ai/client/promise" - -type LegacyClient = OpencodeClient -type LegacyFor = (directory?: string) => LegacyClient -type CompatibleSessionApi = Omit< - SessionApi, - "prompt" | "command" | "shell" | "compact" | "rename" | "archive" | "remove" -> & { - prompt: (input: SessionPromptInput & LegacyPrompt) => Promise - command: (input: SessionCommandInput) => Promise - shell: (input: SessionShellInput & LegacyPrompt) => Promise - compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise - rename: (input: Parameters[0] & LegacyLocation) => ReturnType - // archive: (input: Parameters[0] & LegacyLocation) => ReturnType - remove: (input: Parameters[0] & LegacyLocation) => ReturnType -} -type CompatiblePermissionApi = Omit & { - reply: ( - input: Parameters[0] & { location?: { directory?: string } }, - ) => ReturnType -} -export type CompatibleApi = Omit & { - readonly session: CompatibleSessionApi - readonly permission: CompatiblePermissionApi -} -type LegacyPrompt = { - agent?: string - model?: { providerID: string; modelID: string } - variant?: string - legacyParts?: (TextPartInput | FilePartInput | AgentPartInput)[] -} -type LegacyLocation = { directory?: string } -type CompatibleInput = { - protocol: Promise - current: ServerApi - legacy: LegacyFor - directory?: string -} - -function mime(uri: string) { - const match = /^data:([^;,]+)/.exec(uri) - return match?.[1] ?? "application/octet-stream" -} - -function sessionInfo(session: Session): SessionInfo { - return { - id: session.id, - parentID: session.parentID, - projectID: session.projectID, - agent: session.agent, - model: session.model && { - id: session.model.id, - providerID: session.model.providerID, - variant: session.model.variant, - }, - cost: session.cost ?? 0, - tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: session.time, - title: session.title, - location: { directory: session.directory, workspaceID: session.workspaceID }, - subpath: session.path, - revert: session.revert && { - messageID: session.revert.messageID, - partID: session.revert.partID, - snapshot: session.revert.snapshot, - }, - } -} - -export function createCompatibleApi(input: CompatibleInput): CompatibleApi { - const v1 = createV1Api(input) - return lazyApi( - input.protocol.then((protocol) => (protocol === "v1" ? v1 : input.current)), - input.current, - ) -} - -function lazyApi(implementation: Promise, shape: T): T { - const cache = new Map() - return new Proxy(shape, { - get(target, property, receiver) { - const sample = Reflect.get(target, property, receiver) - if (typeof sample === "function") { - return (...args: unknown[]) => - implementation.then((value) => { - const method = Reflect.get(value, property) - if (typeof method !== "function") throw new Error(`API method unavailable: ${String(property)}`) - return Reflect.apply(method, value, args) - }) - } - if (sample === null || typeof sample !== "object") return sample - if (cache.has(property)) return cache.get(property) - const nested = lazyApi( - implementation.then((value) => { - const result = Reflect.get(value, property) - if (result === null || typeof result !== "object") { - throw new Error(`API namespace unavailable: ${String(property)}`) - } - return result - }), - sample, - ) - cache.set(property, nested) - return nested - }, - }) -} - -function createV1Api(input: CompatibleInput): CompatibleApi { - const directory = (location?: { directory?: string }) => location?.directory ?? input.directory - const legacy = (location?: { directory?: string }) => input.legacy(directory(location)) - const located = (data: T, value?: { directory?: string }) => ({ - location: { - directory: directory(value) ?? "", - project: { id: "", directory: directory(value) ?? "" }, - }, - data, - }) - - return { - ...input.current, - session: { - ...input.current.session, - async list( - value?: Parameters[0], - options?: Parameters[1], - ) { - if (!value?.directory && value?.search !== undefined) { - const result = await legacy().experimental.session.list( - { - roots: value.parentID === null ? true : undefined, - search: value.search, - limit: value.limit, - }, - options, - ) - return { data: (result.data ?? []).map(sessionInfo), cursor: {} } - } - const result = await legacy({ directory: value?.directory }).session.list({ - directory: value?.directory, - roots: value?.parentID === null ? true : undefined, - search: value?.search, - limit: value?.limit, - }) - return { data: (result.data ?? []).map(sessionInfo), cursor: {} } - }, - async create(value?: Parameters[0]) { - const result = await legacy(value?.location ?? undefined).session.create({ - directory: directory(value?.location ?? undefined), - }) - if (!result.data) throw new Error("Failed to create session") - return sessionInfo(result.data) - }, - async get(value: Parameters[0]) { - const result = await legacy().session.get(value) - if (!result.data) throw new Error(`Session not found: ${value.sessionID}`) - return sessionInfo(result.data) - }, - async active() { - const result = await legacy().session.status() - return Object.fromEntries( - Object.entries(result.data ?? {}).flatMap(([sessionID, status]) => - status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]], - ), - ) - }, - async rename(value: Parameters[0] & LegacyLocation) { - await legacy(value).session.update({ sessionID: value.sessionID, title: value.title }) - }, - // async archive(value: Parameters[0] & LegacyLocation) { - // await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } }) - // }, - async remove(value: Parameters[0] & LegacyLocation) { - await legacy(value).session.delete(value) - }, - async fork(value: Parameters[0]) { - const result = await legacy().session.fork(value) - if (!result.data) throw new Error("Failed to fork session") - return sessionInfo(result.data) - }, - async interrupt(value: Parameters[0]) { - await legacy().session.abort(value) - }, - async prompt(value: SessionPromptInput & LegacyPrompt) { - await legacy().session.promptAsync({ - sessionID: value.sessionID, - messageID: value.id ?? undefined, - agent: value.agent, - model: value.model, - variant: value.variant, - parts: value.legacyParts ?? [ - { type: "text", text: value.text }, - ...(value.files ?? []).map((file) => ({ - type: "file" as const, - mime: file.mention ? "text/plain" : mime(file.uri), - url: file.uri, - filename: file.name, - source: file.mention - ? { - type: "file" as const, - text: { value: file.mention.text, start: file.mention.start, end: file.mention.end }, - path: file.uri, - } - : undefined, - })), - ...(value.agents ?? []).map((agent) => ({ - type: "agent" as const, - name: agent.name, - source: agent.mention - ? { value: agent.mention.text, start: agent.mention.start, end: agent.mention.end } - : undefined, - })), - ], - }) - return { - admittedSeq: 0, - id: value.id ?? "", - sessionID: value.sessionID, - timeCreated: Date.now(), - type: "user", - data: { text: value.text }, - delivery: value.delivery ?? "steer", - } - }, - async command(value: SessionCommandInput) { - await legacy().session.command({ - sessionID: value.sessionID, - messageID: value.id ?? undefined, - command: value.command, - arguments: value.arguments ?? "", - agent: value.agent ?? undefined, - model: value.model ? `${value.model.providerID}/${value.model.id}` : undefined, - variant: value.model?.variant, - parts: value.files?.map((file) => ({ - type: "file" as const, - mime: mime(file.uri), - url: file.uri, - filename: file.name, - })), - }) - return { - admittedSeq: 0, - id: value.id ?? "", - sessionID: value.sessionID, - timeCreated: Date.now(), - type: "user", - data: { text: `/${value.command} ${value.arguments ?? ""}`.trim() }, - delivery: value.delivery ?? "steer", - } - }, - async shell(value: SessionShellInput & LegacyPrompt) { - await legacy().session.shell({ - sessionID: value.sessionID, - command: value.command, - agent: value.agent, - model: value.model, - }) - }, - compact: async (value: SessionCompactInput & { model?: LegacyPrompt["model"] }) => { - if (!value.model) throw new Error("A model is required to compact a V1 session") - await legacy().session.summarize({ - sessionID: value.sessionID, - providerID: value.model.providerID, - modelID: value.model.modelID, - }) - return { - admittedSeq: 0, - id: value.id ?? "", - sessionID: value.sessionID, - timeCreated: Date.now(), - type: "compaction", - } - }, - revert: { - stage: async (value: Parameters[0]) => { - await legacy().session.revert(value) - return { messageID: value.messageID } - }, - clear: async (value: Parameters[0]) => { - await legacy().session.unrevert(value) - }, - commit: input.current.session.revert.commit, - }, - }, - project: { - ...input.current.project, - async list() { - return ((await legacy().project.list()).data ?? []) as Project[] - }, - async current(value?: Parameters[0]) { - const result = await legacy(value?.location).project.current() - if (!result.data) throw new Error("Project not found") - return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent - }, - // async update(value: Parameters[0]) { - // const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID) - // const result = await legacy({ directory: project?.worktree }).project.update({ - // ...value, - // directory: project?.worktree, - // }) - // if (!result.data) throw new Error(`Project not found: ${value.projectID}`) - // return result.data as Project - // }, - async directories(value: Parameters[0]) { - const result = await legacy(value.location).worktree.list() - return (result.data ?? []).map((item) => ({ directory: item })) - }, - }, - // path: { - // ...input.current.path, - // async get(value?: Parameters[0]) { - // const result = await legacy(value?.location).path.get() - // if (!result.data) throw new Error("Path unavailable") - // return result.data - // }, - // }, - vcs: { - ...input.current.vcs, - // async get(value?: Parameters[0]) { - // const result = await legacy(value?.location).vcs.get() - // return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location) - // }, - async status(value?: Parameters[0]) { - const result = await legacy(value?.location).vcs.status() - return located(result.data ?? [], value?.location) - }, - async diff(value: Parameters[0]) { - const result = await legacy(value.location).vcs.diff({ - mode: value.mode === "working" ? "git" : value.mode, - context: value.context, - }) - return located( - (result.data ?? []).map((file) => ({ - file: file.file, - patch: file.patch ?? "", - additions: file.additions, - deletions: file.deletions, - status: file.status ?? "modified", - })), - value.location, - ) - }, - }, - file: { - ...input.current.file, - async list(value?: Parameters[0]) { - const result = await legacy(value?.location).file.list({ path: value?.path ?? "" }) - return located(result.data ?? [], value?.location) - }, - async find(value: Parameters[0]) { - const result = await legacy(value.location).find.files({ - query: value.query, - dirs: value.type === undefined ? undefined : value.type === "directory" ? "true" : "false", - limit: value.limit, - }) - return located( - (result.data ?? []).map((path) => ({ path, type: value.type ?? "file" })), - value.location, - ) - }, - }, - integration: { - ...input.current.integration, - async get(value: Parameters[0]) { - const methods = ((await legacy(value.location).provider.auth()).data?.[value.integrationID] ?? []).map( - (method, index) => - method.type === "api" - ? { type: "key" as const, label: method.label } - : { type: "oauth" as const, id: String(index), label: method.label, prompts: method.prompts }, - ) - return located( - { - id: value.integrationID, - name: value.integrationID, - methods, - connections: [], - }, - value.location, - ) - }, - connect: { - ...input.current.integration.connect, - key: async (value: Parameters[0]) => { - await legacy(value.location).auth.set({ - providerID: value.integrationID, - auth: { type: "api", key: value.key }, - }) - await legacy(value.location).instance.dispose() - await input.legacy().instance.dispose() - }, - }, - oauth: { - ...input.current.integration.oauth, - connect: async (value: Parameters[0]) => { - const method = Number(value.methodID) - const result = await legacy(value.location).provider.oauth.authorize( - { providerID: value.integrationID, method, inputs: value.inputs }, - { throwOnError: true }, - ) - if (!result.data) throw new Error("Failed to start OAuth authorization") - return located( - { - attemptID: `${value.integrationID}:${method}`, - url: result.data.url, - instructions: result.data.instructions, - mode: result.data.method, - time: { created: Date.now(), expires: Date.now() + 10 * 60 * 1000 }, - }, - value.location, - ) - }, - complete: async (value: Parameters[0]) => { - const method = Number(value.attemptID.split(":").at(-1)) - await legacy(value.location).provider.oauth.callback( - { providerID: value.integrationID, method, code: value.code }, - { throwOnError: true }, - ) - await legacy(value.location).instance.dispose() - await input.legacy().instance.dispose() - }, - status: async (value: Parameters[0]) => { - const method = Number(value.attemptID.split(":").at(-1)) - await legacy(value.location).provider.oauth.callback( - { providerID: value.integrationID, method }, - { throwOnError: true }, - ) - await legacy(value.location).instance.dispose() - await input.legacy().instance.dispose() - return located( - { status: "complete" as const, time: { created: Date.now(), expires: Date.now() } }, - value.location, - ) - }, - }, - }, - pty: { - ...input.current.pty, - // async shells(value?: Parameters[0]) { - // return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location) - // }, - async list(value?: Parameters[0]) { - return located((await legacy(value?.location).pty.list()).data ?? [], value?.location) - }, - async create(value?: Parameters[0]) { - const result = await legacy(value?.location).pty.create({ - command: value?.command, - args: value?.args ? [...value.args] : undefined, - cwd: value?.cwd, - title: value?.title, - env: value?.env, - }) - if (!result.data) throw new Error("Failed to create terminal") - return located(result.data, value?.location) - }, - async get(value: Parameters[0]) { - const result = await legacy(value.location).pty.get({ ptyID: value.ptyID }) - if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`) - return located(result.data, value.location) - }, - async update(value: Parameters[0]) { - const result = await legacy(value.location).pty.update({ - ptyID: value.ptyID, - title: value.title, - size: value.size, - }) - if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`) - return located(result.data, value.location) - }, - async remove(value: Parameters[0]) { - await legacy(value.location).pty.remove({ ptyID: value.ptyID }) - }, - // async connectToken(value: Parameters[0]) { - // const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID }) - // if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`) - // return located(result.data, value.location) - // }, - }, - permission: { - ...input.current.permission, - async reply(value: Parameters[0] & { location?: { directory?: string } }) { - await legacy(value.location).permission.respond({ - sessionID: value.sessionID, - permissionID: value.requestID, - response: value.reply, - directory: directory(value.location), - }) - }, - }, - question: { - ...input.current.question, - async reply(value: Parameters[0]) { - await legacy().question.reply({ - requestID: value.requestID, - answers: value.answers.map((answer) => [...answer]), - }) - }, - async reject(value: Parameters[0]) { - await legacy().question.reject({ requestID: value.requestID }) - }, - }, - } -} diff --git a/packages/app/src/utils/server-health.test.ts b/packages/app/src/utils/server-health.test.ts index 69a8c7b3be..b1c8f2c7e2 100644 --- a/packages/app/src/utils/server-health.test.ts +++ b/packages/app/src/utils/server-health.test.ts @@ -14,45 +14,15 @@ function abortFromInput(input: RequestInfo | URL, init?: RequestInit) { describe("checkServerHealth", () => { test("returns healthy response with version", async () => { - let request: URL | undefined - const fetch = (async (input: RequestInfo | URL) => { - request = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input) - return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + const fetch = (async () => + new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { status: 200, headers: { "content-type": "application/json" }, - }) - }) as unknown as typeof globalThis.fetch + })) as unknown as typeof globalThis.fetch const result = await checkServerHealth(server, fetch) expect(result).toEqual({ healthy: true, version: "1.2.3" }) - expect(request?.pathname).toBe("/api/health") - }) - - test("falls back to the V1 health endpoint", async () => { - const paths: string[] = [] - const fetch = (async (input: RequestInfo | URL) => { - const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input) - paths.push(url.pathname) - if (url.pathname === "/api/health") return new Response(undefined, { status: 404 }) - return Response.json({ healthy: true, version: "1.18.4" }) - }) as unknown as typeof globalThis.fetch - - expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" }) - expect(paths).toEqual(["/api/health", "/global/health"]) - }) - - test("falls back when the current health response is malformed", async () => { - const paths: string[] = [] - const fetch = (async (input: RequestInfo | URL) => { - const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input) - paths.push(url.pathname) - if (url.pathname === "/api/health") return Response.json({}) - return Response.json({ healthy: true, version: "1.18.4" }) - }) as unknown as typeof globalThis.fetch - - expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" }) - expect(paths).toEqual(["/api/health", "/global/health"]) }) test("allows slow servers thirty seconds by default", async () => { @@ -172,7 +142,7 @@ describe("checkServerHealth", () => { retryDelayMs: 1, }) - expect(count).toBe(6) + expect(count).toBe(3) expect(result).toEqual({ healthy: false }) }) }) diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts index 1d7d9e4b2e..1b684d9af7 100644 --- a/packages/app/src/utils/server-health.ts +++ b/packages/app/src/utils/server-health.ts @@ -1,7 +1,6 @@ import { usePlatform } from "@/context/platform" import { ServerConnection } from "@/context/server" -import { authTokenFromCredentials, createSdkForServer } from "./server" -import { ClientError, OpenCode } from "@opencode-ai/client" +import { createSdkForServer } from "./server" import { Accessor, createEffect, onCleanup } from "solid-js" import { createStore, reconcile } from "solid-js/store" @@ -62,7 +61,6 @@ function wait(ms: number, signal?: AbortSignal) { function retryable(error: unknown, signal?: AbortSignal) { if (signal?.aborted) return false - if (error instanceof ClientError) return error.reason === "Transport" if (!(error instanceof Error)) return false if (error.name === "AbortError" || error.name === "TimeoutError") return false if (error instanceof TypeError) return true @@ -84,31 +82,15 @@ export async function checkServerHealth( .then(() => attempt(count + 1)) .catch(() => ({ healthy: false })) } - const attempt = async (count: number): Promise => { - const current = await OpenCode.make({ - baseUrl: server.url, + const attempt = (count: number): Promise => + createSdkForServer({ + server, fetch, - headers: server.password - ? { - Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`, - } - : undefined, + signal, }) - .health.get({ signal }) - .then((x) => - typeof x.healthy === "boolean" - ? { data: { healthy: x.healthy, version: x.version } } - : { error: new Error("Invalid health response") }, - ) - .catch((error) => ({ error })) - if ("data" in current && current.data) return current.data - if (signal?.aborted) return { healthy: false } - - return createSdkForServer({ server, fetch, signal }) .global.health() .then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version })) .catch((error) => next(count, error)) - } return attempt(0).finally(() => timeout?.clear?.()) } diff --git a/packages/app/src/utils/server-protocol.test.ts b/packages/app/src/utils/server-protocol.test.ts deleted file mode 100644 index 2130a968c4..0000000000 --- a/packages/app/src/utils/server-protocol.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { detectServerProtocol } from "./server-protocol" - -const server = { url: "http://localhost:4096" } -const json = (value: unknown, status = 200) => - new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }) -const mockFetch = (run: (input: string | URL | Request) => Promise) => - Object.assign(run, { preconnect: globalThis.fetch.preconnect }) - -describe("detectServerProtocol", () => { - test("prefers the legacy health endpoint when both API generations exist", async () => { - const fetcher = mockFetch((input) => { - const path = new URL(input instanceof Request ? input.url : input).pathname - if (path === "/global/health") return Promise.resolve(json({ healthy: true, version: "1.18.4" })) - return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 })) - }) - - expect(await detectServerProtocol(server, fetcher)).toBe("v1") - }) - - test("recognizes V2 health by its process identifier", async () => { - const fetcher = mockFetch((input) => { - const path = new URL(input instanceof Request ? input.url : input).pathname - if (path === "/global/health") return Promise.resolve(json({}, 404)) - return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 })) - }) - - expect(await detectServerProtocol(server, fetcher)).toBe("v2") - }) - - test("recognizes the transitional V1 API health response", async () => { - const fetcher = mockFetch((input) => { - const path = new URL(input instanceof Request ? input.url : input).pathname - if (path === "/global/health") return Promise.resolve(json({}, 404)) - return Promise.resolve(json({ healthy: true })) - }) - - expect(await detectServerProtocol(server, fetcher)).toBe("v1") - }) -}) diff --git a/packages/app/src/utils/server-protocol.ts b/packages/app/src/utils/server-protocol.ts deleted file mode 100644 index 27b8dc208e..0000000000 --- a/packages/app/src/utils/server-protocol.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { ServerConnection } from "@/context/server" -import { authTokenFromCredentials } from "./server" - -export type ServerProtocol = "v1" | "v2" - -function headers(server: ServerConnection.HttpBase) { - if (!server.password) return - return { - Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`, - } -} - -async function probe(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch, path: string) { - const response = await fetch(new URL(path, server.url), { - headers: headers(server), - signal: AbortSignal.timeout(5_000), - }) - if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return - const value: unknown = await response.json() - if (!value || typeof value !== "object") return - return value -} - -export async function detectServerProtocol( - server: ServerConnection.HttpBase, - fetch: typeof globalThis.fetch, -): Promise { - const legacy = await probe(server, fetch, "/global/health").catch(() => undefined) - if (legacy && "healthy" in legacy && legacy.healthy === true) return "v1" - - const current = await probe(server, fetch, "/api/health").catch(() => undefined) - if (current && "pid" in current && typeof current.pid === "number") return "v2" - if (current && "healthy" in current && current.healthy === true) return "v1" - return "v2" -} diff --git a/packages/app/src/utils/server.ts b/packages/app/src/utils/server.ts index 1c8292ca9d..603784e4d4 100644 --- a/packages/app/src/utils/server.ts +++ b/packages/app/src/utils/server.ts @@ -1,5 +1,4 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" -import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import type { ServerConnection } from "@/context/server" import { decode64 } from "@/utils/base64" @@ -40,23 +39,3 @@ export function createSdkForServer({ baseUrl: server.url, }) } - -export function createApiForServer(input: { - server: ServerConnection.HttpBase - fetch?: typeof globalThis.fetch -}): OpenCodeClient { - return OpenCode.make({ - baseUrl: input.server.url, - fetch: input.fetch, - headers: input.server.password - ? { - Authorization: `Basic ${authTokenFromCredentials({ - username: input.server.username, - password: input.server.password, - })}`, - } - : undefined, - }) -} - -export type ServerApi = OpenCodeClient diff --git a/packages/app/src/utils/session-message.test.ts b/packages/app/src/utils/session-message.test.ts deleted file mode 100644 index a69c414e16..0000000000 --- a/packages/app/src/utils/session-message.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { SessionMessageInfo } from "@opencode-ai/client/promise" -import { normalizeSessionMessages } from "./session-message" - -describe("normalizeSessionMessages", () => { - test("projects current turns into stable legacy rendering records", () => { - const source = [ - { id: "msg_1", type: "agent-switched", agent: "build", time: { created: 1 } }, - { - id: "msg_2", - type: "model-switched", - model: { id: "claude", providerID: "anthropic", variant: "high" }, - time: { created: 2 }, - }, - { - id: "msg_3", - type: "user", - text: "inspect @src/client.ts", - files: [ - { - data: "aGVsbG8=", - mime: "text/plain", - name: "note.txt", - source: { type: "inline" }, - }, - { - data: "ZXhwb3J0IHt9", - mime: "text/plain", - name: "client.ts", - source: { type: "inline" }, - mention: { text: "@src/client.ts", start: 8, end: 22 }, - }, - ], - agents: [{ name: "review", mention: { text: "@review", start: 0, end: 7 } }], - time: { created: 3 }, - }, - { - id: "msg_4", - type: "assistant", - agent: "build", - model: { id: "claude", providerID: "anthropic", variant: "high" }, - content: [ - { type: "reasoning", text: "Thinking", time: { created: 4, completed: 5 } }, - { type: "text", text: "Result" }, - { - type: "tool", - id: "call_1", - name: "read", - state: { - status: "completed", - input: { filePath: "note.txt" }, - metadata: { title: "note.txt" }, - content: [{ type: "text", text: "hello" }], - }, - time: { created: 5, ran: 6, completed: 7 }, - }, - ], - cost: 0.1, - tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 1, write: 0 } }, - time: { created: 4, completed: 7 }, - }, - { - id: "msg_5", - type: "compaction", - status: "completed", - reason: "auto", - summary: "summary", - recent: "recent", - time: { created: 8 }, - }, - ] satisfies SessionMessageInfo[] - - const result = normalizeSessionMessages("ses_1", source) - - expect(result.messages).toHaveLength(2) - expect(result.messages[0]).toMatchObject({ - id: "msg_3", - role: "user", - agent: "build", - model: { providerID: "anthropic", modelID: "claude", variant: "high" }, - }) - expect(result.messages[1]).toMatchObject({ id: "msg_4", role: "assistant", parentID: "msg_3", cost: 0.1 }) - expect(result.parts.get("msg_3")?.map((part) => part.id)).toEqual([ - "msg_3:text:0", - "msg_3:file:0", - "msg_3:file:1", - "msg_3:agent:0", - "msg_5:compaction", - ]) - expect(result.parts.get("msg_3")?.[2]).toMatchObject({ - type: "file", - source: { - type: "file", - path: "src/client.ts", - text: { value: "@src/client.ts", start: 8, end: 22 }, - }, - }) - expect(result.parts.get("msg_4")?.map((part) => part.id)).toEqual(["msg_4:reasoning:0", "msg_4:text:0", "call_1"]) - expect(result.parts.get("msg_4")?.[2]).toMatchObject({ - type: "tool", - tool: "read", - state: { status: "completed", output: "hello" }, - }) - }) - - test("does not invent a parent for an assistant-only page", () => { - const source = [ - { - id: "msg_2", - type: "assistant", - agent: "build", - model: { id: "model", providerID: "provider" }, - content: [{ type: "text", text: "orphan" }], - time: { created: 2 }, - }, - ] satisfies SessionMessageInfo[] - - expect(normalizeSessionMessages("ses_1", source).messages).toEqual([]) - }) - - test("projects a current shell message into a renderable standalone turn", () => { - const source = [ - { - id: "msg_shell", - type: "shell", - shellID: "shell_1", - command: "printf hello", - status: "exited", - exit: 0, - output: { output: "hello", cursor: 5, size: 5, truncated: false }, - time: { created: 1, completed: 2 }, - }, - ] satisfies SessionMessageInfo[] - - const result = normalizeSessionMessages("ses_1", source) - - expect(result.messages).toEqual([ - expect.objectContaining({ id: "msg_shell", role: "user" }), - expect.objectContaining({ id: "msg_shell:assistant", role: "assistant", parentID: "msg_shell" }), - ]) - expect(result.parts.get("msg_shell")).toEqual([expect.objectContaining({ type: "text", text: "printf hello" })]) - expect(result.parts.get("msg_shell:assistant")).toEqual([ - expect.objectContaining({ - type: "tool", - tool: "bash", - state: expect.objectContaining({ - status: "completed", - input: { command: "printf hello" }, - output: "hello", - title: "Shell", - }), - }), - ]) - }) - - test("adapts current edit fields for the legacy edit renderer", () => { - const source = [ - { id: "msg_user", type: "user", text: "edit it", time: { created: 1 } }, - { - id: "msg_assistant", - type: "assistant", - agent: "build", - model: { id: "model", providerID: "provider" }, - content: [ - { - type: "tool", - id: "call_edit", - name: "edit", - state: { - status: "completed", - input: { path: "/repo/README.md", oldString: "old", newString: "new" }, - content: [{ type: "text", text: "Edited file successfully" }], - metadata: { - files: [ - { - file: "README.md", - patch: "@@ -1 +1 @@\n-old\n+new", - additions: 1, - deletions: 1, - status: "modified", - }, - ], - replacements: 1, - }, - }, - time: { created: 2, ran: 3, completed: 4 }, - }, - ], - time: { created: 2, completed: 4 }, - }, - ] satisfies SessionMessageInfo[] - - const result = normalizeSessionMessages("ses_1", source) - - expect(result.parts.get("msg_assistant")).toEqual([ - expect.objectContaining({ - type: "tool", - tool: "edit", - state: expect.objectContaining({ - status: "completed", - input: expect.objectContaining({ path: "/repo/README.md", filePath: "/repo/README.md" }), - metadata: expect.objectContaining({ - filediff: { - file: "README.md", - patch: "@@ -1 +1 @@\n-old\n+new", - additions: 1, - deletions: 1, - }, - }), - }), - }), - ]) - }) -}) diff --git a/packages/app/src/utils/session-message.ts b/packages/app/src/utils/session-message.ts deleted file mode 100644 index 93d86a66bb..0000000000 --- a/packages/app/src/utils/session-message.ts +++ /dev/null @@ -1,358 +0,0 @@ -import type { - SessionMessageAssistant, - SessionMessageAssistantTool, - SessionMessageInfo, - SessionMessageShell, - SessionMessageUser, -} from "@opencode-ai/client/promise" -import type { AssistantMessage, FilePart, Message, Part, ToolPart, UserMessage } from "@opencode-ai/sdk/v2" -import { Option, Schema } from "effect" - -const emptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } -const emptyModel: { id: string; providerID: string; variant?: string } = { id: "", providerID: "" } -const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) - -function record(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value) -} - -function normalizeToolInput(name: string, input: Record) { - if (!["edit", "write"].includes(name) || typeof input.path !== "string" || typeof input.filePath === "string") - return input - return { ...input, filePath: input.path } -} - -function normalizeToolMetadata(name: string, metadata: Record) { - if (name !== "edit" || !Array.isArray(metadata.files)) return metadata - const file = metadata.files.find(record) - if (!file || typeof file.file !== "string") return metadata - return { - ...metadata, - filediff: { - file: file.file, - patch: typeof file.patch === "string" ? file.patch : undefined, - additions: typeof file.additions === "number" ? file.additions : 0, - deletions: typeof file.deletions === "number" ? file.deletions : 0, - }, - } -} - -export function normalizeSessionMessages(sessionID: string, source: readonly SessionMessageInfo[]) { - const messages: Message[] = [] - const parts = new Map() - let agent = "" - let model = emptyModel - let parentID: string | undefined - - source.forEach((message) => { - if (message.type === "agent-switched") { - agent = message.agent - return - } - if (message.type === "model-switched") { - model = message.model - return - } - if (message.type === "user") { - parentID = message.id - messages.push(userMessage(sessionID, message, agent, model)) - parts.set(message.id, userParts(sessionID, message)) - return - } - if (message.type === "synthetic" && message.description?.trim()) { - parentID = message.id - messages.push({ - id: message.id, - sessionID, - role: "user", - time: message.time, - agent, - model: { providerID: model.providerID, modelID: model.id, variant: model.variant }, - }) - parts.set(message.id, [textPart(sessionID, message.id, 0, message.description, true)]) - return - } - if (message.type === "shell") { - messages.push(...shellMessages(sessionID, message, agent, model)) - parts.set(message.id, [textPart(sessionID, message.id, 0, message.command)]) - parts.set(`${message.id}:assistant`, [shellPart(sessionID, message)]) - parentID = undefined - return - } - if (message.type === "assistant") { - agent = message.agent - model = message.model - if (!parentID) return - const parent = messages.findLast((item) => item.id === parentID) - if (parent?.role === "user") { - parent.agent = message.agent - parent.model = { - providerID: message.model.providerID, - modelID: message.model.id, - variant: message.model.variant, - } - } - messages.push(assistantMessage(sessionID, parentID, message)) - parts.set(message.id, assistantParts(sessionID, message)) - return - } - if (message.type !== "compaction" || !parentID) return - parts.set(parentID, [ - ...(parts.get(parentID) ?? []), - { - id: `${message.id}:compaction`, - sessionID, - messageID: parentID, - type: "compaction", - auto: message.reason === "auto", - }, - ]) - }) - - return { messages, parts } -} - -function shellMessages( - sessionID: string, - message: SessionMessageShell, - agent: string, - model: { id: string; providerID: string; variant?: string }, -): [UserMessage, AssistantMessage] { - return [ - { - id: message.id, - sessionID, - role: "user", - time: { created: message.time.created }, - agent, - model: { providerID: model.providerID, modelID: model.id, variant: model.variant }, - }, - { - id: `${message.id}:assistant`, - sessionID, - role: "assistant", - time: message.time, - parentID: message.id, - modelID: model.id, - providerID: model.providerID, - variant: model.variant, - mode: agent, - agent, - path: { cwd: "", root: "" }, - cost: 0, - tokens: emptyTokens, - }, - ] -} - -function shellPart(sessionID: string, message: SessionMessageShell): ToolPart { - const input = { command: message.command } - const start = message.time.created - const state: ToolPart["state"] = - message.status === "running" - ? { status: "running", input, time: { start } } - : { - status: "completed", - input, - output: message.output?.output ?? "", - title: "Shell", - metadata: { - status: message.status, - exit: message.exit, - truncated: message.output?.truncated, - }, - time: { start, end: message.time.completed ?? start }, - } - return { - id: `${message.id}:tool`, - sessionID, - messageID: `${message.id}:assistant`, - type: "tool", - callID: message.shellID, - tool: "bash", - state, - } -} - -export function sessionMessagePartID(messageID: string, type: "text" | "reasoning", ordinal: number) { - return `${messageID}:${type}:${ordinal}` -} - -function userMessage( - sessionID: string, - message: SessionMessageUser, - agent: string, - model: { id: string; providerID: string; variant?: string }, -): UserMessage { - return { - id: message.id, - sessionID, - role: "user", - time: message.time, - agent, - model: { providerID: model.providerID, modelID: model.id, variant: model.variant }, - } -} - -function userParts(sessionID: string, message: SessionMessageUser): Part[] { - return [ - textPart(sessionID, message.id, 0, message.text), - ...(message.files ?? []).map( - (file, index): FilePart => ({ - id: `${message.id}:file:${index}`, - sessionID, - messageID: message.id, - type: "file", - mime: file.mime, - filename: file.name, - url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`, - source: file.mention - ? { - type: "file", - text: { value: file.mention.text, start: file.mention.start, end: file.mention.end }, - path: file.mention.text.startsWith("@") ? file.mention.text.slice(1) : (file.name ?? file.mention.text), - } - : undefined, - }), - ), - ...(message.agents ?? []).map( - (item, index): Part => ({ - id: `${message.id}:agent:${index}`, - sessionID, - messageID: message.id, - type: "agent", - name: item.name, - source: item.mention - ? { value: item.mention.text, start: item.mention.start, end: item.mention.end } - : undefined, - }), - ), - ] -} - -function assistantMessage(sessionID: string, parentID: string, message: SessionMessageAssistant): AssistantMessage { - const error = message.error - ? message.error.type.toLowerCase().includes("abort") || message.error.type.toLowerCase().includes("interrupt") - ? { name: "MessageAbortedError" as const, data: { message: message.error.message } } - : { name: "UnknownError" as const, data: { message: message.error.message } } - : undefined - return { - id: message.id, - sessionID, - role: "assistant", - time: message.time, - error, - parentID, - modelID: message.model.id, - providerID: message.model.providerID, - variant: message.model.variant, - mode: message.agent, - agent: message.agent, - path: { cwd: "", root: "" }, - cost: message.cost ?? 0, - tokens: message.tokens ?? emptyTokens, - finish: message.finish, - } -} - -function assistantParts(sessionID: string, message: SessionMessageAssistant): Part[] { - const ordinals = { text: 0, reasoning: 0 } - return message.content.flatMap((content): Part[] => { - if (content.type === "text") { - const part = textPart(sessionID, message.id, ordinals.text++, content.text) - return content.text.trim() ? [part] : [] - } - if (content.type === "reasoning") { - const part: Part = { - id: sessionMessagePartID(message.id, "reasoning", ordinals.reasoning++), - sessionID, - messageID: message.id, - type: "reasoning", - text: content.text, - metadata: content.state, - time: { - start: content.time?.created ?? message.time.created, - end: content.time?.completed, - }, - } - return content.text.trim() ? [part] : [] - } - return [toolPart(sessionID, message.id, content)] - }) -} - -function textPart(sessionID: string, messageID: string, ordinal: number, text: string, synthetic?: boolean): Part { - return { - id: sessionMessagePartID(messageID, "text", ordinal), - sessionID, - messageID, - type: "text", - text, - synthetic, - } -} - -function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssistantTool): ToolPart { - const start = tool.time.ran ?? tool.time.created - const state = (() => { - if (tool.state.status === "streaming") { - const value = Option.getOrUndefined(decodeToolInput(tool.state.input)) - const input = normalizeToolInput(tool.name, record(value) ? value : {}) - return { status: "pending" as const, input, raw: tool.state.input } - } - if (tool.state.status === "running") { - return { - status: "running" as const, - input: normalizeToolInput(tool.name, tool.state.input), - // metadata: normalizeToolMetadata(tool.name, tool.state.structured), - metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}), - time: { start }, - } - } - if (tool.state.status === "error") { - return { - status: "error" as const, - input: normalizeToolInput(tool.name, tool.state.input), - error: tool.state.error.message, - // metadata: normalizeToolMetadata(tool.name, tool.state.structured), - metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}), - time: { start, end: tool.time.completed ?? start }, - } - } - const attachments = tool.state.content.flatMap((item, index): FilePart[] => - item.type === "file" - ? [ - { - id: `${tool.id}:file:${index}`, - sessionID, - messageID, - type: "file", - mime: item.mime, - filename: item.name, - url: item.uri, - }, - ] - : [], - ) - return { - status: "completed" as const, - input: normalizeToolInput(tool.name, tool.state.input), - output: tool.state.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n"), - title: tool.name, - // metadata: normalizeToolMetadata(tool.name, tool.state.structured), - metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}), - time: { start, end: tool.time.completed ?? start }, - attachments: attachments.length ? attachments : undefined, - } - })() - return { - id: tool.id, - sessionID, - messageID, - type: "tool", - callID: tool.id, - tool: tool.name, - state, - metadata: { providerState: tool.providerState, providerResultState: tool.providerResultState }, - } -} diff --git a/packages/app/src/utils/session.test.ts b/packages/app/src/utils/session.test.ts deleted file mode 100644 index b15c23b660..0000000000 --- a/packages/app/src/utils/session.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise" -import { listAllSessions, normalizeSessionInfo } from "./session" - -describe("normalizeSessionInfo", () => { - test("adapts a current session to the app session shape", () => { - const result = normalizeSessionInfo({ - id: "session-1", - projectID: "project-1", - agent: "build", - model: { id: "gpt-5", providerID: "openai", variant: "high" }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1, updated: 1 }, - title: "New session", - location: { directory: "/repo/worktree", workspaceID: "workspace-1" }, - subpath: "worktree", - revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot", files: [] }, - } as SessionInfo) - - expect(result).toEqual({ - id: "session-1", - slug: "session-1", - projectID: "project-1", - workspaceID: "workspace-1", - directory: "/repo/worktree", - path: "worktree", - parentID: undefined, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - title: "New session", - agent: "build", - model: { id: "gpt-5", providerID: "openai", variant: "high" }, - version: "", - time: { created: 1, updated: 1 }, - revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot" }, - }) - }) -}) - -describe("listAllSessions", () => { - test("loads every page in server order and retains the query", async () => { - const calls: SessionListInput[] = [] - const pages = new Map([ - [undefined, { data: [sessionInfo("session-3"), sessionInfo("session-2")], cursor: { next: "next" } }], - ["next", { data: [sessionInfo("session-1", true)], cursor: {} }], - ]) - const api = { - list: async (query = {}) => { - calls.push(query) - return pages.get(query.cursor) ?? { data: [], cursor: {} } - }, - } satisfies Pick - - const result = await listAllSessions(api, { directory: "/repo", order: "desc" }) - - expect(result.map((session) => session.id)).toEqual(["session-3", "session-2", "session-1"]) - expect(result[2]?.time.archived).toBe(2) - expect(calls).toEqual([ - { directory: "/repo", order: "desc", limit: 100, cursor: undefined }, - { directory: "/repo", order: "desc", limit: 100, cursor: "next" }, - ]) - }) - - test("requests the terminal empty page when the server returns a next cursor", async () => { - const cursors: Array = [] - const api = { - list: async (query = {}) => { - cursors.push(query.cursor) - if (query.cursor) return { data: [], cursor: { next: "unused" } } - return { data: [sessionInfo("session-1")], cursor: { next: "terminal" } } - }, - } satisfies Pick - - const result = await listAllSessions(api, { directory: "/repo", limit: 25 }) - - expect(result.map((session) => session.id)).toEqual(["session-1"]) - expect(cursors).toEqual([undefined, "terminal"]) - }) -}) - -function sessionInfo(id: string, archived = false) { - return { - id, - projectID: "project-1", - agent: "build", - model: { id: "model-1", providerID: "provider-1" }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1, updated: 1, archived: archived ? 2 : undefined }, - title: id, - location: { directory: "/repo" }, - } as SessionInfo -} diff --git a/packages/app/src/utils/session.ts b/packages/app/src/utils/session.ts deleted file mode 100644 index faf847967b..0000000000 --- a/packages/app/src/utils/session.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise" -import type { Session } from "@opencode-ai/sdk/v2/client" - -export function normalizeSessionInfo(input: SessionInfo | Session): Session { - if (!("location" in input)) return input - return { - id: input.id, - slug: input.id, - projectID: input.projectID, - workspaceID: input.location.workspaceID, - directory: input.location.directory, - path: input.subpath, - parentID: input.parentID, - cost: input.cost, - tokens: input.tokens, - title: input.title, - agent: input.agent, - model: input.model, - version: "", - time: input.time, - revert: input.revert && { - messageID: input.revert.messageID, - partID: input.revert.partID, - snapshot: input.revert.snapshot, - }, - } -} - -export async function listAllSessions(api: Pick, input: Omit) { - const load = async (cursor?: string): Promise => { - const result = await api.list({ ...input, limit: input.limit ?? 100, cursor }) - const sessions = result.data.map(normalizeSessionInfo) - if (result.data.length === 0 || !result.cursor.next) return sessions - return [...sessions, ...(await load(result.cursor.next))] - } - return load() -} diff --git a/packages/app/src/utils/terminal-websocket-url.test.ts b/packages/app/src/utils/terminal-websocket-url.test.ts index aac854ca82..5fa1506b1e 100644 --- a/packages/app/src/utils/terminal-websocket-url.test.ts +++ b/packages/app/src/utils/terminal-websocket-url.test.ts @@ -2,28 +2,8 @@ import { describe, expect, test } from "bun:test" import { terminalWebSocketURL } from "./terminal-websocket-url" describe("terminalWebSocketURL", () => { - test("uses the current ticketed PTY route", () => { + test("uses query auth without embedding credentials in websocket URL", () => { const url = terminalWebSocketURL({ - url: "http://127.0.0.1:49365", - id: "pty_test", - directory: "/tmp/project", - cursor: 0, - ticket: "connect-ticket", - }) - - expect(url.protocol).toBe("ws:") - expect(url.username).toBe("") - expect(url.password).toBe("") - expect(url.pathname).toBe("/api/pty/pty_test/connect") - expect(url.searchParams.get("location[directory]")).toBe("/tmp/project") - expect(url.searchParams.get("cursor")).toBe("0") - expect(url.searchParams.get("ticket")).toBe("connect-ticket") - expect(url.searchParams.has("auth_token")).toBe(false) - }) - - test("uses query auth without embedding credentials in websocket URL for v1", () => { - const url = terminalWebSocketURL({ - protocol: "v1", url: "http://127.0.0.1:49365", id: "pty_test", directory: "/tmp/project", @@ -36,14 +16,11 @@ describe("terminalWebSocketURL", () => { expect(url.protocol).toBe("ws:") expect(url.username).toBe("") expect(url.password).toBe("") - expect(url.pathname).toBe("/pty/pty_test/connect") - expect(url.searchParams.get("directory")).toBe("/tmp/project") expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) }) - test("omits query auth for same-origin saved credentials for v1", () => { + test("omits query auth for same-origin saved credentials", () => { const url = terminalWebSocketURL({ - protocol: "v1", url: "https://app.example.test", id: "pty_test", directory: "/tmp/project", @@ -54,14 +31,11 @@ describe("terminalWebSocketURL", () => { }) expect(url.protocol).toBe("wss:") - expect(url.pathname).toBe("/pty/pty_test/connect") - expect(url.searchParams.get("directory")).toBe("/tmp/project") expect(url.searchParams.has("auth_token")).toBe(false) }) - test("uses query auth for same-origin credentials from auth_token for v1", () => { + test("uses query auth for same-origin credentials from auth_token", () => { const url = terminalWebSocketURL({ - protocol: "v1", url: "https://app.example.test", id: "pty_test", directory: "/tmp/project", @@ -73,8 +47,6 @@ describe("terminalWebSocketURL", () => { }) expect(url.protocol).toBe("wss:") - expect(url.pathname).toBe("/pty/pty_test/connect") - expect(url.searchParams.get("directory")).toBe("/tmp/project") expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) }) }) diff --git a/packages/app/src/utils/terminal-websocket-url.ts b/packages/app/src/utils/terminal-websocket-url.ts index a32b239cc9..06facdc7d2 100644 --- a/packages/app/src/utils/terminal-websocket-url.ts +++ b/packages/app/src/utils/terminal-websocket-url.ts @@ -1,7 +1,6 @@ import { authTokenFromCredentials } from "@/utils/server" export function terminalWebSocketURL(input: { - protocol?: "v1" | "v2" url: string id: string directory: string @@ -12,24 +11,18 @@ export function terminalWebSocketURL(input: { password?: string authToken?: boolean }) { - const isV1 = input.protocol === "v1" - const next = new URL(`${input.url}${isV1 ? `/pty/${input.id}/connect` : `/api/pty/${input.id}/connect`}`) - if (isV1) { - next.searchParams.set("directory", input.directory) - } else { - next.searchParams.set("location[directory]", input.directory) - } + const next = new URL(`${input.url}/pty/${input.id}/connect`) + next.searchParams.set("directory", input.directory) next.searchParams.set("cursor", String(input.cursor)) next.protocol = next.protocol === "https:" ? "wss:" : "ws:" if (input.ticket) { next.searchParams.set("ticket", input.ticket) return next } - if (isV1 && input.password && (!input.sameOrigin || input.authToken)) { + if (input.password && (!input.sameOrigin || input.authToken)) next.searchParams.set( "auth_token", authTokenFromCredentials({ username: input.username, password: input.password }), ) - } return next } diff --git a/packages/app/src/utils/toast.tsx b/packages/app/src/utils/toast.tsx index 6f23b63d1e..e444548508 100644 --- a/packages/app/src/utils/toast.tsx +++ b/packages/app/src/utils/toast.tsx @@ -1,12 +1,6 @@ import { Icon, type IconProps } from "@opencode-ai/ui/icon" -import { - Toast, - showToast as showLegacyToast, - toaster as legacyToaster, - type ToastOptions, - type ToastVariant, -} from "@opencode-ai/ui/toast" -import { ToastV2, showToastV2, toasterV2 } from "@opencode-ai/ui/v2/toast-v2" +import { Toast, showToast as showLegacyToast, type ToastOptions, type ToastVariant } from "@opencode-ai/ui/toast" +import { ToastV2, showToastV2 } from "@opencode-ai/ui/v2/toast-v2" let v2 = false @@ -33,13 +27,6 @@ export function showToast(options: ToastOptions | string) { }) } -// v1 and v2 ids come from separate registries, so dismissal has to use the same -// implementation that issued the id. -export function dismissToast(toastId: number) { - if (!v2) return legacyToaster.dismiss(toastId) - return toasterV2.dismiss(toastId) -} - function resolveIcon(icon: IconProps["name"] | undefined, variant: ToastVariant | undefined) { const name = icon ?? (variant === "success" ? "check" : undefined) if (!name) return diff --git a/packages/app/test-browser/command-palette.test.ts b/packages/app/test-browser/command-palette.test.ts deleted file mode 100644 index 6a74834fd0..0000000000 --- a/packages/app/test-browser/command-palette.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { Project } from "@opencode-ai/sdk/v2/client" -import type { SessionInfo } from "@opencode-ai/client/promise" -import { createRoot } from "solid-js" -import { createServerSessionEntries } from "@/components/command-palette" -import type { LocalProject } from "@/context/layout" -import { ServerConnection } from "@/context/server" -import { getProjectAvatarSource } from "@/pages/layout/helpers" - -const stored: Project = { - id: "project-1", - name: "Palette project", - worktree: "/workspace/project", - sandboxes: [], - time: { created: 1, updated: 1 }, -} - -const session: SessionInfo = { - id: "session-1", - projectID: stored.id, - agent: "build", - model: { id: "model-1", providerID: "provider-1" }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - location: { directory: stored.worktree }, - title: "Palette session", - time: { created: 1, updated: 2 }, -} - -describe("command palette sessions", () => { - test("uses the home project avatar and cancels superseded searches", async () => { - const server = ServerConnection.Key.make("selected-server") - const opened: LocalProject = { - ...stored, - icon: { override: "home-project-avatar" }, - expanded: true, - } - const searches: string[] = [] - const result = await new Promise>>>( - (resolve, reject) => { - createRoot((dispose) => { - const search = createServerSessionEntries({ - server, - opened: () => [opened], - stored: () => [{ ...stored, icon: { url: "stored-project-avatar" } }], - load: async (text) => { - searches.push(text) - return { - data: [session, { ...session, id: "archived-session", time: { ...session.time, archived: 3 } }], - } - }, - untitled: () => "Untitled", - category: () => "Sessions", - }) - const first = search("palette") - const second = search("palette session") - Promise.all([first, second]) - .then(([cancelled, entries]) => { - expect(cancelled).toEqual([]) - resolve(entries) - }) - .catch(reject) - .finally(dispose) - }) - }, - ) - - expect(searches).toEqual(["palette session"]) - expect(result).toHaveLength(1) - expect(getProjectAvatarSource(result[0]?.project?.id, result[0]?.project?.icon)).toBe("home-project-avatar") - expect(result[0]).toMatchObject({ - server, - sessionID: session.id, - description: stored.name, - project: { id: stored.id, icon: opened.icon }, - }) - }) -}) diff --git a/packages/app/test-browser/prompt-attachments.test.ts b/packages/app/test-browser/prompt-attachments.test.ts index 49dad279cd..ca7686598e 100644 --- a/packages/app/test-browser/prompt-attachments.test.ts +++ b/packages/app/test-browser/prompt-attachments.test.ts @@ -1,10 +1,7 @@ import { describe, expect, test } from "bun:test" import { createRoot } from "solid-js" -import { createStore } from "solid-js/store" import { createPromptAttachmentsCore } from "@/components/prompt-input/attachments" import { createPromptState } from "@/context/prompt" -import { createPromptInputV2Attachments } from "../../session-ui/src/v2/components/prompt-input/attachments" -import type { PromptInputV2Prompt } from "../../session-ui/src/v2/components/prompt-input/types" describe("prompt attachment session ownership", () => { test("adds an asynchronously read image to the session where the read started", async () => { @@ -88,88 +85,6 @@ describe("prompt attachment session ownership", () => { }) }) -test("rejects a duplicate native clipboard attachment in the V2 prompt store", async () => { - await createRoot(async (dispose) => { - const [state, setState] = createStore({ prompt: [] as PromptInputV2Prompt }) - const duplicate = Promise.withResolvers() - const files = [ - new File(["hello"], "clipboard-1.txt", { type: "text/plain" }), - new File(["hello"], "clipboard-2.txt", { type: "text/plain" }), - ] - const attachments = createPromptInputV2Attachments({ - capture: () => ({ - current: () => state.prompt, - cursor: () => 0, - set: (prompt) => setState("prompt", prompt), - }), - editor: () => document.createElement("div"), - focusEditor: () => undefined, - addPart: () => false, - setDraggingType: () => undefined, - directory: () => "/", - isDialogActive: () => false, - warn: () => undefined, - duplicate: duplicate.resolve, - onError: () => undefined, - readClipboardImage: async () => files.shift() ?? null, - }) - const event = { - clipboardData: { items: [], getData: () => "" }, - preventDefault: () => undefined, - stopPropagation: () => undefined, - } as unknown as ClipboardEvent - - await attachments.handlePaste(event) - await attachments.handlePaste(event) - await duplicate.promise - - expect(state.prompt).toHaveLength(1) - dispose() - }) -}) - -test("rejects desktop duplicates and keeps changed files in the V2 prompt store", async () => { - await createRoot(async (dispose) => { - const [state, setState] = createStore({ prompt: [] as PromptInputV2Prompt }) - const duplicates: string[] = [] - const attachments = createPromptInputV2Attachments({ - capture: () => ({ - current: () => state.prompt, - cursor: () => 0, - set: (prompt) => setState("prompt", prompt), - }), - editor: () => document.createElement("div"), - focusEditor: () => undefined, - addPart: () => false, - setDraggingType: () => undefined, - directory: () => "/", - isDialogActive: () => false, - warn: () => undefined, - duplicate: () => duplicates.push("duplicate"), - onError: () => undefined, - getPathForFile: (file) => (file.name === "browser.txt" ? "" : `/tmp/${file.name}`), - }) - const first = new File(["first"], "a.txt", { type: "text/plain" }) - const second = new File(["second"], "b.txt", { type: "text/plain" }) - - await attachments.addAttachments([first, second]) - await attachments.addAttachments([first, second]) - expect(state.prompt).toHaveLength(2) - expect(duplicates).toEqual(["duplicate", "duplicate"]) - - await attachments.addAttachments([new File(["edited"], "a.txt", { type: "text/plain" })]) - expect(state.prompt).toHaveLength(3) - - await attachments.addAttachments([ - new File(["same"], "browser.txt", { type: "text/plain" }), - new File(["same"], "browser.txt", { type: "text/plain" }), - ]) - expect(state.prompt).toHaveLength(4) - expect(duplicates).toHaveLength(3) - dispose() - }) -}) - function images(prompt: ReturnType) { return prompt.current().filter((part) => part.type === "image") } diff --git a/packages/app/test-browser/prompt-persistence.test.ts b/packages/app/test-browser/prompt-persistence.test.ts index f3d8f8ae4f..a7b08078d7 100644 --- a/packages/app/test-browser/prompt-persistence.test.ts +++ b/packages/app/test-browser/prompt-persistence.test.ts @@ -2,7 +2,6 @@ import { beforeAll, describe, expect, mock, test } from "bun:test" import type { AsyncStorage } from "@solid-primitives/storage" import { createEffect, createRoot } from "solid-js" import { ServerScope } from "@/utils/server-scope" -import { createDraftStore } from "@/utils/draft-store" let Prompt: typeof import("@/context/prompt") let read: ((value: string | null) => void) | undefined @@ -31,7 +30,7 @@ beforeAll(async () => { }), })) mock.module("@/context/platform", () => ({ - usePlatform: () => ({ platform: "desktop", storage: () => storage, draftStore: storage }), + usePlatform: () => ({ platform: "desktop", storage: () => storage }), })) Prompt = await import("@/context/prompt") @@ -70,50 +69,3 @@ describe("prompt persistence", () => { }) }) }) - -test("moves legacy image data URLs into blobs and hydrates object URLs", async () => { - const documents = new Map() - const blobs = new Map() - const store = createDraftStore({ - get: async (key) => documents.get(key) ?? null, - set: async (key, value) => void documents.set(key, value), - remove: async (key) => void documents.delete(key), - putBlob: async (blob) => { - const id = String(blob.size) - blobs.set(id, blob) - return id - }, - getBlob: async (id) => blobs.get(id) ?? null, - }) - - await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "image", dataUrl: "data:image/png;base64,YQ==" }] })) - expect(documents.get("prompt")).not.toContain("dataUrl") - const value = JSON.parse((await store.getItem("prompt"))!) - expect(value.prompt[0].blob.id).toBe("1") - expect(value.prompt[0].blob.url).toStartWith("blob:") -}) - -test("does not let delayed blob migration overwrite a newer draft", async () => { - const documents = new Map() - const migration = Promise.withResolvers() - const store = createDraftStore({ - get: async () => null, - set: async (key, value) => void documents.set(key, value), - remove: async () => undefined, - putBlob: async () => { - await migration.promise - return "blob" - }, - getBlob: async () => null, - }) - const older = store.setItem( - "prompt", - JSON.stringify({ prompt: [{ type: "image", dataUrl: "data:image/png;base64,YQ==" }] }), - ) - await Bun.sleep(0) - await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "text", content: "latest" }] })) - migration.resolve() - await older - - expect(documents.get("prompt")).toContain("latest") -}) diff --git a/packages/app/test-browser/prompt-transient-state.test.ts b/packages/app/test-browser/prompt-transient-state.test.ts index 5777c9c392..61b5c6a14f 100644 --- a/packages/app/test-browser/prompt-transient-state.test.ts +++ b/packages/app/test-browser/prompt-transient-state.test.ts @@ -18,6 +18,7 @@ test("resets transient prompt input state when the prompt session changes", () = draggingType: "image", mode: "shell", applyingHistory: true, + variantOpen: true, }) setIdentity("B") @@ -32,6 +33,7 @@ test("resets transient prompt input state when the prompt session changes", () = draggingType: null, mode: "normal", applyingHistory: false, + variantOpen: false, }) dispose() }) diff --git a/packages/app/test-browser/settings-keybinds.test.ts b/packages/app/test-browser/settings-keybinds.test.ts deleted file mode 100644 index 75f0b75543..0000000000 --- a/packages/app/test-browser/settings-keybinds.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { createRoot } from "solid-js" -import { createKeybindSettingsController } from "../src/components/settings-keybinds" - -function setup(overrides: Record = {}) { - const changes: [string, string][] = [] - const suppression: boolean[] = [] - const notifications: { title: string; description?: string }[] = [] - let resets = 0 - let controller: ReturnType - - const dispose = createRoot((dispose) => { - controller = createKeybindSettingsController( - { - command: { - catalog: [ - { id: "session.alpha", title: "Alpha", keybind: "mod+a" }, - { id: "session.beta", title: "Beta", keybind: "mod+b" }, - ], - options: [], - keybinds: (enabled) => suppression.push(enabled), - }, - settings: { - current: { keybinds: overrides }, - keybinds: { - get: (id) => overrides[id], - set: (id, value) => { - overrides[id] = value - changes.push([id, value]) - }, - resetAll: () => { - resets++ - }, - }, - }, - notify: (toast) => notifications.push(toast), - }, - { - locale: () => "en", - t: (key, params) => { - if (params) return `${key}:${Object.values(params).join("|")}` - if (key === "common.key.alt") return "Alt" - return String(key) - }, - }, - ) - return dispose - }) - - return { - controller: controller!, - changes, - suppression, - notifications, - resets: () => resets, - dispose, - } -} - -function modKey(key: string) { - const mac = /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) - return new KeyboardEvent("keydown", { key, ctrlKey: !mac, metaKey: mac, bubbles: true, cancelable: true }) -} - -describe("keybind settings controller", () => { - test("derives the catalog, effective bindings, and filtered groups", () => { - const state = setup({ "session.beta": "alt+k" }) - - expect(state.controller.catalog.title("session.alpha")).toBe("Alpha") - expect(state.controller.catalog.keybind("session.beta")).toBe("Alt+K") - expect(state.controller.catalog.filtered("alt k").get("Session")).toEqual(["session.beta"]) - expect(state.controller.settings.hasOverrides()).toBe(true) - - state.dispose() - }) - - test("captures bindings, rejects conflicts, and restores command handling", () => { - const state = setup() - - state.controller.capture.toggle("session.beta") - document.dispatchEvent(modKey("a")) - expect(state.changes).toEqual([]) - expect(state.notifications).toHaveLength(1) - expect(state.controller.capture.active()).toBe("session.beta") - - document.dispatchEvent(modKey("x")) - expect(state.changes).toEqual([["session.beta", "mod+x"]]) - expect(state.suppression).toEqual([false, true]) - expect(state.controller.capture.active()).toBeNull() - - state.controller.capture.toggle("session.alpha") - state.dispose() - expect(state.suppression).toEqual([false, true, false, true]) - document.dispatchEvent(modKey("z")) - expect(state.changes).toEqual([["session.beta", "mod+x"]]) - }) - - test("resets persisted overrides and reports success", () => { - const state = setup({ "session.alpha": "none" }) - - state.controller.settings.reset() - expect(state.resets()).toBe(1) - expect(state.notifications[0]?.title).toBe("settings.shortcuts.reset.toast.title") - - state.dispose() - }) -}) diff --git a/packages/app/test-browser/solid-router-cleanup.test.ts b/packages/app/test-browser/solid-router-cleanup.test.ts deleted file mode 100644 index 5795fa5648..0000000000 --- a/packages/app/test-browser/solid-router-cleanup.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { expect, test } from "bun:test" -import { MetaProvider, Title } from "@solidjs/meta" -import { MemoryRouter, Route, createMemoryHistory, useParams } from "@solidjs/router" -import { createMemo } from "solid-js" -import { createComponent, render } from "solid-js/web" - -test("route cleanup cannot invalidate an owner list being disposed", async () => { - const host = document.createElement("div") - document.body.append(host) - const history = createMemoryHistory() - - const RepoPage = () => { - const params = useParams<{ id?: string }>() - const title = createMemo(() => params.id ?? "") - const button = document.createElement("button") - button.textContent = "Back" - button.addEventListener("click", () => history.set({ value: "/", scroll: false, replace: false })) - return [ - createComponent(Title, { - get children() { - return title() - }, - }), - button, - ] - } - - const HomePage = () => { - const button = document.createElement("button") - button.textContent = "Go" - button.addEventListener("click", () => history.set({ value: "/project", scroll: false, replace: false })) - return button - } - - const App = () => - createComponent(MetaProvider, { - get children() { - return createComponent(MemoryRouter, { - history, - get children() { - return [ - createComponent(Route, { path: "/", component: HomePage }), - createComponent(Route, { path: "/:id", component: RepoPage }), - ] - }, - }) - }, - }) - - const dispose = render(() => createComponent(App, {}), host) - const go = host.querySelector("button") - expect(go?.textContent).toBe("Go") - go?.click() - await new Promise((resolve) => setTimeout(resolve, 0)) - - const back = host.querySelector("button") - expect(back?.textContent).toBe("Back") - back?.click() - await new Promise((resolve) => setTimeout(resolve, 0)) - - expect(host.querySelector("button")?.textContent).toBe("Go") - dispose() - host.remove() -}) diff --git a/packages/app/test-browser/toast-owner.test.ts b/packages/app/test-browser/toast-owner.test.ts index 25ba5c000e..104a505851 100644 --- a/packages/app/test-browser/toast-owner.test.ts +++ b/packages/app/test-browser/toast-owner.test.ts @@ -1,46 +1,8 @@ -import { beforeEach, describe, expect, test } from "bun:test" +import { describe, expect, test } from "bun:test" import { createSignal, type JSX } from "solid-js" import { showToastV2, toasterV2 } from "@opencode-ai/ui/v2/toast-v2" describe("showToastV2", () => { - // The toast registry is module state, so each test starts from an empty stack. - beforeEach(() => { - toasterV2.dismiss() - }) - - test("coalesces exact active content", () => { - const first = showToastV2({ title: "Repeated error", description: "Try again" }) - const second = showToastV2({ title: "Repeated error", description: "Try again" }) - const different = showToastV2({ title: "Repeated error", description: "A different error" }) - - expect(second).toBe(first) - expect(different).not.toBe(first) - - toasterV2.dismiss(first) - toasterV2.dismiss(different) - }) - - test("allows dismissed content to appear again", () => { - const first = showToastV2("Dismiss and retry") - toasterV2.dismiss(first) - - const second = showToastV2("Dismiss and retry") - expect(second).not.toBe(first) - - toasterV2.dismiss(second) - }) - - test("recreates matching content when it is not the topmost toast", () => { - const first = showToastV2("First toast") - const topmost = showToastV2("Topmost toast") - const repeated = showToastV2("First toast") - - expect(repeated).not.toBe(first) - - toasterV2.dismiss(topmost) - toasterV2.dismiss(repeated) - }) - test("creates no reactive computations at call time", () => { const [tick, setTick] = createSignal(0) let reads = 0 diff --git a/packages/app/vendor/opencode-ai-client-1.17.13-v2.tgz b/packages/app/vendor/opencode-ai-client-1.17.13-v2.tgz deleted file mode 100644 index bc1b664f82..0000000000 Binary files a/packages/app/vendor/opencode-ai-client-1.17.13-v2.tgz and /dev/null differ diff --git a/packages/cli/package.json b/packages/cli/package.json index be5d853de0..d2285d0fc7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/cli", - "version": "1.18.11", + "version": "1.18.0", "type": "module", "license": "MIT", "bin": { diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index f42d8b07b0..55869a6295 100755 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { $ } from "bun" +import fs from "fs" import { rm } from "fs/promises" import path from "path" import { Script } from "@opencode-ai/script" @@ -50,6 +51,10 @@ const targets = singleFlag if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}` +const localParserWorker = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js") +const rootParserWorker = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js") +const parserWorker = fs.realpathSync(fs.existsSync(localParserWorker) ? localParserWorker : rootParserWorker) + for (const item of targets) { const target = [ binary, @@ -63,7 +68,7 @@ for (const item of targets) { const name = target.replace(binary, "cli") console.log(`building ${name}`) const result = await Bun.build({ - entrypoints: ["./src/index.ts"], + entrypoints: ["./src/index.ts", parserWorker], tsconfig: "./tsconfig.json", plugins: [plugin], external: ["node-gyp"], @@ -89,6 +94,10 @@ for (const item of targets) { OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined", // FFF_LIBC selects the fff native lib variant: "musl" or "gnu". FFF_LIBC: item.os === "linux" ? `'${item.abi ?? "gnu"}'` : "undefined", + OTUI_TREE_SITTER_WORKER_PATH: + (item.os === "win32" ? '"B:/~BUN/root/' : '"/$bunfs/root/') + + path.relative(dir, parserWorker).replaceAll("\\", "/") + + '"', ...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}), }, }) diff --git a/packages/cli/script/generate.ts b/packages/cli/script/generate.ts index e162f2ea7e..d98565e298 100755 --- a/packages/cli/script/generate.ts +++ b/packages/cli/script/generate.ts @@ -1,4 +1,4 @@ -const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.opencode.ai" +const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev" export const modelsData = process.env.MODELS_DEV_API_JSON ? await Bun.file(process.env.MODELS_DEV_API_JSON).text() diff --git a/packages/codemode/package.json b/packages/codemode/package.json index 1641518483..2c183736e1 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "1.18.11", + "version": "1.18.0", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 5023021661..dfde890ba0 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.18.11", + "version": "1.18.0", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/app/src/app.tsx b/packages/console/app/src/app.tsx index 1f1d0066ec..99b1010767 100644 --- a/packages/console/app/src/app.tsx +++ b/packages/console/app/src/app.tsx @@ -9,6 +9,7 @@ import "./app.css" import { LanguageProvider } from "~/context/language" import { I18nProvider, useI18n } from "~/context/i18n" import { strip } from "~/lib/language" +import { DesktopPromo } from "~/component/desktop-promo" function AppMeta() { const i18n = useI18n() @@ -33,6 +34,7 @@ export default function App() { {props.children} + diff --git a/packages/console/app/src/asset/lander/desktop-tabs-landscape.mp4 b/packages/console/app/src/asset/lander/desktop-tabs-landscape.mp4 index c6e710c024..5d2bb04ea9 100644 Binary files a/packages/console/app/src/asset/lander/desktop-tabs-landscape.mp4 and b/packages/console/app/src/asset/lander/desktop-tabs-landscape.mp4 differ diff --git a/packages/console/app/src/component/desktop-promo.css b/packages/console/app/src/component/desktop-promo.css index 5e9a4863c0..dca1385d4d 100644 --- a/packages/console/app/src/component/desktop-promo.css +++ b/packages/console/app/src/component/desktop-promo.css @@ -81,9 +81,7 @@ border-radius: 0.25rem; background: rgb(0 0 0 / 70%); opacity: 0; - transition: - opacity 150ms ease, - background 150ms ease; + transition: opacity 150ms ease, background 150ms ease; } &:hover [data-slot="desktop-promo-close"], diff --git a/packages/console/app/src/component/desktop-promo.tsx b/packages/console/app/src/component/desktop-promo.tsx index 504088e3c7..165f4fc690 100644 --- a/packages/console/app/src/component/desktop-promo.tsx +++ b/packages/console/app/src/component/desktop-promo.tsx @@ -7,17 +7,12 @@ import { useI18n } from "~/context/i18n" import { useLanguage } from "~/context/language" import { strip } from "~/lib/language" -const DISMISSED_COOKIE = "desktop_promo_dismissed" - export function DesktopPromo() { const i18n = useI18n() const language = useLanguage() const location = useLocation() + const [visible, setVisible] = createSignal(true) const request = getRequestEvent()?.request - const cookie = request?.headers.get("cookie") ?? (typeof document === "object" ? document.cookie : "") - const [visible, setVisible] = createSignal( - !cookie.split(";").some((value) => value.trim() === `${DISMISSED_COOKIE}=1`), - ) const hostname = request ? new URL(request.url).hostname : typeof window === "object" ? window.location.hostname : "" const primaryHost = hostname === "opencode.ai" || hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" @@ -41,14 +36,7 @@ export function DesktopPromo() { -
diff --git a/packages/console/app/src/routes/go/index.css b/packages/console/app/src/routes/go/index.css index bcf7c896a4..45b61d217e 100644 --- a/packages/console/app/src/routes/go/index.css +++ b/packages/console/app/src/routes/go/index.css @@ -327,37 +327,6 @@ body { } } - [data-component="desktop-app-banner"] { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 32px; - - [data-slot="badge"] { - background: var(--color-background-strong); - color: var(--color-text-inverted); - font-weight: 500; - padding: 4px 8px; - line-height: 1; - flex-shrink: 0; - } - - [data-slot="content"] { - display: flex; - align-items: center; - gap: 1ch; - } - - [data-slot="text"] { - color: var(--color-text-strong); - line-height: 1.4; - - @media (max-width: 30.625rem) { - display: none; - } - } - } - [data-slot="hero-copy"] { img { margin-bottom: 24px; @@ -523,12 +492,6 @@ body { @media (prefers-color-scheme: light) { color: color-mix(in srgb, var(--color-text-weak) 82%, var(--color-text-strong)); } - - @media (max-width: 60rem) { - &:not([data-tick="1"], [data-tick="25"], [data-tick="100"], [data-tick="250"]) { - display: none; - } - } } [data-slot="ylabels"] [data-ylabel] { @@ -665,8 +628,8 @@ body { stroke: none; } - [data-bar][data-kind="promo"] { - fill: color-mix(in srgb, var(--bar-go) 50%, transparent); + [data-bar][data-kind="free"] { + fill: var(--color-text-strong); } [data-val] { @@ -689,6 +652,11 @@ body { transform-origin: center; } + [data-point][data-kind="free"] { + fill: var(--color-background); + stroke: var(--color-text-strong); + } + [data-point][data-kind="go"] { fill: var(--color-background-interactive); stroke: var(--color-text-strong); @@ -1060,47 +1028,28 @@ body { margin-bottom: 32px; [data-slot="faq-models"] { - margin: 16px 0 0; - padding-left: 20px; - - li { - margin-bottom: 0; - list-style: disc; - } - } - - [data-slot="faq-model-table"] { - max-width: 560px; margin-top: 16px; overflow-x: auto; table { width: 100%; + min-width: 28rem; border-collapse: collapse; - line-height: 150%; } th, td { - padding: 8px 12px; - border-bottom: 1px solid var(--color-border-weak); + padding: 12px 16px; text-align: left; white-space: nowrap; } th { + background: var(--color-background-weak); color: var(--color-text-strong); font-weight: 500; } } - - [data-slot="faq-retention-notes"] { - margin-top: 16px; - - p { - margin: 8px 0 0; - } - } } } diff --git a/packages/console/app/src/routes/go/index.tsx b/packages/console/app/src/routes/go/index.tsx index 17c78f214d..960099fe49 100644 --- a/packages/console/app/src/routes/go/index.tsx +++ b/packages/console/app/src/routes/go/index.tsx @@ -23,25 +23,20 @@ const checkLoggedIn = query(async () => { }, "checkLoggedIn.get") const models = [ - { name: "Grok 4.5", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" }, - { name: "GPT 5.6 Luna", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" }, - { name: "GLM-5.2", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, - { name: "GLM-5.1", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, - { name: "Kimi K3", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, - { name: "Kimi K2.7 Code", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, - { name: "Kimi K2.6", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, - { name: "MiMo-V2.5-Pro", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, - { name: "MiMo-V2.5", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, - { name: "Qwen3.8 Max", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, - { name: "Qwen3.7 Max", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, - { name: "Qwen3.7 Plus", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, - { name: "Qwen3.6 Plus", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, - { name: "MiniMax M3", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, - { name: "MiniMax M2.7", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, - { name: "DeepSeek V4 Pro", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, - { name: "DeepSeek V4 Flash", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, - { name: "Hy3", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, -] as const + { name: "GLM-5.2", provider: "DeepInfra, Fireworks AI, Z.ai" }, + { name: "GLM-5.1", provider: "DeepInfra, Fireworks AI, Z.ai" }, + { name: "Kimi K2.7 Code", provider: "Moonshot AI" }, + { name: "Kimi K2.6", provider: "Moonshot AI" }, + { name: "MiMo-V2.5-Pro", provider: "Xiaomi MiMo" }, + { name: "MiMo-V2.5", provider: "Xiaomi MiMo" }, + { name: "Qwen3.7 Max", provider: "Alibaba Cloud Model Studio" }, + { name: "Qwen3.7 Plus", provider: "Alibaba Cloud Model Studio" }, + { name: "Qwen3.6 Plus", provider: "Alibaba Cloud Model Studio" }, + { name: "MiniMax M3", provider: "MiniMax" }, + { name: "MiniMax M2.7", provider: "MiniMax" }, + { name: "DeepSeek V4 Pro", provider: "DeepSeek" }, + { name: "DeepSeek V4 Flash", provider: "DeepSeek" }, +] function LimitsGraph(props: { href: string }) { let root!: HTMLElement @@ -64,17 +59,15 @@ function LimitsGraph(props: { href: string }) { onCleanup(() => observer.disconnect()) }) - const baseline = 100 + const free = 200 const graph = [ - { id: "grok-4.5", name: "Grok 4.5", req: 120, d: "50ms" }, - { id: "kimi-k3", name: "Kimi K3", req: 110, d: "75ms" }, - { id: "qwen3.8-max", name: "Qwen3.8 Max", req: 160, d: "90ms" }, { id: "glm-5.2", name: "GLM-5.2", req: 880, d: "100ms" }, + { id: "qwen3.7-max", name: "Qwen3.7 Max", req: 950, d: "110ms" }, + { id: "kimi-k2.7-code", name: "Kimi K2.7 Code", req: 1150, d: "150ms" }, { id: "minimax-m3", name: "MiniMax M3", req: 3200, d: "210ms" }, + { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "240ms" }, { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "270ms" }, - { id: "gpt-5.6-luna", name: "GPT 5.6 Luna (2x usage)", req: 4100, baseReq: 2050, d: "290ms" }, { id: "qwen3.7-plus", name: "Qwen3.7 Plus", req: 4300, d: "300ms" }, - { id: "hy3", name: "Hy3", req: 4300, d: "320ms" }, { id: "mimo-v2.5", name: "MiMo-V2.5", req: 30100, d: "340ms" }, { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", req: 31650, d: "340ms" }, ] @@ -86,13 +79,15 @@ function LimitsGraph(props: { href: string }) { const bottom = 44 const plot = w - left - right - const ratio = (n: number) => n / baseline + const ratio = (n: number) => n / free const rmax = Math.max(1, ...graph.map((m) => ratio(m.req))) const log = (n: number) => Math.log10(Math.max(n, 1)) const base = 24 const p = 2.2 const x = (r: number) => left + base + Math.pow(log(r) / log(rmax), p) * (plot - base) - const ticks = [1, 5, 10, 25, 50, 100, 250].filter((t) => t <= rmax) + const start = (x(1) / w) * 100 + + const ticks = [1, 5, 10, 25, 50, 100].filter((t) => t <= rmax) const labels = (() => { const set = new Set() let last = -Infinity @@ -113,8 +108,10 @@ function LimitsGraph(props: { href: string }) { const bh = 8 const gap = 20 const step = bh + gap - const gy = (i: number) => top + 22 + step * i - const h = gy(graph.length - 1) + bottom + const h = 330 + Math.max(0, graph.length - 8) * step + const sep = bh + 40 + const fy = top + 22 + const gy = (i: number) => fy + sep + step * i const my = graph.length < 2 ? gy(0) : (gy(0) + gy(graph.length - 1)) / 2 const px = (n: number) => `${(n / w) * 100}%` const py = (n: number) => `${(n / h) * 100}%` @@ -124,9 +121,10 @@ function LimitsGraph(props: { href: string }) { return (
- {(t) => } + + {(t) => ( + + + + )} + + + + + {(m, i) => ( - {m.baseReq && ( - - )} )} @@ -173,6 +170,9 @@ function LimitsGraph(props: { href: string }) { diff --git a/packages/session-ui/src/components/message-file.test.ts b/packages/session-ui/src/components/message-file.test.ts index a769ae01bb..3882be027e 100644 --- a/packages/session-ui/src/components/message-file.test.ts +++ b/packages/session-ui/src/components/message-file.test.ts @@ -21,7 +21,7 @@ describe("message-file", () => { expect(attached(file())).toBe(false) }) - test("keeps data-backed file mentions inline", () => { + test("treats only non-attachment source ranges as inline references", () => { expect( inline( file({ @@ -34,16 +34,18 @@ describe("message-file", () => { ), ).toBe(true) - const mentioned = file({ - url: "data:text/plain;base64,SGVsbG8=", - source: { - type: "file", - path: "/repo/README.txt", - text: { value: "@README.txt", start: 0, end: 11 }, - }, - }) - expect(inline(mentioned)).toBe(true) - expect(attached(mentioned)).toBe(false) + expect( + inline( + file({ + url: "data:text/plain;base64,SGVsbG8=", + source: { + type: "file", + path: "/repo/README.txt", + text: { value: "@README.txt", start: 0, end: 11 }, + }, + }), + ), + ).toBe(false) }) test("separates image and file attachment kinds", () => { diff --git a/packages/session-ui/src/components/message-file.ts b/packages/session-ui/src/components/message-file.ts index e09269f33f..81ce97827f 100644 --- a/packages/session-ui/src/components/message-file.ts +++ b/packages/session-ui/src/components/message-file.ts @@ -3,10 +3,11 @@ import { getFilename } from "@opencode-ai/core/util/path" import type { FilePart } from "@opencode-ai/sdk/v2" export function attached(part: FilePart) { - return part.url.startsWith("data:") && !inline(part) + return part.url.startsWith("data:") } export function inline(part: FilePart) { + if (attached(part)) return false return part.source?.text?.start !== undefined && part.source?.text?.end !== undefined } diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index f4e8f68981..2063507241 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -891,7 +891,7 @@ width: 16px; height: 2px; border-radius: 999px; - background-color: var(--v2-icon-icon-muted); + background-color: var(--icon-weak-base); transition: background-color 0.2s ease; } diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index e63b5b5685..601d7839ef 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -62,6 +62,7 @@ import { AnimatedCountList } from "./tool-count-summary" import { ToolStatusTitle } from "./tool-status-title" import { patchFiles } from "./apply-patch-file" import { animate } from "motion" +import { useLocation } from "@solidjs/router" import { attached, inline, kind, typeLabel } from "./message-file" import { readPartText } from "./message-part-text" import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2" @@ -520,7 +521,6 @@ export function getToolInfo( } } case "bash": - case "shell": return { icon: "console", title: i18n.t("ui.tool.shell"), @@ -538,7 +538,6 @@ export function getToolInfo( title: i18n.t("ui.messagePart.title.write"), subtitle: input.filePath ? getFilename(input.filePath) : undefined, } - case "patch": case "apply_patch": return { icon: "code-lines", @@ -582,18 +581,29 @@ function urls(text: string | undefined) { }) } -function sessionLink(id: string | undefined, href?: (id: string) => string | undefined) { - if (!id) return undefined - return href?.(id) +function sessionLink(id: string | undefined, path: string, href?: (id: string) => string | undefined) { + if (!id) return + + const direct = href?.(id) + if (direct) return direct + + const idx = path.indexOf("/session") + if (idx === -1) return + return `${path.slice(0, idx)}/session/${id}` +} + +function currentSession(path: string) { + return path.match(/\/session\/([^/?#]+)/)?.[1] } function taskSession( input: Record, - parentID: string | undefined, + path: string, sessions: Session[] | undefined, agents?: readonly { name: string; color?: string }[], ) { - if (!parentID) return undefined + const parentID = currentSession(path) + if (!parentID) return const description = typeof input.description === "string" ? input.description : "" const agent = taskAgent(input.subagent_type, agents).name return (sessions ?? []) @@ -719,8 +729,8 @@ export function renderable(part: PartType, showReasoningSummaries = true) { } function toolDefaultOpen(tool: string, shell = false, edit = false) { - if (tool === "bash" || tool === "shell") return shell - if (tool === "edit" || tool === "write" || tool === "patch" || tool === "apply_patch") return edit + if (tool === "bash") return shell + if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit } export function partDefaultOpen(part: PartType, shell = false, edit = false) { @@ -1496,7 +1506,7 @@ export function registerTool(input: { name: string; render?: ToolComponent }) { } export function getTool(name: string) { - return state[name === "apply_patch" ? "patch" : name === "bash" ? "shell" : name]?.render + return state[name]?.render } export const ToolRegistry = { @@ -1563,7 +1573,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { }) const taskHref = createMemo(() => { if (part().tool !== "task") return - return sessionLink(taskId(), data.sessionHref) + return sessionLink(taskId(), useLocation().pathname, data.sessionHref) }) const taskSubtitle = createMemo(() => { if (part().tool !== "task") return undefined @@ -1602,14 +1612,6 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined} subtitle={taskSubtitle()} href={taskHref()} - onSubtitleClick={(event) => { - if (!data.navigateToSession) return - if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return - const id = taskId() - if (!id) return - event.preventDefault() - data.navigateToSession(id) - }} /> ) }} @@ -1990,10 +1992,11 @@ ToolRegistry.register({ render(props) { const data = useData() const i18n = useI18n() + const location = useLocation() const childSessionId = createMemo(() => { const value = props.metadata.sessionId if (typeof value === "string" && value) return value - return taskSession(props.input, data.sessionID, data.store.session, data.store.agent) + return taskSession(props.input, location.pathname, data.store.session, data.store.agent) }) const agent = createMemo(() => taskAgent(props.input.subagent_type, data.store.agent)) const title = createMemo(() => agent().name ?? i18n.t("ui.tool.agent.default")) @@ -2010,13 +2013,18 @@ ToolRegistry.register({ }) const running = createMemo(() => props.status === "pending" || props.status === "running") - const href = createMemo(() => sessionLink(childSessionId(), data.sessionHref)) + const href = createMemo(() => sessionLink(childSessionId(), location.pathname, data.sessionHref)) const clickable = createMemo(() => !!(childSessionId() && (data.navigateToSession || href()))) const open = () => { const id = childSessionId() if (!id) return - data.navigateToSession?.(id) + if (data.navigateToSession) { + data.navigateToSession(id) + return + } + const value = href() + if (value) window.location.assign(value) } const navigate = (event: MouseEvent) => { @@ -2093,7 +2101,7 @@ ToolRegistry.register({ }) ToolRegistry.register({ - name: "shell", + name: "bash", render(props) { const i18n = useI18n() const pending = () => props.status === "pending" || props.status === "running" @@ -2118,14 +2126,13 @@ ToolRegistry.register({ (
- +
@@ -2329,7 +2336,7 @@ ToolRegistry.register({ }) ToolRegistry.register({ - name: "patch", + name: "apply_patch", render(props) { const i18n = useI18n() const fileComponent = useFileComponent() diff --git a/packages/session-ui/src/components/session-diff.ts b/packages/session-ui/src/components/session-diff.ts index 2fbd022235..48e8eee310 100644 --- a/packages/session-ui/src/components/session-diff.ts +++ b/packages/session-ui/src/components/session-diff.ts @@ -1,7 +1,6 @@ import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs" import { parsePatch } from "diff" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" -import type { FileDiffInfo } from "@opencode-ai/client/promise" type LegacyDiff = { file: string @@ -14,7 +13,7 @@ type LegacyDiff = { } type SnapshotDiff = SnapshotFileDiff & { file: string } -type ReviewDiff = SnapshotDiff | FileDiffInfo | VcsFileDiff | LegacyDiff +type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff export type DiffSource = Pick export type ViewDiff = { diff --git a/packages/session-ui/src/components/session-review.tsx b/packages/session-ui/src/components/session-review.tsx index 1585a8aa32..8db21f025b 100644 --- a/packages/session-ui/src/components/session-review.tsx +++ b/packages/session-ui/src/components/session-review.tsx @@ -16,7 +16,6 @@ import { checksum } from "@opencode-ai/core/util/encode" import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { type FileContent, type SnapshotFileDiff, type VcsFileDiff } from "@opencode-ai/sdk/v2" -import type { FileDiffInfo } from "@opencode-ai/client/promise" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { type SelectedLineRange } from "@pierre/diffs" import { Dynamic } from "solid-js/web" @@ -63,10 +62,10 @@ export type SessionReviewCommentActions = { export type SessionReviewFocus = { file: string; id: string } -type RawReviewDiff = (SnapshotFileDiff | FileDiffInfo | VcsFileDiff) & { +type RawReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } -type ReviewDiff = ((SnapshotFileDiff & { file: string }) | FileDiffInfo | VcsFileDiff) & { +type ReviewDiff = ((SnapshotFileDiff & { file: string }) | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult } diff --git a/packages/session-ui/src/components/session-turn.tsx b/packages/session-ui/src/components/session-turn.tsx index 301a74d3f0..75274fc50e 100644 --- a/packages/session-ui/src/components/session-turn.tsx +++ b/packages/session-ui/src/components/session-turn.tsx @@ -4,7 +4,6 @@ import { Message as MessageType, Part as PartType, } from "@opencode-ai/sdk/v2/client" -import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { SessionStatus } from "@opencode-ai/sdk/v2" import { useData } from "../context" import { useFileComponent } from "@opencode-ai/ui/context/file" @@ -91,7 +90,7 @@ function list(value: T[] | undefined | null, fallback: T[]) { return fallback } -type SummaryDiff = (SnapshotFileDiff & { file: string }) | FileDiffInfo +type SummaryDiff = SnapshotFileDiff & { file: string } function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff { return typeof value.file === "string" diff --git a/packages/session-ui/src/components/tool-error-card.tsx b/packages/session-ui/src/components/tool-error-card.tsx index 2251641857..35720a2753 100644 --- a/packages/session-ui/src/components/tool-error-card.tsx +++ b/packages/session-ui/src/components/tool-error-card.tsx @@ -16,7 +16,6 @@ export interface ToolErrorCardProps extends Omit, "c onOpenChange?: (open: boolean) => void subtitle?: string href?: string - onSubtitleClick?: (event: MouseEvent) => void } export function ToolErrorCard(props: ToolErrorCardProps) { @@ -36,7 +35,6 @@ export function ToolErrorCard(props: ToolErrorCardProps) { "onOpenChange", "subtitle", "href", - "onSubtitleClick", ]) const setOpen = (value: boolean) => { if (props.open === undefined) setState("open", value) @@ -53,8 +51,6 @@ export function ToolErrorCard(props: ToolErrorCardProps) { webfetch: "ui.tool.webfetch", websearch: "ui.tool.websearch", bash: "ui.tool.shell", - shell: "ui.tool.shell", - patch: "ui.tool.patch", apply_patch: "ui.tool.patch", question: "ui.tool.questions", } @@ -115,10 +111,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) { data-slot="basic-tool-tool-subtitle" class="clickable subagent-link" href={split.href!} - onClick={(event) => { - event.stopPropagation() - split.onSubtitleClick?.(event) - }} + onClick={(e) => e.stopPropagation()} > {subtitle()} diff --git a/packages/session-ui/src/context/data.tsx b/packages/session-ui/src/context/data.tsx index 76eacb3336..999ff510d5 100644 --- a/packages/session-ui/src/context/data.tsx +++ b/packages/session-ui/src/context/data.tsx @@ -1,5 +1,4 @@ import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, Provider } from "@opencode-ai/sdk/v2" -import type { FileDiffInfo } from "@opencode-ai/client/promise" import { createSimpleContext } from "@opencode-ai/ui/context" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -22,7 +21,7 @@ type Data = { [sessionID: string]: SessionStatus } session_diff: { - [sessionID: string]: (SnapshotFileDiff | FileDiffInfo)[] + [sessionID: string]: SnapshotFileDiff[] } session_diff_preload?: { [sessionID: string]: PreloadMultiFileDiffResult[] @@ -47,7 +46,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ init: (props: { data: Data directory: string - sessionID?: string onNavigateToSession?: NavigateToSessionFn onSessionHref?: SessionHrefFn }) => { @@ -58,9 +56,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ get directory() { return props.directory }, - get sessionID() { - return props.sessionID - }, navigateToSession: props.onNavigateToSession, sessionHref: props.onSessionHref, } diff --git a/packages/session-ui/src/v2/components/prompt-input/attachments.css b/packages/session-ui/src/v2/components/prompt-input/attachments.css deleted file mode 100644 index 3b8173d105..0000000000 --- a/packages/session-ui/src/v2/components/prompt-input/attachments.css +++ /dev/null @@ -1,44 +0,0 @@ -@keyframes prompt-input-v2-attachments-fade-left { - from { - visibility: hidden; - } - to { - visibility: visible; - } -} - -@keyframes prompt-input-v2-attachments-fade-right { - from { - visibility: visible; - } - to { - visibility: hidden; - } -} - -[data-component="prompt-input-v2-attachments"] { - timeline-scope: --prompt-input-v2-attachments-scroll; - - [data-slot^="prompt-attachments-fade-"] { - visibility: hidden; - } -} - -@supports (animation-timeline: --prompt-input-v2-attachments-scroll) and - (timeline-scope: --prompt-input-v2-attachments-scroll) { - [data-component="prompt-input-v2-attachments"] [data-slot="prompt-attachments-scroll"] { - scroll-timeline: --prompt-input-v2-attachments-scroll x; - } - - [data-component="prompt-input-v2-attachments"] [data-slot="prompt-attachments-fade-left"] { - animation: prompt-input-v2-attachments-fade-left linear both; - animation-timeline: --prompt-input-v2-attachments-scroll; - animation-range: 0 0.1px; - } - - [data-component="prompt-input-v2-attachments"] [data-slot="prompt-attachments-fade-right"] { - animation: prompt-input-v2-attachments-fade-right linear both; - animation-timeline: --prompt-input-v2-attachments-scroll; - animation-range: calc(100% - 1.1px) calc(100% - 1px); - } -} diff --git a/packages/session-ui/src/v2/components/prompt-input/attachments.ts b/packages/session-ui/src/v2/components/prompt-input/attachments.ts deleted file mode 100644 index 89f38ec4a3..0000000000 --- a/packages/session-ui/src/v2/components/prompt-input/attachments.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { onMount } from "solid-js" -import { makeEventListener } from "@solid-primitives/event-listener" -import type { PromptInputV2Attachment, PromptInputV2Prompt } from "./types" - -const accepted = [ - "image/png", - "image/jpeg", - "image/gif", - "image/webp", - "application/pdf", - "text/*", - "application/json", - "application/ld+json", - "application/toml", - "application/x-toml", - "application/x-yaml", - "application/xml", - "application/yaml", - ".c", - ".cc", - ".cjs", - ".conf", - ".cpp", - ".css", - ".csv", - ".cts", - ".env", - ".go", - ".gql", - ".graphql", - ".h", - ".hh", - ".hpp", - ".htm", - ".html", - ".ini", - ".java", - ".js", - ".json", - ".jsx", - ".log", - ".md", - ".mdx", - ".mjs", - ".mts", - ".py", - ".rb", - ".rs", - ".sass", - ".scss", - ".sh", - ".sql", - ".toml", - ".ts", - ".tsx", - ".txt", - ".xml", - ".yaml", - ".yml", - ".zsh", -] - -type PromptTarget = { - current: () => PromptInputV2Prompt - cursor: () => number | undefined - set: (prompt: PromptInputV2Prompt, cursor?: number) => void -} - -export type PromptInputV2AttachmentConfig = { - picker?: ( - options: { defaultPath?: string; multiple?: boolean; accept?: string[] }, - onFile: (file: File) => Promise, - ) => Promise - directory: () => string - isDialogActive: () => boolean - warn: () => void - duplicate: () => void - onError: (error: unknown) => void - readClipboardImage?: () => Promise - getPathForFile?: (file: File) => string - store?: (file: File) => Promise<{ id: string; url: string }> -} - -export function createPromptInputV2Attachments( - input: PromptInputV2AttachmentConfig & { - capture: () => PromptTarget - editor: () => HTMLElement | undefined - focusEditor: () => void - addPart: (part: PromptInputV2Prompt[number]) => boolean - setDraggingType: (type: "image" | "@mention" | null) => void - }, -) { - const capture = () => { - const prompt = input.capture() - const editor = input.editor() - if (!editor) return - return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) } - } - const add = async (file: File, toast = true, target = capture(), clipboard = false) => { - if (!target) return false - const mime = await attachmentMime(file) - if (!mime) { - if (toast) input.warn() - return false - } - const blob = input.store ? await input.store(file) : await blobReference(file) - const sourcePath = input.getPathForFile?.(file) || undefined - // Native clipboard images arrive with a fresh timestamped filename on every paste, so identical - // clipboard content is matched on bytes alone. - const duplicate = target.prompt - .current() - .some( - (part) => - part.type === "image" && - part.blob.id === blob.id && - (sourcePath - ? part.sourcePath === sourcePath - : !part.sourcePath && (clipboard || part.filename === file.name)), - ) - if (duplicate) { - input.duplicate() - return true - } - const attachment: PromptInputV2Attachment = { - type: "image", - id: globalThis.crypto?.randomUUID?.() ?? Math.random().toString(16).slice(2), - filename: file.name, - sourcePath, - mime, - blob, - } - target.prompt.set([...target.prompt.current(), attachment], target.cursor) - return true - } - const addAttachments = async (files: File[], toast = true, target = capture()) => { - const found = await files.reduce(async (result, file) => { - const previous = await result - return (await add(file, false, target)) || previous - }, Promise.resolve(false)) - if (!found && files.length > 0 && toast) input.warn() - return found - } - const handlePaste = async (event: ClipboardEvent) => { - const clipboardData = event.clipboardData - if (!clipboardData) return - const target = capture() - if (!target) return - event.preventDefault() - event.stopPropagation() - const files = Array.from(clipboardData.items).flatMap((item) => { - if (item.kind !== "file") return [] - const file = item.getAsFile() - return file ? [file] : [] - }) - if (files.length > 0) { - await addAttachments(files, true, target) - return - } - const plainText = clipboardData.getData("text/plain") ?? "" - if (input.readClipboardImage && !plainText) { - const file = await input.readClipboardImage() - if (file && (await add(file, true, target, true))) return - } - if (!plainText) return - const text = plainText.includes("\r") ? plainText.replace(/\r\n?/g, "\n") : plainText - const put = () => { - if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true - input.focusEditor() - return input.addPart({ type: "text", content: text, start: 0, end: 0 }) - } - if (text.includes("\n") || largePaste(text)) { - put() - return - } - if (typeof document.execCommand === "function" && document.execCommand("insertText", false, text)) return - put() - } - const handleDrop = async (event: DragEvent) => { - if (input.isDialogActive()) return - event.preventDefault() - input.setDraggingType(null) - const plainText = event.dataTransfer?.getData("text/plain") - if (plainText?.startsWith("file:")) { - const path = plainText.slice("file:".length) - input.focusEditor() - input.addPart({ type: "file", path, content: `@${path}`, start: 0, end: 0 }) - return - } - const files = event.dataTransfer?.files - if (files) await addAttachments(Array.from(files)) - } - - onMount(() => { - makeEventListener(document, "dragover", (event) => { - if (input.isDialogActive()) return - event.preventDefault() - if (event.dataTransfer?.types.includes("Files")) input.setDraggingType("image") - else if (event.dataTransfer?.types.includes("text/plain")) input.setDraggingType("@mention") - }) - makeEventListener(document, "dragleave", (event) => { - if (!input.isDialogActive() && !event.relatedTarget) input.setDraggingType(null) - }) - makeEventListener(document, "drop", handleDrop) - }) - - return { - addAttachments, - handlePaste, - handleDrop, - pick(fallback: () => void) { - if (!input.picker) { - fallback() - return - } - void input - .picker({ defaultPath: input.directory(), multiple: true, accept: accepted }, (file) => add(file)) - .catch(input.onError) - }, - } -} - -const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]) - -async function blobReference(file: File) { - const id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await file.arrayBuffer()))) - .map((byte) => byte.toString(16).padStart(2, "0")) - .join("") - return { id, url: URL.createObjectURL(file) } -} -const imageExtensions = new Map([ - ["gif", "image/gif"], - ["jpeg", "image/jpeg"], - ["jpg", "image/jpeg"], - ["png", "image/png"], - ["webp", "image/webp"], -]) -const textMimes = new Set([ - "application/json", - "application/ld+json", - "application/toml", - "application/x-toml", - "application/x-yaml", - "application/xml", - "application/yaml", -]) - -async function attachmentMime(file: File) { - const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? "" - if (imageMimes.has(type) || type === "application/pdf") return type - const index = file.name.lastIndexOf(".") - const suffix = index === -1 ? "" : file.name.slice(index + 1).toLowerCase() - const fallback = imageExtensions.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined) - if ((!type || type === "application/octet-stream") && fallback) return fallback - if (type.startsWith("text/") || textMimes.has(type) || type.endsWith("+json") || type.endsWith("+xml")) { - return "text/plain" - } - const bytes = new Uint8Array(await file.slice(0, 4096).arrayBuffer()) - if (bytes.some((byte) => byte === 0)) return - const control = bytes.filter((byte) => byte < 9 || (byte > 13 && byte < 32)).length - if (bytes.length > 0 && control / bytes.length > 0.3) return - return "text/plain" -} - -function cursorPosition(editor: HTMLElement) { - const selection = window.getSelection() - if (!selection || selection.rangeCount === 0) return 0 - const range = selection.getRangeAt(0) - if (!editor.contains(range.startContainer)) return 0 - const before = range.cloneRange() - before.selectNodeContents(editor) - before.setEnd(range.startContainer, range.startOffset) - return before.toString().replace(/\u200B/g, "").length -} - -function largePaste(text: string) { - if (text.length >= 8000) return true - return text.split("\n").length - 1 >= 120 -} diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx deleted file mode 100644 index d29fd4df23..0000000000 --- a/packages/session-ui/src/v2/components/prompt-input/index.tsx +++ /dev/null @@ -1,714 +0,0 @@ -import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js" -import { FileIcon } from "@opencode-ai/ui/file-icon" -import { Icon } from "@opencode-ai/ui/icon" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" -import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" -import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { AttachmentCardV2 } from "../attachment-card-v2" -import { CommentCardV2 } from "../comment-card-v2" -import { typeLabel } from "../../../components/message-file" -import type { - PromptInputV2Attachment, - PromptInputV2Comment, - PromptInputV2Option, - PromptInputV2PersistedState, - PromptInputV2Prompt, - PromptInputV2Suggestion, -} from "./types" -import type { PromptInputV2Interaction, PromptInputV2SelectControl } from "./interaction" -import "./attachments.css" - -export type { - PromptInputV2Attachment, - PromptInputV2Comment, - PromptInputV2Option, - PromptInputV2PersistedState, - PromptInputV2Suggestion, -} from "./types" - -export type PromptInputV2Mode = "normal" | "shell" - -export type PromptInputV2Props = { - controller: PromptInputV2Interaction - disabled?: boolean - readOnly?: boolean - borderUnderlay?: boolean - class?: string - modelControl?: JSX.Element - variantControlVisible?: boolean - attachKeybind?: string[] - attachShortcut?: string -} - -export function PromptInputV2(props: PromptInputV2Props) { - const state = props.controller.state - const view = props.controller.view - let editor: HTMLDivElement | undefined - let localInput = false - const updateCursor = () => { - if (!editor || !window.getSelection()?.isCollapsed) return - props.controller.onCursor(promptInputV2Cursor(editor)) - } - const mode = createMemo(() => state.mode) - const buttons = createMemo(() => ({ - opacity: mode() === "normal" ? 1 : 0, - "pointer-events": mode() === "normal" ? ("auto" as const) : ("none" as const), - transition: "opacity 200ms ease", - })) - - createEffect(() => { - const parts = props.controller.parts() - if (!editor) return - if (localInput) { - localInput = false - return - } - renderPromptInputV2Editor(editor, parts) - }) - - return ( -
- { - const list = event.currentTarget.files - if (list) props.controller.addAttachments(Array.from(list)) - event.currentTarget.value = "" - }} - /> - - props.controller.dispatch({ type: "popover.active", id: item.id })} - onSelect={(item) => props.controller.dispatch({ type: "popover.select", item })} - /> - -
{ - event.preventDefault() - if (!props.disabled) props.controller.submit() - }} - onDragEnter={props.controller.onDragEnter} - onDragOver={props.controller.onDragOver} - onDragLeave={props.controller.onDragLeave} - onDrop={props.controller.onDrop} - > - -
- Drop files to attach -
-
- - - props.controller.removeAttachment(attachment.id)} - onCommentClick={(comment) => props.controller.toggleContext(comment.key)} - onCommentRemove={(comment) => props.controller.removeContext(comment.key)} - /> - - -
-
{ - editor = element - props.controller.setEditor(element) - renderPromptInputV2Editor(element, props.controller.parts()) - }} - data-component="prompt-input" - role="textbox" - aria-multiline="true" - aria-label="Prompt" - contenteditable={!props.disabled && !props.readOnly} - autocapitalize={state.mode === "normal" ? "sentences" : "off"} - autocorrect={state.mode === "normal" ? "on" : "off"} - spellcheck={state.mode === "normal"} - // @ts-expect-error - autocomplete="off" - class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword" - classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }} - onInput={(event) => { - const cursor = promptInputV2Cursor(event.currentTarget) - const prompt = parsePromptInputV2Editor(event.currentTarget) - const images = props.controller.parts().filter((part) => part.type === "image") - localInput = true - props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor) - }} - onKeyDown={(event) => { - if (props.controller.onKeyDown(event)) return - if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { - event.preventDefault() - if (event.repeat) return - props.controller.submit() - } - }} - onKeyUp={updateCursor} - onPointerUp={updateCursor} - onPaste={props.controller.onPaste} - onFocus={() => props.controller.dispatch({ type: "focus.editor" })} - /> - -
- {view.placeholder?.() ?? - (state.mode === "shell" ? "Enter shell command..." : "Ask anything, / for commands, @ for context...")} -
-
-
- -
-
- - - {(control) => ( - - )} - - - {(control) => ( - - )} - - } - > - {props.modelControl} - - - {(control) => ( - 1}> - - - )} - -
- -
- -
- ) -} - -function renderPromptInputV2Editor(editor: HTMLDivElement, prompt: PromptInputV2Prompt) { - const active = document.activeElement === editor - editor.replaceChildren( - ...prompt.flatMap((part) => { - if (part.type === "image") return [] - if (part.type === "text") return [document.createTextNode(part.content)] - const mention = document.createElement("span") - mention.textContent = part.content - mention.contentEditable = "false" - mention.dataset.mention = - part.type === "file" && part.mime === "application/x-directory" ? "reference" : part.type - if (part.type === "agent") mention.dataset.name = part.name - if (part.type === "file") { - mention.dataset.path = part.path - if (part.mime) mention.dataset.mime = part.mime - if (part.filename) mention.dataset.filename = part.filename - } - return [mention] - }), - ) - if (!active) return - const selection = window.getSelection() - const range = document.createRange() - range.selectNodeContents(editor) - range.collapse(false) - selection?.removeAllRanges() - selection?.addRange(range) -} - -function parsePromptInputV2Editor(editor: HTMLDivElement) { - const parts: Exclude[] = [] - let buffer = "" - let position = 0 - - const flush = () => { - if (!buffer) return - parts.push({ type: "text", content: buffer, start: position, end: position + buffer.length }) - position += buffer.length - buffer = "" - } - const mention = (element: HTMLElement) => { - flush() - const content = element.textContent ?? "" - if (element.dataset.mention === "agent") { - parts.push({ - type: "agent", - name: element.dataset.name ?? content.slice(1), - content, - start: position, - end: position + content.length, - }) - position += content.length - return - } - parts.push({ - type: "file", - path: element.dataset.path ?? content.slice(1), - content, - start: position, - end: position + content.length, - ...(element.dataset.mime ? { mime: element.dataset.mime } : {}), - ...(element.dataset.filename ? { filename: element.dataset.filename } : {}), - }) - position += content.length - } - const visit = (node: Node) => { - if (node.nodeType === Node.TEXT_NODE) { - buffer += node.textContent ?? "" - return - } - if (!(node instanceof HTMLElement)) return - if (node.dataset.mention) { - mention(node) - return - } - if (node.tagName === "BR") { - buffer += "\n" - return - } - Array.from(node.childNodes).forEach(visit) - } - - Array.from(editor.childNodes).forEach((node, index, nodes) => { - visit(node) - if (node instanceof HTMLElement && ["DIV", "P"].includes(node.tagName) && index < nodes.length - 1) buffer += "\n" - }) - flush() - if ( - parts.every((part) => part.type === "text") && - parts.every((part) => part.content.replace(/[\n\u200B]/g, "") === "") - ) { - return [{ type: "text" as const, content: "", start: 0, end: 0 }] - } - if (parts.length > 0) return parts - return [{ type: "text" as const, content: "", start: 0, end: 0 }] -} - -function promptInputV2Cursor(editor: HTMLDivElement) { - const selection = window.getSelection() - if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0 - const range = selection.getRangeAt(0).cloneRange() - range.selectNodeContents(editor) - range.setEnd(selection.anchorNode!, selection.anchorOffset) - return range.toString().length -} - -export function PromptInputV2Attachments(props: { - attachments: PromptInputV2Attachment[] - comments?: PromptInputV2Comment[] - activeCommentID?: string - removeLabel: string - onAttachmentClick?: (attachment: PromptInputV2Attachment) => void - onAttachmentRemove: (attachment: PromptInputV2Attachment) => void - onCommentClick?: (comment: PromptInputV2Comment) => void - onCommentRemove?: (comment: PromptInputV2Comment) => void -}) { - return ( - 0 || (props.comments?.length ?? 0) > 0}> -
-
- - {(comment) => ( -
- - props.onCommentClick?.(comment)} - /> - - -
- )} -
- - {(attachment) => ( -
- - - {typeLabel(attachment.filename, attachment.mime)} - - } - > - {attachment.filename} props.onAttachmentClick?.(attachment)} - /> -
- - - -
- )} - -
-
-
-
- - ) -} - -export function PromptInputV2AddMenu(props: { - disabled?: boolean - title: string - keybind?: string[] - attachLabel: string - attachShortcut?: string - commandsLabel: string - contextLabel: string - shellLabel: string - onAttach: () => void - onCommands: () => void - onContext: () => void - onShell: () => void -}) { - return ( - - {props.title} - - - } - > - - } - variant="ghost-muted" - size="large" - disabled={props.disabled} - aria-label={props.title} - /> - - - - {props.attachLabel} - - - - {props.commandsLabel} - - - {props.contextLabel} - - - {props.shellLabel} - - - - - - ) -} - -function PromptInputV2ConfiguredSelect(props: { - title: string - keybind?: string[] - control: PromptInputV2SelectControl - model?: boolean -}) { - const current = () => props.control.current() - const providerID = () => props.control.options().find((option) => option.id === current())?.providerID - return ( - - - - } - onSelect={props.control.onSelect} - /> - ) -} - -export function PromptInputV2Select(props: { - title: string - keybind?: string[] - options: PromptInputV2Option[] - current: string - currentIcon?: JSX.Element - class?: string - onOpenChange?: (open: boolean) => void - onSelect: (id: string) => void -}) { - return ( - - {props.title} - - - } - > - - - {props.currentIcon} - - {props.options.find((option) => option.id === props.current)?.label ?? props.current} - - - - - - - - - - {(option) => ( - - {option.label} - - )} - - - - - - - ) -} - -export function PromptInputV2Popover(props: { - emptyLabel: string - items: PromptInputV2Suggestion[] - activeID?: string - search?: { - value: string - label: string - placeholder: string - onValueChange: (value: string) => void - onKeyDown: (event: KeyboardEvent) => void - } - onActiveChange: (item: PromptInputV2Suggestion) => void - onSelect: (item: PromptInputV2Suggestion) => void -}) { - return ( -
event.preventDefault()} - > - - {(search) => ( -
- requestAnimationFrame(() => element.focus())} - value={search().value} - aria-label={search().label} - placeholder={search().placeholder} - class="w-full bg-transparent text-[13px] leading-5 text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint" - onInput={(event) => search().onValueChange(event.currentTarget.value)} - onKeyDown={(event) => search().onKeyDown(event)} - onMouseDown={(event) => event.stopPropagation()} - /> -
- )} -
- 0} - fallback={
{props.emptyLabel}
} - > - - {(item) => ( - - )} - -
-
- ) -} - -export function PromptInputV2SubmitButton(props: { - mode: PromptInputV2Mode - stopping: boolean - disabled: boolean - sendLabel: string - stopLabel: string - onSubmit: () => void - onStop: () => void -}) { - return ( - - { - event.preventDefault() - event.stopPropagation() - if (props.stopping) { - props.onStop() - return - } - props.onSubmit() - }} - /> - - ) -} - -function PromptInputV2SuggestionIcon(props: { item: PromptInputV2Suggestion }) { - if (props.item.kind === "agent") return - if (props.item.kind === "command") return null - return ( - - ) -} diff --git a/packages/session-ui/src/v2/components/prompt-input/interaction.ts b/packages/session-ui/src/v2/components/prompt-input/interaction.ts deleted file mode 100644 index f4a9fa74b7..0000000000 --- a/packages/session-ui/src/v2/components/prompt-input/interaction.ts +++ /dev/null @@ -1,482 +0,0 @@ -import { createEffect, on, type Accessor } from "solid-js" -import { createStore, reconcile } from "solid-js/store" -import { useFilteredList } from "@opencode-ai/ui/hooks" -import { createPromptInputV2Attachments, type PromptInputV2AttachmentConfig } from "./attachments" -import { createPromptInputV2Store, type PromptInputV2StoreInput } from "./store" -import type { - PromptInputV2Attachment, - PromptInputV2Comment, - PromptInputV2History, - PromptInputV2HistoryEntry, - PromptInputV2Option, - PromptInputV2PersistedState, - PromptInputV2Suggestion, -} from "./types" -import { - createPromptInputV2InteractionState, - transitionPromptInputV2, - type PromptInputV2InteractionCommand, - type PromptInputV2InteractionEvent, -} from "./machine" - -export type PromptInputV2SelectControl = { - options: Accessor - current: Accessor - onSelect: (id: string) => void - keybind?: Accessor -} - -export type PromptInputV2ViewConfig = { - placeholder?: Accessor - add?: { - onAttach: () => void - } - agent?: PromptInputV2SelectControl - model?: PromptInputV2SelectControl - variant?: PromptInputV2SelectControl - submit: { - stopping: Accessor - working?: Accessor - onSubmit: () => void - onStop: () => void - } - shell?: { - onOpen: () => void - onClose: () => void - } - onKeyDown?: (event: KeyboardEvent) => void - onPaste?: (event: ClipboardEvent) => void - onDrop?: (event: DragEvent) => void -} - -export function createPromptInputV2State() { - return createStore(createPromptInputV2InteractionState()) -} - -export function createPromptInputV2Controller(input: { - store: PromptInputV2StoreInput - state?: ReturnType - identity?: Accessor - history?: PromptInputV2History - commands: Accessor - context: Accessor - searchContextFiles: (query: string) => PromptInputV2Suggestion[] | Promise - openAttachment?: (attachment: PromptInputV2Attachment) => void - openContext?: (key: string) => void - onContextRemove?: (item: PromptInputV2Comment) => void - onEditor?: (element: HTMLElement) => void - onSuggestionSelect?: (item: PromptInputV2Suggestion) => (() => void) | void - view: PromptInputV2ViewConfig - attachments?: PromptInputV2AttachmentConfig -}) { - let editor: HTMLElement | undefined - let fileInput: HTMLInputElement | undefined - const draft = createPromptInputV2Store(input.store) - const [state, setState] = input.state ?? createPromptInputV2State() - if (input.identity) { - createEffect(on(input.identity, () => setState(reconcile(createPromptInputV2InteractionState())), { defer: true })) - } - function addPart(part: PromptInputV2PersistedState["prompt"][number]) { - if (part.type === "image") return false - if (part.type === "file" || part.type === "agent") { - draft.addMention(part) - return true - } - draft.addText(part.content) - return true - } - const attachments = input.attachments - ? createPromptInputV2Attachments({ - ...input.attachments, - capture: () => ({ - current: () => draft.state.prompt, - cursor: () => draft.state.cursor, - set: draft.setPrompt, - }), - editor: () => editor, - focusEditor: () => editor?.focus(), - addPart, - setDraggingType: (type) => dispatch({ type: type ? "drag.enter" : "drag.leave" }), - }) - : undefined - const attach = () => { - if (!attachments) { - input.view.add?.onAttach() - return - } - attachments.pick(() => fileInput?.click()) - } - const contextList = useFilteredList({ - items: async (query) => { - const fixed = input.context().filter((item) => item.kind !== "file") - const recent = input.context().filter((item) => item.kind === "file" && item.recent) - if (!query.trim()) return [...fixed, ...recent] - const seen = new Set(recent.map((item) => item.id)) - const files = (await input.searchContextFiles(query)).filter((item) => !seen.has(item.id)) - return [...fixed, ...recent, ...files] - }, - key: (item) => item.id, - filterKeys: ["label"], - skipFilter: (item) => item.kind === "file" && !item.recent, - groupBy: (item) => { - if (item.kind === "reference") return "reference" - if (item.kind === "agent") return "agent" - if (item.kind === "resource") return "resource" - if (item.recent) return "recent" - return "file" - }, - sortGroupsBy: (a, b) => { - const order = ["reference", "agent", "resource", "recent", "file"] - return order.indexOf(a.category) - order.indexOf(b.category) - }, - }) - const commandList = useFilteredList({ - items: () => input.commands(), - key: (item) => item.id, - filterKeys: ["trigger", "title"], - }) - const list = () => (state.popover.type === "context" ? contextList : commandList) - const suggestions = () => list().flat() - - const execute = (command: PromptInputV2InteractionCommand) => { - if (command.type === "draft.setText") { - draft.setText(command.value) - return - } - if (command.type === "mention.add") { - if (command.item.mention) draft.addMention(command.item.mention) - return - } - if (command.type === "popover.filter") { - ;(command.popover === "command" ? commandList : contextList).onInput(command.query) - return - } - if (command.type === "suggestion.select") { - const item = suggestions().find((entry) => entry.id === command.id) - if (item) dispatch({ type: "popover.select", item }) - return - } - if (command.type === "focus.editor") requestAnimationFrame(() => editor?.focus()) - } - - function dispatch(event: PromptInputV2InteractionEvent) { - const mode = state.mode - const result = transitionPromptInputV2(state, event, draft.state) - const action = event.type === "popover.select" ? input.onSuggestionSelect?.(event.item) : undefined - if (event.type === "popover.select") { - if (!action || state.popover.type !== "command-menu") result.commands.forEach(execute) - if (action && event.item.kind === "command" && state.popover.type !== "command-menu") { - draft.setPrompt( - draft.state.prompt.filter((part): part is PromptInputV2Attachment => part.type === "image"), - 0, - ) - } - } - setState(reconcile(result.state)) - if (event.type !== "popover.select") result.commands.forEach(execute) - if (mode !== result.state.mode) { - if (result.state.mode === "shell") input.view.shell?.onOpen() - if (result.state.mode === "normal") input.view.shell?.onClose() - } - if (event.type === "popover.select") { - if (!action) return result.handled - action() - } - return result.handled - } - - const onKeyDown = (event: KeyboardEvent) => { - if ( - state.mode === "normal" && - (event.metaKey || event.ctrlKey) && - !event.altKey && - !event.shiftKey && - event.key.toLowerCase() === "u" - ) { - event.preventDefault() - attach() - return true - } - const handled = dispatch({ - type: "key.down", - key: event.key, - ctrl: event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey, - composing: event.isComposing, - ids: suggestions().map((item) => item.id), - empty: draft.state.prompt.every((part) => !("content" in part) || part.content.length === 0), - }) - if (handled) event.preventDefault() - if (handled && event.key !== "Enter" && event.key !== "Tab" && state.popover.type !== "closed") { - const activeID = state.popover.activeID ?? "" - requestAnimationFrame(() => - document.querySelector(`[data-suggestion-id="${CSS.escape(activeID)}"]`)?.scrollIntoView({ block: "nearest" }), - ) - } - if (handled) return true - const stop = - input.view.submit.working?.() && - ((event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "g") || - event.key === "Escape") - if (stop) { - event.preventDefault() - input.view.submit.onStop() - return true - } - if ( - !event.altKey && - !event.ctrlKey && - !event.metaKey && - (event.key === "ArrowUp" || event.key === "ArrowDown") && - navigateHistory(event.key === "ArrowUp" ? "up" : "down") - ) { - event.preventDefault() - return true - } - input.view.onKeyDown?.(event) - return event.defaultPrevented - } - - createEffect(() => { - if (state.popover.type === "closed") return - const ids = suggestions().map((item) => item.id) - if (state.popover.activeID ? ids.includes(state.popover.activeID) : ids.length === 0) return - dispatch({ type: "popover.results", ids }) - }) - - const restoreFocus = (cursor = draft.state.cursor ?? promptLength(draft.state.prompt)) => { - requestAnimationFrame(() => { - editor?.focus() - setEditorCursor(editor, cursor) - }) - } - - const applyHistory = (entry: PromptInputV2HistoryEntry, position: "start" | "end") => { - input.history?.restore?.(entry.metadata) - const cursor = position === "start" ? 0 : promptLength(entry.prompt) - draft.setPrompt(clonePrompt(entry.prompt), cursor) - restoreFocus(cursor) - } - const navigateHistory = (direction: "up" | "down") => { - if (!input.history || !editor) return false - const selection = window.getSelection() - if (!selection?.isCollapsed || !editor.contains(selection.anchorNode)) return false - const text = draft.state.prompt.map((part) => ("content" in part ? part.content : "")).join("") - if (!canNavigateHistory(direction, text, editorCursor(editor), state.historyIndex >= 0)) return false - const entries = input.history.entries(state.mode) - if (direction === "up") { - if (entries.length === 0 || state.historyIndex >= entries.length - 1) return false - if (state.historyIndex === -1) { - setState("savedHistory", { - prompt: clonePrompt(draft.state.prompt), - metadata: input.history.capture?.(), - }) - } - const index = state.historyIndex + 1 - setState("historyIndex", index) - applyHistory(entries[index]!, "start") - return true - } - if (state.historyIndex < 0) return false - if (state.historyIndex > 0) { - const index = state.historyIndex - 1 - setState("historyIndex", index) - applyHistory(entries[index]!, "end") - return true - } - const saved = state.savedHistory ?? { prompt: [{ type: "text", content: "", start: 0, end: 0 }] } - setState({ historyIndex: -1, savedHistory: undefined }) - applyHistory(saved, "end") - return true - } - - return { - state, - view: input.view, - suggestions, - dispatch, - onKeyDown, - value() { - return draft.state.prompt.map((part) => ("content" in part ? part.content : "")).join("") - }, - parts() { - return draft.state.prompt - }, - addPart, - contextItem(id: string) { - return draft.state.context.items.find((item) => item.key === id) - }, - comments() { - return draft.state.context.items.filter((item) => !!item.comment?.trim()) - }, - attachments(): PromptInputV2Attachment[] { - return draft.state.prompt.filter((part): part is PromptInputV2Attachment => part.type === "image") - }, - toggleContext(id: string) { - dispatch({ type: "context.active", id }) - input.openContext?.(id) - }, - removeContext(id: string) { - const item = draft.state.context.items.find((entry) => entry.key === id) - if (item) input.onContextRemove?.(item) - draft.removeContext(id) - if (state.activeContextID === id) dispatch({ type: "context.active", id }) - }, - openAttachment(attachment: PromptInputV2Attachment) { - input.openAttachment?.(attachment) - }, - removeAttachment(id: string) { - draft.removeAttachment(id) - }, - canSubmit() { - const persisted = draft.state - if (persisted.prompt.some((part) => part.type === "image")) return true - if (persisted.context.items.some((item) => !!item.comment?.trim())) return true - return persisted.prompt.some((part) => "content" in part && !!part.content.trim()) - }, - setEditor(element: HTMLElement) { - editor = element - input.onEditor?.(element) - }, - restoreFocus, - onInput(value: string, prompt?: PromptInputV2PersistedState["prompt"], cursor?: number) { - if (prompt) draft.setPrompt(prompt, cursor) - dispatch({ type: "input.changed", value, persist: !prompt }) - }, - onCursor(cursor: number) { - draft.setCursor(cursor) - }, - openCommands() { - dispatch({ type: "commands.open" }) - }, - openContext() { - dispatch({ type: "context.open" }) - }, - openShell() { - dispatch({ type: "mode.shell" }) - }, - closeShell() { - dispatch({ type: "mode.normal" }) - }, - submit() { - input.view.submit.onSubmit() - dispatch({ type: "popover.close" }) - }, - stop() { - input.view.submit.onStop() - }, - addHistory(prompt: PromptInputV2PersistedState["prompt"], mode: "normal" | "shell") { - input.history?.add(prompt, mode) - setState({ historyIndex: -1, savedHistory: undefined }) - }, - resetHistory() { - setState({ historyIndex: -1, savedHistory: undefined }) - }, - onPaste(event: ClipboardEvent) { - const clipboard = event.clipboardData - if ( - attachments && - (Array.from(clipboard?.items ?? []).some((item) => item.kind === "file") || !clipboard?.getData("text/plain")) - ) { - void attachments.handlePaste(event) - return - } - input.view.onPaste?.(event) - if (event.defaultPrevented) return - const text = clipboard?.getData("text/plain") - if (!text) return - event.preventDefault() - if (typeof document.execCommand === "function" && document.execCommand("insertText", false, text)) return - const target = event.currentTarget - const selection = window.getSelection() - if (!(target instanceof HTMLElement) || !selection?.rangeCount || !target.contains(selection.anchorNode)) return - const range = selection.getRangeAt(0) - range.deleteContents() - const node = document.createTextNode(text) - range.insertNode(node) - range.setStartAfter(node) - range.collapse(true) - selection.removeAllRanges() - selection.addRange(range) - target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: text })) - }, - onDragEnter(event: DragEvent) { - event.preventDefault() - dispatch({ type: "drag.enter" }) - }, - onDragOver(event: DragEvent) { - event.preventDefault() - }, - onDragLeave() { - dispatch({ type: "drag.leave" }) - }, - onDrop(event: DragEvent) { - event.preventDefault() - dispatch({ type: "drag.leave" }) - if (attachments) { - event.stopPropagation() - void attachments.handleDrop(event) - return - } - input.view.onDrop?.(event) - }, - attach, - setFileInput(element: HTMLInputElement) { - fileInput = element - }, - addAttachments(files: File[]) { - if (attachments) void attachments.addAttachments(files) - }, - setQuery(value: string) { - dispatch({ type: "popover.query", value }) - }, - } -} - -export type PromptInputV2Interaction = ReturnType - -function canNavigateHistory(direction: "up" | "down", text: string, cursor: number, inHistory: boolean) { - const position = Math.max(0, Math.min(cursor, text.length)) - if (inHistory) return position === 0 || position === text.length - if (direction === "up") return position === 0 && text.length === 0 - return position === text.length -} - -function clonePrompt(prompt: PromptInputV2PersistedState["prompt"]): PromptInputV2PersistedState["prompt"] { - return prompt.map((part) => - part.type === "file" ? { ...part, selection: part.selection ? { ...part.selection } : undefined } : { ...part }, - ) -} - -function promptLength(prompt: PromptInputV2PersistedState["prompt"]) { - return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0) -} - -function editorCursor(editor: HTMLElement) { - const selection = window.getSelection() - if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0 - const range = selection.getRangeAt(0).cloneRange() - range.selectNodeContents(editor) - range.setEnd(selection.anchorNode!, selection.anchorOffset) - return range.toString().length -} - -function setEditorCursor(editor: HTMLElement | undefined, cursor: number) { - if (!editor) return - const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) - let remaining = cursor - let node = walker.nextNode() - while (node) { - const length = node.textContent?.length ?? 0 - if (remaining <= length) { - const range = document.createRange() - range.setStart(node, remaining) - range.collapse(true) - const selection = window.getSelection() - selection?.removeAllRanges() - selection?.addRange(range) - return - } - remaining -= length - node = walker.nextNode() - } -} diff --git a/packages/session-ui/src/v2/components/prompt-input/machine.test.ts b/packages/session-ui/src/v2/components/prompt-input/machine.test.ts deleted file mode 100644 index e36982666e..0000000000 --- a/packages/session-ui/src/v2/components/prompt-input/machine.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { PromptInputV2PersistedState, PromptInputV2Suggestion } from "./types" -import { createPromptInputV2InteractionState, transitionPromptInputV2 } from "./machine" - -const command: PromptInputV2Suggestion = { - id: "review", - kind: "command", - label: "/review", -} - -function persisted(value = ""): PromptInputV2PersistedState { - return { - prompt: [{ type: "text", content: value, start: 0, end: value.length }], - cursor: value.length, - context: { items: [] }, - } -} - -describe("prompt input v2 interaction machine", () => { - test("opens inline commands only when slash is the entire prompt", () => { - const state = createPromptInputV2InteractionState() - const open = transitionPromptInputV2(state, { type: "input.changed", value: "/re" }, persisted()) - const closed = transitionPromptInputV2(state, { type: "input.changed", value: "explain /re" }, persisted()) - - expect(open.state.popover).toEqual({ type: "command-inline", query: "re" }) - expect(closed.state.popover).toEqual({ type: "closed" }) - }) - - test("completes nested slash command names", () => { - const open = transitionPromptInputV2( - createPromptInputV2InteractionState(), - { type: "input.changed", value: "/review/" }, - persisted(), - ) - const item = { ...command, label: "/review/nested" } - const selected = transitionPromptInputV2(open.state, { type: "popover.select", item }, persisted("/review/")) - - expect(open.state.popover).toEqual({ type: "command-inline", query: "review/" }) - expect(selected.commands).toContainEqual({ type: "draft.setText", value: "/review/nested " }) - }) - - test("opens context completion at the cursor", () => { - const value = "alpha @sr omega" - const input = persisted(value) - input.cursor = 9 - - const result = transitionPromptInputV2( - createPromptInputV2InteractionState(), - { type: "input.changed", value, persist: false }, - input, - ) - - expect(result.state.popover).toEqual({ type: "context", query: "sr" }) - }) - - test("enters shell mode from an initial exclamation mark", () => { - const result = transitionPromptInputV2( - createPromptInputV2InteractionState(), - { type: "input.changed", value: "!", persist: false }, - persisted("!"), - ) - - expect(result.state.mode).toBe("shell") - expect(result.commands).toContainEqual({ type: "draft.setText", value: "" }) - }) - - test("leaves shell mode with escape", () => { - const state = { ...createPromptInputV2InteractionState(), mode: "shell" as const } - const result = transitionPromptInputV2( - state, - { type: "key.down", key: "Escape", ctrl: false, composing: false, ids: [] }, - persisted(), - ) - - expect(result.state.mode).toBe("normal") - expect(result.handled).toBeTrue() - }) - - test("leaves shell mode with backspace when empty", () => { - const state = { ...createPromptInputV2InteractionState(), mode: "shell" as const } - const result = transitionPromptInputV2( - state, - { type: "key.down", key: "Backspace", ctrl: false, composing: false, ids: [], empty: true }, - persisted(), - ) - - expect(result.state.mode).toBe("normal") - expect(result.handled).toBeTrue() - }) - - test("closes a popover with ctrl-g before stopping a run", () => { - const state = { - ...createPromptInputV2InteractionState(), - popover: { type: "context" as const, query: "", activeID: "first" }, - } - const result = transitionPromptInputV2( - state, - { type: "key.down", key: "g", ctrl: true, composing: false, ids: ["first"] }, - persisted(), - ) - - expect(result.state.popover).toEqual({ type: "closed" }) - expect(result.handled).toBeTrue() - }) - - test("opens the searchable command menu for a populated draft", () => { - const result = transitionPromptInputV2( - createPromptInputV2InteractionState(), - { type: "commands.open" }, - persisted("existing text"), - ) - - expect(result.state.popover).toEqual({ type: "command-menu", query: "" }) - expect(result.state.focus).toBe("command-search") - }) - - test("prepends a menu command and preserves existing text as arguments", () => { - const open = transitionPromptInputV2( - createPromptInputV2InteractionState(), - { type: "commands.open" }, - persisted("existing text"), - ) - const selected = transitionPromptInputV2( - open.state, - { type: "popover.select", item: command }, - persisted("existing text"), - ) - - expect(selected.commands).toContainEqual({ type: "draft.setText", value: "/review existing text" }) - expect(selected.state.popover).toEqual({ type: "closed" }) - }) - - test("stores selected context files as prompt file parts", () => { - const item: PromptInputV2Suggestion = { - id: "src/index.ts", - kind: "file", - label: "index.ts", - path: "src/index.ts", - } - const state = { - ...createPromptInputV2InteractionState(), - popover: { type: "context" as const, query: "index" }, - } - - const selected = transitionPromptInputV2(state, { type: "popover.select", item }, persisted("@index")) - - expect(selected.commands).toContainEqual({ type: "mention.add", item }) - }) - - test("loops active popover items with arrow keys", () => { - const state = { - ...createPromptInputV2InteractionState(), - popover: { type: "context" as const, query: "", activeID: "second" }, - } - const result = transitionPromptInputV2( - state, - { type: "key.down", key: "ArrowDown", ctrl: false, composing: false, ids: ["first", "second"] }, - persisted(), - ) - - expect(result.state.popover).toEqual({ type: "context", query: "", activeID: "first" }) - expect(result.handled).toBeTrue() - }) -}) diff --git a/packages/session-ui/src/v2/components/prompt-input/machine.ts b/packages/session-ui/src/v2/components/prompt-input/machine.ts deleted file mode 100644 index d2508e249c..0000000000 --- a/packages/session-ui/src/v2/components/prompt-input/machine.ts +++ /dev/null @@ -1,261 +0,0 @@ -import type { PromptInputV2HistoryEntry, PromptInputV2PersistedState, PromptInputV2Suggestion } from "./types" - -export type PromptInputV2InteractionState = { - mode: "normal" | "shell" - popover: - | { type: "closed" } - | { type: "context"; query: string; activeID?: string } - | { type: "command-inline"; query: string; activeID?: string } - | { type: "command-menu"; query: string; activeID?: string } - drag: "idle" | "active" - focus: "editor" | "command-search" | "external" - activeContextID?: string - historyIndex: number - savedHistory?: PromptInputV2HistoryEntry -} - -export type PromptInputV2InteractionEvent = - | { type: "input.changed"; value: string; persist?: boolean } - | { type: "commands.open" } - | { type: "context.open" } - | { type: "popover.query"; value: string } - | { type: "popover.results"; ids: string[] } - | { type: "popover.active"; id: string } - | { type: "popover.close" } - | { type: "popover.select"; item: PromptInputV2Suggestion } - | { type: "key.down"; key: string; ctrl: boolean; composing: boolean; ids: string[]; empty?: boolean } - | { type: "mode.shell" } - | { type: "mode.normal" } - | { type: "drag.enter" } - | { type: "drag.leave" } - | { type: "focus.editor" } - | { type: "focus.external" } - | { type: "context.active"; id: string } - -export type PromptInputV2InteractionCommand = - | { type: "draft.setText"; value: string } - | { type: "mention.add"; item: PromptInputV2Suggestion } - | { type: "popover.filter"; popover: "command" | "context"; query: string } - | { type: "suggestion.select"; id: string } - | { type: "focus.editor" } - | { type: "focus.command-search" } - -export type PromptInputV2Transition = { - state: PromptInputV2InteractionState - commands: PromptInputV2InteractionCommand[] - handled: boolean -} - -export function createPromptInputV2InteractionState(): PromptInputV2InteractionState { - return { - mode: "normal", - popover: { type: "closed" }, - drag: "idle", - focus: "external", - historyIndex: -1, - } -} - -export function transitionPromptInputV2( - state: PromptInputV2InteractionState, - event: PromptInputV2InteractionEvent, - persisted: PromptInputV2PersistedState, -): PromptInputV2Transition { - if (event.type === "input.changed") return inputChanged(state, event.value, event.persist !== false, persisted.cursor) - if (event.type === "commands.open") return openCommands(state, persisted) - if (event.type === "context.open") return openContext(state, persisted) - if (event.type === "popover.query") return queryChanged(state, event.value) - if (event.type === "popover.results") return resultsChanged(state, event.ids) - if (event.type === "popover.active") return activeChanged(state, event.id) - if (event.type === "popover.close") return changed({ ...state, popover: { type: "closed" } }) - if (event.type === "popover.select") return suggestionSelected(state, event.item, persisted) - if (event.type === "key.down") return keyDown(state, event) - if (event.type === "mode.shell") return changed({ ...state, mode: "shell", popover: { type: "closed" } }) - if (event.type === "mode.normal") return changed({ ...state, mode: "normal" }) - if (event.type === "drag.enter") return changed({ ...state, drag: "active" }) - if (event.type === "drag.leave") return changed({ ...state, drag: "idle" }) - if (event.type === "focus.editor") return changed({ ...state, focus: "editor" }) - if (event.type === "context.active") { - return changed({ ...state, activeContextID: state.activeContextID === event.id ? undefined : event.id }) - } - return changed({ ...state, focus: "external" }) -} - -function inputChanged( - state: PromptInputV2InteractionState, - value: string, - persist: boolean, - cursor: number | undefined, -): PromptInputV2Transition { - const setText: PromptInputV2InteractionCommand[] = persist ? [{ type: "draft.setText", value }] : [] - if (state.mode === "normal" && value === "!") { - return changed({ ...state, mode: "shell", popover: { type: "closed" }, focus: "editor" }, [ - { type: "draft.setText", value: "" }, - ]) - } - const context = value.slice(0, cursor ?? value.length).match(/(?:^|\s)@([^\s@]*)$/) - if (context) { - const query = context[1] ?? "" - return changed({ ...state, popover: { type: "context", query }, focus: "editor" }, [ - ...setText, - { type: "popover.filter", popover: "context", query }, - ]) - } - - const command = value.match(/^\/(\S*)$/) - if (command) { - const query = command[1] ?? "" - return changed({ ...state, popover: { type: "command-inline", query }, focus: "editor" }, [ - ...setText, - { type: "popover.filter", popover: "command", query }, - ]) - } - - return changed( - { ...state, popover: state.popover.type === "command-menu" ? state.popover : { type: "closed" }, focus: "editor" }, - setText, - ) -} - -function openCommands( - state: PromptInputV2InteractionState, - persisted: PromptInputV2PersistedState, -): PromptInputV2Transition { - if (!populated(persisted)) { - return changed({ ...state, popover: { type: "command-inline", query: "" }, focus: "editor" }, [ - { type: "draft.setText", value: promptText(persisted) + "/" }, - { type: "popover.filter", popover: "command", query: "" }, - { type: "focus.editor" }, - ]) - } - return changed({ ...state, popover: { type: "command-menu", query: "" }, focus: "command-search" }, [ - { type: "popover.filter", popover: "command", query: "" }, - { type: "focus.command-search" }, - ]) -} - -function openContext( - state: PromptInputV2InteractionState, - persisted: PromptInputV2PersistedState, -): PromptInputV2Transition { - return changed({ ...state, popover: { type: "context", query: "" }, focus: "editor" }, [ - { type: "draft.setText", value: promptText(persisted) + "@" }, - { type: "popover.filter", popover: "context", query: "" }, - { type: "focus.editor" }, - ]) -} - -function queryChanged(state: PromptInputV2InteractionState, query: string): PromptInputV2Transition { - if (state.popover.type === "closed") return unchanged(state) - const popover = state.popover.type === "context" ? "context" : "command" - return changed({ ...state, popover: { ...state.popover, query, activeID: undefined } }, [ - { type: "popover.filter", popover, query }, - ]) -} - -function resultsChanged(state: PromptInputV2InteractionState, ids: string[]): PromptInputV2Transition { - if (state.popover.type === "closed") return unchanged(state) - const activeID = state.popover.activeID && ids.includes(state.popover.activeID) ? state.popover.activeID : ids[0] - if (activeID === state.popover.activeID) return unchanged(state) - return changed({ ...state, popover: { ...state.popover, activeID } }) -} - -function activeChanged(state: PromptInputV2InteractionState, id: string): PromptInputV2Transition { - if (state.popover.type === "closed" || state.popover.activeID === id) return unchanged(state) - return changed({ ...state, popover: { ...state.popover, activeID: id } }) -} - -function suggestionSelected( - state: PromptInputV2InteractionState, - item: PromptInputV2Suggestion, - persisted: PromptInputV2PersistedState, -): PromptInputV2Transition { - const current = promptText(persisted) - const commands: PromptInputV2InteractionCommand[] = [] - if (item.kind === "command") { - commands.push({ - type: "draft.setText", - value: - state.popover.type === "command-menu" - ? current.trim() - ? `${item.label} ${current.trim()}` - : `${item.label} ` - : replaceTrigger(current, "/", `${item.label} `), - }) - } else { - commands.push({ type: "mention.add", item }) - } - commands.push({ type: "focus.editor" }) - return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, commands) -} - -function keyDown( - state: PromptInputV2InteractionState, - event: Extract, -): PromptInputV2Transition { - if (event.ctrl && event.key.toLowerCase() === "g") { - if (state.popover.type === "closed") return unchanged(state) - return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, [{ type: "focus.editor" }], true) - } - if (state.popover.type === "closed") { - if (state.mode === "shell" && (event.key === "Escape" || (event.key === "Backspace" && event.empty))) { - return changed({ ...state, mode: "normal" }, [], true) - } - return unchanged(state) - } - if (event.key === "Escape") { - return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, [{ type: "focus.editor" }], true) - } - if (event.key === "Tab" || (event.key === "Enter" && !event.composing)) { - if (!state.popover.activeID) return unchanged(state, true) - return unchanged(state, true, [{ type: "suggestion.select", id: state.popover.activeID }]) - } - const direction = - event.key === "ArrowDown" || (event.ctrl && event.key === "n") - ? 1 - : event.key === "ArrowUp" || (event.ctrl && event.key === "p") - ? -1 - : 0 - if (!direction || event.ids.length === 0) return unchanged(state) - const current = state.popover.activeID ? event.ids.indexOf(state.popover.activeID) : -1 - const index = - current < 0 - ? direction === 1 - ? 0 - : event.ids.length - 1 - : (current + direction + event.ids.length) % event.ids.length - return changed({ ...state, popover: { ...state.popover, activeID: event.ids[index] } }, [], true) -} - -function promptText(persisted: PromptInputV2PersistedState) { - return persisted.prompt.map((part) => (part.type === "text" ? part.content : "")).join("") -} - -function populated(persisted: PromptInputV2PersistedState) { - return ( - !!promptText(persisted).trim() || - persisted.context.items.length > 0 || - persisted.prompt.some((part) => part.type === "file" || part.type === "image") - ) -} - -function replaceTrigger(value: string, trigger: "@" | "/", replacement: string) { - const index = trigger === "/" ? value.indexOf(trigger) : value.lastIndexOf(trigger) - return index < 0 ? replacement : value.slice(0, index) + replacement -} - -function changed( - state: PromptInputV2InteractionState, - commands: PromptInputV2InteractionCommand[] = [], - handled = false, -): PromptInputV2Transition { - return { state, commands, handled } -} - -function unchanged( - state: PromptInputV2InteractionState, - handled = false, - commands: PromptInputV2InteractionCommand[] = [], -): PromptInputV2Transition { - return { state, commands, handled } -} diff --git a/packages/session-ui/src/v2/components/prompt-input/prompt-input.stories.tsx b/packages/session-ui/src/v2/components/prompt-input/prompt-input.stories.tsx deleted file mode 100644 index 6c535b0284..0000000000 --- a/packages/session-ui/src/v2/components/prompt-input/prompt-input.stories.tsx +++ /dev/null @@ -1,221 +0,0 @@ -// @ts-nocheck -import { createStore } from "solid-js/store" -import { PromptInputV2, type PromptInputV2PersistedState, type PromptInputV2Suggestion } from "." -import { createPromptInputV2Controller } from "./interaction" -import { createPromptInputV2Store } from "./store" -import { createEffect } from "solid-js" - -const agents = [ - { id: "build", label: "Build" }, - { id: "plan", label: "Plan" }, - { id: "review", label: "Review" }, -] - -const variants = [ - { id: "default", label: "Default" }, - { id: "fast", label: "Fast" }, - { id: "thinking", label: "Thinking" }, -] - -const models = [ - { id: "claude-sonnet", name: "Claude Sonnet", providerID: "anthropic" }, - { id: "gpt-5", name: "GPT-5", providerID: "openai" }, - { id: "gemini-pro", name: "Gemini Pro", providerID: "google" }, -] - -const contextSuggestions: PromptInputV2Suggestion[] = [ - { - id: "file-prompt", - kind: "file", - label: "prompt-input-v2.tsx", - path: "src/components/prompt-input-v2.tsx", - recent: true, - mention: { - type: "file", - path: "src/components/prompt-input-v2.tsx", - content: "@src/components/prompt-input-v2.tsx", - start: 0, - end: 0, - }, - }, - { - id: "file-story", - kind: "file", - label: "prompt-input-v2.stories.tsx", - path: "src/components/prompt-input-v2.stories.tsx", - mention: { - type: "file", - path: "src/components/prompt-input-v2.stories.tsx", - content: "@src/components/prompt-input-v2.stories.tsx", - start: 0, - end: 0, - }, - }, - { - id: "agent-review", - kind: "agent", - label: "@review", - description: "Ask the review agent", - mention: { type: "agent", name: "review", content: "@review", start: 0, end: 0 }, - }, - { - id: "reference-docs", - kind: "reference", - label: "@UI guidelines", - path: "docs/ui.md", - description: "Project reference", - mention: { - type: "file", - path: "docs/ui.md", - content: "@UI guidelines", - start: 0, - end: 0, - mime: "application/x-directory", - filename: "UI guidelines", - }, - }, -] - -const commandSuggestions: PromptInputV2Suggestion[] = [ - { - id: "command-fix", - kind: "command", - label: "/fix", - trigger: "fix", - title: "Fix", - description: "Fix the current issue", - keybind: ["Enter"], - }, - { - id: "command-review", - kind: "command", - label: "/review", - trigger: "review", - title: "Review", - description: "Review pending changes", - }, - { - id: "command-test", - kind: "command", - label: "/test", - trigger: "test", - title: "Test", - description: "Run relevant tests", - }, -] - -function ControlledPromptInput() { - // Agent choice is a persisted user/workspace preference in v1, not part of PromptStore. - const [preferences, setPreferences] = createStore({ agent: "build" }) - - const [runtime, setRuntime] = createStore({ - stopping: false, - }) - - // This matches the v1 PromptStore and can use the same persistence boundary. - const state = createStore({ - prompt: [ - { type: "text", content: "", start: 0, end: 0 }, - { - type: "image", - id: "attachment-1", - filename: "requirements.md", - mime: "text/markdown", - blob: { id: "requirements", url: "data:text/markdown;base64,IyBSZXF1aXJlbWVudHM=" }, - }, - ], - cursor: 0, - model: { providerID: "anthropic", modelID: "claude-sonnet", variant: null }, - context: { - items: [ - { - key: "file:src/components/prompt-input-v2.tsx:1:40", - type: "file", - path: "src/components/prompt-input-v2.tsx", - selection: { startLine: 1, startChar: 0, endLine: 40, endChar: 0 }, - comment: "Keep this component context-free", - }, - ], - }, - }) - const store = createPromptInputV2Store(state) - - const controller = createPromptInputV2Controller({ - store: state, - commands: () => commandSuggestions, - context: () => contextSuggestions, - searchContextFiles: (query) => { - const needle = query.trim().toLowerCase() - return contextSuggestions.filter( - (item) => item.kind === "file" && `${item.label} ${item.path ?? ""}`.toLowerCase().includes(needle), - ) - }, - view: { - add: { - onAttach: () => addAttachment("architecture.txt", "text/plain"), - }, - agent: { - options: () => agents, - current: () => preferences.agent, - onSelect: (agent) => setPreferences("agent", agent), - }, - model: { - options: () => models.map((model) => ({ id: model.id, label: model.name, providerID: model.providerID })), - current: () => - models.find( - (model) => model.id === store.state.model?.modelID && model.providerID === store.state.model?.providerID, - )?.id ?? "", - onSelect(id) { - const model = models.find((item) => item.id === id) - if (!model) return - store.setModel({ - providerID: model.providerID, - modelID: model.id, - variant: store.state.model?.variant, - }) - }, - }, - variant: { - options: () => variants, - current: () => store.state.model?.variant ?? "default", - onSelect: (variant) => store.setVariant(variant === "default" ? null : variant), - }, - submit: { - stopping: () => runtime.stopping, - working: () => runtime.stopping, - onSubmit: () => { - store.reset() - setRuntime("stopping", true) - }, - onStop: () => setRuntime("stopping", false), - }, - onDrop: (event) => addAttachment(event.dataTransfer?.files[0]?.name ?? "dropped-file.txt", "text/plain"), - }, - }) - - const addAttachment = (filename: string, mime: string) => { - store.addAttachment({ - type: "image", - id: `attachment-${store.state.prompt.filter((part) => part.type === "image").length + 1}`, - filename, - mime, - blob: { id: filename, url: `data:${mime};base64,` }, - }) - } - - return ( -
- -
- ) -} - -export default { - title: "Session UI/PromptInputV2", - id: "session-ui-prompt-input-v2", - component: PromptInputV2, -} - -export const ControlledComposition = { - render: () => , -} diff --git a/packages/session-ui/src/v2/components/prompt-input/store.test.ts b/packages/session-ui/src/v2/components/prompt-input/store.test.ts deleted file mode 100644 index 768a222450..0000000000 --- a/packages/session-ui/src/v2/components/prompt-input/store.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { createStore } from "solid-js/store" -import type { PromptInputV2PersistedState } from "./types" -import { createPromptInputV2Store } from "./store" - -function createPromptStore() { - return createPromptInputV2Store( - createStore({ - prompt: [ - { type: "text", content: "old", start: 0, end: 3 }, - { - type: "image", - id: "attachment-1", - filename: "notes.txt", - mime: "text/plain", - blob: { id: "a", url: "blob:a" }, - }, - ], - cursor: 3, - model: { providerID: "anthropic", modelID: "claude-sonnet", variant: null }, - context: { items: [] }, - }), - ) -} - -describe("prompt input v2 store", () => { - test("accepts an accessor for the backing store", () => { - const [state, setState] = createStore({ - prompt: [{ type: "text", content: "", start: 0, end: 0 }], - cursor: 0, - context: { items: [] }, - }) - const prompt = createPromptInputV2Store([() => state, setState]) - - prompt.setText("accessed") - - expect(prompt.state.prompt).toEqual([{ type: "text", content: "accessed", start: 0, end: 8 }]) - expect(prompt.state.cursor).toBe(8) - }) - - test("updates prompt text and cursor together while preserving attachments", () => { - const prompt = createPromptStore() - - prompt.setText("updated") - - expect(prompt.state.prompt).toEqual([ - { type: "text", content: "updated", start: 0, end: 7 }, - { - type: "image", - id: "attachment-1", - filename: "notes.txt", - mime: "text/plain", - blob: { id: "a", url: "blob:a" }, - }, - ]) - expect(prompt.state.cursor).toBe(7) - }) - - test("inserts text without flattening structured mentions", () => { - const [state, setState] = createStore({ - prompt: [ - { type: "text", content: "A ", start: 0, end: 2 }, - { type: "file", path: "one", content: "@one", start: 2, end: 6 }, - { type: "text", content: " B", start: 6, end: 8 }, - ], - cursor: 2, - context: { items: [] }, - }) - const prompt = createPromptInputV2Store([state, setState]) - - prompt.addText("X\nY") - - expect(prompt.state.prompt).toEqual([ - { type: "text", content: "A X\nY", start: 0, end: 5 }, - { type: "file", path: "one", content: "@one", start: 5, end: 9 }, - { type: "text", content: " B", start: 9, end: 11 }, - ]) - expect(prompt.state.cursor).toBe(5) - }) - - test("mutates context, attachments, and model through shared actions", () => { - const prompt = createPromptStore() - const context = { key: "file:src/index.ts", type: "file" as const, path: "src/index.ts" } - - prompt.addContext(context) - prompt.addContext(context) - prompt.addMention({ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 0 }) - prompt.removeAttachment("attachment-1") - prompt.setVariant("thinking") - - expect(prompt.state.context.items).toEqual([context]) - expect(prompt.state.prompt).toEqual([ - { type: "text", content: "old", start: 0, end: 3 }, - { type: "file", path: "src/app.ts", content: "@src/app.ts", start: 3, end: 14 }, - { type: "text", content: " ", start: 14, end: 15 }, - ]) - expect(prompt.state.model?.variant).toBe("thinking") - - prompt.removeContext(context.key) - prompt.setPrompt([{ type: "text", content: "old", start: 0, end: 3 }], 3) - prompt.setModel(undefined) - - expect(prompt.state.context.items).toEqual([]) - expect(prompt.state.prompt).toEqual([{ type: "text", content: "old", start: 0, end: 3 }]) - expect(prompt.state.model).toBeUndefined() - }) - - test("resets the prompt and cursor", () => { - const prompt = createPromptStore() - - prompt.reset() - - expect(prompt.state.prompt).toEqual([{ type: "text", content: "", start: 0, end: 0 }]) - expect(prompt.state.cursor).toBe(0) - }) -}) diff --git a/packages/session-ui/src/v2/components/prompt-input/store.ts b/packages/session-ui/src/v2/components/prompt-input/store.ts deleted file mode 100644 index 5a27de6c4d..0000000000 --- a/packages/session-ui/src/v2/components/prompt-input/store.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { batch, type Accessor } from "solid-js" -import type { SetStoreFunction, Store } from "solid-js/store" -import type { - PromptInputV2AgentPart, - PromptInputV2Attachment, - PromptInputV2Comment, - PromptInputV2FilePart, - PromptInputV2Model, - PromptInputV2PersistedState, - PromptInputV2Prompt, -} from "./types" - -export type PromptInputV2StoreTuple = [ - Store | Accessor>, - SetStoreFunction, -] - -export type PromptInputV2StoreInput = PromptInputV2StoreTuple | Accessor - -export function createPromptInputV2Store(input: PromptInputV2StoreInput) { - const tuple = () => (typeof input === "function" ? input() : input) - const store = () => { - const value = tuple()[0] - return typeof value === "function" ? value() : value - } - const setStore = () => tuple()[1] - - return { - get state() { - return store() - }, - setPrompt(prompt: PromptInputV2Prompt, cursor?: number) { - batch(() => { - setStore()("prompt", prompt) - if (cursor !== undefined) setStore()("cursor", cursor) - }) - }, - setCursor(cursor: number) { - setStore()("cursor", cursor) - }, - setText(content: string) { - batch(() => { - setStore()("prompt", (prompt) => [ - { type: "text", content, start: 0, end: content.length }, - ...prompt.filter((part) => part.type !== "text"), - ]) - setStore()("cursor", content.length) - }) - }, - addText(content: string) { - const cursor = store().cursor ?? promptLength(store().prompt) - batch(() => { - setStore()("prompt", (prompt) => insertText(prompt, cursor, content)) - setStore()("cursor", cursor + content.length) - }) - }, - reset() { - batch(() => { - setStore()("prompt", [{ type: "text", content: "", start: 0, end: 0 }]) - setStore()("cursor", 0) - }) - }, - setModel(model: PromptInputV2Model | undefined) { - setStore()("model", model) - }, - setVariant(variant: string | null) { - if (store().model) setStore()("model", "variant", variant) - }, - addContext(item: PromptInputV2Comment) { - if (store().context.items.some((entry) => entry.key === item.key)) return - setStore()("context", "items", (items) => [...items, item]) - }, - removeContext(key: string) { - setStore()("context", "items", (items) => items.filter((item) => item.key !== key)) - }, - addMention(mention: PromptInputV2FilePart | PromptInputV2AgentPart) { - const text = store() - .prompt.map((part) => ("content" in part ? part.content : "")) - .join("") - const end = store().cursor ?? text.length - const start = text.slice(0, end).lastIndexOf("@") - setStore()("prompt", insertMention(store().prompt, start < 0 ? end : start, end, mention)) - setStore()("cursor", (start < 0 ? end : start) + mention.content.length + 1) - }, - addAttachment(attachment: PromptInputV2Attachment) { - setStore()("prompt", (prompt) => [...prompt, attachment]) - }, - removeAttachment(id: string) { - setStore()("prompt", (parts) => parts.filter((part) => part.type !== "image" || part.id !== id)) - }, - } -} - -export type PromptInputV2Store = ReturnType - -function insertText(prompt: PromptInputV2Prompt, cursor: number, content: string): PromptInputV2Prompt { - let position = 0 - let inserted = false - const parts = prompt.flatMap((part) => { - if (part.type === "image") return [part] - const start = position - position += part.content.length - if (inserted) return [part] - if (part.type === "text" && cursor >= start && cursor <= position) { - inserted = true - const offset = cursor - start - return [{ ...part, content: part.content.slice(0, offset) + content + part.content.slice(offset) }] - } - if (cursor > start) return [part] - inserted = true - return [{ type: "text", content, start: 0, end: 0 }, part] - }) - if (!inserted) parts.push({ type: "text", content, start: 0, end: 0 }) - return withOffsets(parts) -} - -function insertMention( - prompt: PromptInputV2Prompt, - start: number, - end: number, - mention: PromptInputV2FilePart | PromptInputV2AgentPart, -): PromptInputV2Prompt { - let position = 0 - const parts = prompt.flatMap((part) => { - if (part.type === "image") return [part] - const partStart = position - position += part.content.length - if (part.type !== "text" || start < partStart || end > position) return [part] - const before = part.content.slice(0, start - partStart) - const after = part.content.slice(end - partStart) - return [ - ...(before ? [{ type: "text" as const, content: before, start: 0, end: 0 }] : []), - mention, - { type: "text" as const, content: ` ${after}`, start: 0, end: 0 }, - ] - }) - return withOffsets(parts) -} - -function withOffsets(prompt: PromptInputV2Prompt): PromptInputV2Prompt { - let offset = 0 - return prompt.map((part) => { - if (part.type === "image") return part - const next = { ...part, start: offset, end: offset + part.content.length } - offset = next.end - return next - }) -} - -function promptLength(prompt: PromptInputV2Prompt) { - return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0) -} diff --git a/packages/session-ui/src/v2/components/prompt-input/types.ts b/packages/session-ui/src/v2/components/prompt-input/types.ts deleted file mode 100644 index c6b47a92ce..0000000000 --- a/packages/session-ui/src/v2/components/prompt-input/types.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { FilePartSource } from "@opencode-ai/sdk/v2/client" - -type PromptInputV2PartBase = { - content: string - start: number - end: number -} - -export type PromptInputV2TextPart = PromptInputV2PartBase & { - type: "text" -} - -export type PromptInputV2FilePart = PromptInputV2PartBase & { - type: "file" - path: string - selection?: PromptInputV2Selection - mime?: string - filename?: string - url?: string - source?: FilePartSource -} - -export type PromptInputV2AgentPart = PromptInputV2PartBase & { - type: "agent" - name: string -} - -export type PromptInputV2Attachment = { - type: "image" - id: string - filename: string - sourcePath?: string - mime: string - blob: { id: string; url: string } -} - -export type PromptInputV2Prompt = ( - | PromptInputV2TextPart - | PromptInputV2FilePart - | PromptInputV2AgentPart - | PromptInputV2Attachment -)[] - -export type PromptInputV2Model = { - providerID: string - modelID: string - variant?: string | null -} - -export type PromptInputV2Selection = { - startLine: number - startChar: number - endLine: number - endChar: number -} - -export type PromptInputV2Comment = { - type: "file" - key: string - path: string - selection?: PromptInputV2Selection - comment?: string - commentID?: string - commentOrigin?: "review" | "file" - preview?: string -} - -export type PromptInputV2PersistedState = { - prompt: PromptInputV2Prompt - cursor?: number - model?: PromptInputV2Model - context: { - items: PromptInputV2Comment[] - } -} - -export type PromptInputV2HistoryEntry = { - prompt: PromptInputV2Prompt - metadata?: unknown -} - -export type PromptInputV2History = { - entries: (mode: "normal" | "shell") => PromptInputV2HistoryEntry[] - add: (prompt: PromptInputV2Prompt, mode: "normal" | "shell") => void - capture?: () => unknown - restore?: (metadata: unknown) => void -} - -export type PromptInputV2Option = { - id: string - label: string - providerID?: string -} - -export type PromptInputV2Suggestion = { - id: string - kind: "agent" | "command" | "file" | "reference" | "resource" - label: string - title?: string - trigger?: string - description?: string - path?: string - keybind?: string[] - recent?: boolean - mention?: PromptInputV2FilePart | PromptInputV2AgentPart -} diff --git a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx index ba276a8f52..3e7bd83051 100644 --- a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx @@ -7,7 +7,6 @@ import { useI18n } from "@opencode-ai/ui/context/i18n" import { mediaKindFromPath } from "../../pierre/media" import { cloneSelectedLineRange, previewSelectedLines } from "../../pierre/selection-bridge" import type { FileContent, SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" -import type { FileDiffInfo } from "@opencode-ai/client/promise" import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" @@ -28,7 +27,7 @@ import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import "./session-review-v2.css" -type ReviewDiff = (SnapshotFileDiff & { file: string }) | FileDiffInfo | VcsFileDiff +type ReviewDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff export type SessionReviewFilePreviewV2Props = { file: string @@ -264,9 +263,7 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop {getDirectory(props.file)}
-
- -
+
{ diff --git a/packages/session-ui/src/v2/components/session-review-v2.css b/packages/session-ui/src/v2/components/session-review-v2.css index 704e8fd7d7..dccc5e2538 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.css +++ b/packages/session-ui/src/v2/components/session-review-v2.css @@ -63,7 +63,7 @@ align-items: center; justify-content: space-between; gap: 8px; - padding: 12px 16px 12px 8px; + padding: 12px 16px 8px 8px; flex-shrink: 0; } @@ -91,7 +91,7 @@ } [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-filter"] { - padding: 0 8px 4px; + padding: 0 8px 8px; flex-shrink: 0; min-width: 0; box-sizing: border-box; @@ -109,71 +109,10 @@ [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] { flex: 1; min-height: 0; - timeline-scope: --session-review-v2-sidebar-tree-scroll; -} - -@keyframes session-review-v2-sidebar-tree-fade-top { - from { - visibility: hidden; - } - to { - visibility: visible; - } -} - -@keyframes session-review-v2-sidebar-tree-fade-bottom { - from { - visibility: visible; - } - to { - visibility: hidden; - } -} - -[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::before, -[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::after { - content: ""; - position: absolute; - inset-inline: 0; - z-index: 10; - height: 16px; - pointer-events: none; - visibility: hidden; -} - -[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::before { - top: 0; - background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent); -} - -[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::after { - bottom: 0; - background: linear-gradient(to top, var(--v2-background-bg-base), transparent); -} - -@supports (animation-timeline: --session-review-v2-sidebar-tree-scroll) and - (timeline-scope: --session-review-v2-sidebar-tree-scroll) { - [data-component="session-review-v2-sidebar-root"] - [data-slot="session-review-v2-sidebar-tree"] - .scroll-view__viewport { - scroll-timeline: --session-review-v2-sidebar-tree-scroll y; - } - - [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::before { - animation: session-review-v2-sidebar-tree-fade-top linear both; - animation-timeline: --session-review-v2-sidebar-tree-scroll; - animation-range: 0 0.1px; - } - - [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::after { - animation: session-review-v2-sidebar-tree-fade-bottom linear both; - animation-timeline: --session-review-v2-sidebar-tree-scroll; - animation-range: calc(100% - 1.1px) calc(100% - 1px); - } } [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport { - padding: 4px 8px 12px; + padding: 0 8px 12px; } [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] .scroll-view__thumb { @@ -210,7 +149,7 @@ align-items: center; justify-content: space-between; gap: 8px; - padding: 10px 12px; + padding: 8px 12px; padding-left: 8px; flex-shrink: 0; border-bottom: 1px solid var(--border-weaker-base, var(--v2-border-border-weak)); @@ -304,8 +243,16 @@ color: var(--v2-icon-icon-base); } -[data-component="icon-button-v2"][data-variant="ghost"].session-review-v2-sidebar-toggle[aria-expanded="true"] { - background-color: var(--v2-background-bg-layer-02); +[data-component="icon-button-v2"][data-variant="ghost"].session-review-v2-sidebar-toggle[aria-expanded="true"]:not( + :disabled + ), +[data-component="icon-button-v2"][data-variant="ghost"].session-review-v2-sidebar-toggle[aria-expanded="true"]:is( + :hover, + [data-state="hover"], + :active, + [data-state="pressed"] + ):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-pressed); } [data-component="session-review-v2"] [data-slot="session-review-v2-file-header"] { @@ -326,37 +273,9 @@ flex: 1; } -[data-component="session-review-v2"] [data-slot="session-review-v2-file-diff"] { - position: relative; +[data-component="session-review-v2"] [data-slot="session-review-v2-file-header"] [data-component="diff-changes"] { flex-shrink: 0; margin-left: auto; - background-color: var(--v2-background-bg-base); - - &::before { - content: ""; - position: absolute; - top: -8px; - bottom: -8px; - right: 100%; - width: 16px; - z-index: 1; - pointer-events: none; - background: linear-gradient(90deg, transparent, var(--v2-background-bg-base)); - } - - &::after { - content: ""; - position: absolute; - inset: -8px -16px -8px 0; - z-index: 0; - pointer-events: none; - background-color: var(--v2-background-bg-base); - } - - [data-component="diff-changes"] { - position: relative; - z-index: 1; - } } [data-component="session-review-v2"] [data-slot="session-review-v2-file-status"] { diff --git a/packages/session-ui/src/v2/components/session-review-v2.tsx b/packages/session-ui/src/v2/components/session-review-v2.tsx index d2f3b6b8a6..811df52e1a 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-v2.tsx @@ -139,7 +139,6 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) { min={minWidth()} max={maxWidth()} onResize={(next) => props.onWidthChange?.(next)} - onDblClick={() => props.onWidthChange?.(SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT)} />
@@ -165,13 +164,15 @@ export function SessionReviewV2(props: SessionReviewV2Props) { } const prev = () => { - if (!canCycle()) return - return props.files[(fileIndex() - 1 + props.files.length) % props.files.length] + const files = props.files + if (files.length === 0) return + return files[(fileIndex() - 1 + files.length) % files.length] } const next = () => { - if (!canCycle()) return - return props.files[(fileIndex() + 1) % props.files.length] + const files = props.files + if (files.length === 0) return + return files[(fileIndex() + 1) % files.length] } const canCycle = () => props.files.length > 0 @@ -185,18 +186,16 @@ export function SessionReviewV2(props: SessionReviewV2Props) { props.onSelectFile(file) } - // Keep the advertised arrow keys working while the + // The prev/next tooltips advertise < and >; keep the keys working while the // pane is mounted, but never while typing in an input or comment editor. makeEventListener(document, "keydown", (event) => { if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return - if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return + if (event.key !== "<" && event.key !== ">") return const target = event.target if (target instanceof HTMLElement && (target.isContentEditable || target.closest("input, textarea, select"))) return if (!props.hasDiffs || !canCycle()) return - const file = event.key === "ArrowLeft" ? prev() : next() - if (!file) return event.preventDefault() - cycle(file) + cycle(event.key === "<" ? prev() : next()) }) const toolbarStart = () => ( @@ -217,11 +216,10 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
{i18n.t("ui.sessionReviewV2.previousFile")} - + } > @@ -230,18 +228,17 @@ export function SessionReviewV2(props: SessionReviewV2Props) { variant="ghost" size="small" class="session-review-v2-file-nav-button" - disabled={!prev()} + disabled={!canCycle()} onClick={() => cycle(prev())} aria-label={i18n.t("ui.sessionReviewV2.previousFile")} /> {i18n.t("ui.sessionReviewV2.nextFile")} - + "]} variant="neutral" /> } > @@ -250,7 +247,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) { variant="ghost" size="small" class="session-review-v2-file-nav-button" - disabled={!next()} + disabled={!canCycle()} onClick={() => cycle(next())} aria-label={i18n.t("ui.sessionReviewV2.nextFile")} /> @@ -333,7 +330,6 @@ export function SessionReviewV2SidebarToggle(props: { opened: boolean; disabled? class="session-review-v2-sidebar-toggle" aria-label={i18n.t("ui.sessionReviewV2.toggleSidebar")} aria-expanded={props.opened} - data-expanded={props.opened ? "" : undefined} disabled={props.disabled} onClick={props.onToggle} icon={} diff --git a/packages/slack/package.json b/packages/slack/package.json index 46d7d185e3..5f4fee49ca 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.18.11", + "version": "1.18.0", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index a04d6761a7..5279de643a 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.18.11", + "version": "1.18.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/app/src/app.tsx b/packages/stats/app/src/app.tsx index c23c7e7934..e8dc678af1 100644 --- a/packages/stats/app/src/app.tsx +++ b/packages/stats/app/src/app.tsx @@ -5,7 +5,7 @@ import { Suspense } from "solid-js" import { I18nProvider, useI18n } from "./context/i18n" import { LanguageProvider } from "./context/language" import { strip } from "./lib/language" -import "./routes/index.css" +import "./app.css" function AppMeta() { const i18n = useI18n() diff --git a/packages/stats/app/src/component/model-compare-detail.tsx b/packages/stats/app/src/component/model-compare-detail.tsx index d02723e461..3043ea1e80 100644 --- a/packages/stats/app/src/component/model-compare-detail.tsx +++ b/packages/stats/app/src/component/model-compare-detail.tsx @@ -1,3 +1,4 @@ +import "../routes/index.css" import { Link, Meta, Title } from "@solidjs/meta" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { @@ -262,8 +263,9 @@ export default function ModelCompareDetailPage(props: ModelCompareDetailPageProp }} /> +
@@ -274,7 +276,6 @@ export default function ModelCompareDetailPage(props: ModelCompareDetailPageProp >
-
(comparisonBodyScroll = element)} diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index 40a0ac9c50..f3c33edde0 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -1,3 +1,4 @@ +import "../index.css" import { Meta, Title } from "@solidjs/meta" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { geoEquirectangular, geoPath } from "d3-geo" diff --git a/packages/stats/app/src/routes/[lab]/index.tsx b/packages/stats/app/src/routes/[lab]/index.tsx index 01bb74c039..da88b05fcd 100644 --- a/packages/stats/app/src/routes/[lab]/index.tsx +++ b/packages/stats/app/src/routes/[lab]/index.tsx @@ -1,3 +1,4 @@ +import "../index.css" import { Meta, Title } from "@solidjs/meta" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { diff --git a/packages/stats/app/src/routes/compare-radar.tsx b/packages/stats/app/src/routes/compare-radar.tsx index d7403809ac..5c9b758cc1 100644 --- a/packages/stats/app/src/routes/compare-radar.tsx +++ b/packages/stats/app/src/routes/compare-radar.tsx @@ -86,10 +86,26 @@ export function ComparisonRadar(props: ComparisonRadarProps) { {(model) => ( - + + {(points) => } + + + + {(connection) => ( + + )} + + {(score, index) => { - const point = () => radarPoint(index(), axes().length, score ?? 0) + if (score === undefined) return null + const point = () => radarPoint(index(), axes().length, score) return ( <> @@ -325,12 +341,27 @@ function radarPolygonPoints(count: number, score: number) { } function radarSeriesPolygon(scores: (number | undefined)[]) { + if (scores.some((score) => score === undefined)) return return scores .map((score, index) => radarPoint(index, scores.length, score ?? 0)) .map((point) => `${point.x},${point.y}`) .join(" ") } +function radarSeriesConnections(scores: (number | undefined)[]) { + return scores.flatMap((score, index) => { + const nextIndex = (index + 1) % scores.length + const next = scores[nextIndex] + if (score === undefined || next === undefined) return [] + return [ + { + start: radarPoint(index, scores.length, score), + end: radarPoint(nextIndex, scores.length, next), + }, + ] + }) +} + function radarAxisStyle(index: number, count: number) { const angle = -Math.PI / 2 + (index * Math.PI * 2) / count const horizontal = Math.cos(angle) diff --git a/packages/stats/app/src/routes/compare/index.tsx b/packages/stats/app/src/routes/compare/index.tsx index 7aa5cbe4c7..39877f670a 100644 --- a/packages/stats/app/src/routes/compare/index.tsx +++ b/packages/stats/app/src/routes/compare/index.tsx @@ -1,3 +1,4 @@ +import "../index.css" import { Meta, Title } from "@solidjs/meta" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { createAsync } from "@solidjs/router" diff --git a/packages/stats/app/src/routes/index.css b/packages/stats/app/src/routes/index.css index b55c3153f2..f60108ddae 100644 --- a/packages/stats/app/src/routes/index.css +++ b/packages/stats/app/src/routes/index.css @@ -42,11 +42,6 @@ U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, U+2212, U+FB01-FB02; } -body { - min-width: 320px; - margin: 0; -} - [data-page="stats"] { color-scheme: light; --color-background: #ffffff; @@ -5326,8 +5321,10 @@ body { [data-page="stats"] [data-component="comparison-card"] b { min-width: 0; + overflow: hidden; font: inherit; - overflow-wrap: anywhere; + text-overflow: ellipsis; + white-space: nowrap; } [data-page="stats"] [data-component="comparison-card"] i { @@ -5775,7 +5772,9 @@ body { } [data-page="stats"] [data-slot="compare-home-select-name"] { - overflow-wrap: anywhere; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } [data-page="stats"] [data-slot="compare-home-select-panel"][data-selected="true"] [data-slot="compare-home-plus"] { @@ -5806,10 +5805,10 @@ body { [data-page="stats"] [data-component="compare-home-card"] { position: relative; display: grid; - grid-template-rows: 62px 1px auto 20px; + grid-template-rows: 62px 1px 20px 20px; gap: 16px; min-width: 0; - min-height: 184px; + height: 184px; box-sizing: border-box; padding: 16px; overflow: hidden; @@ -5891,11 +5890,10 @@ body { [data-page="stats"] [data-slot="compare-home-card-models"] { display: flex; align-items: center; - flex-wrap: wrap; gap: 6px; color: var(--stats-muted); font-weight: 400; - overflow-wrap: anywhere; + white-space: nowrap; } [data-page="stats"] [data-slot="compare-home-card-models"] span { @@ -6078,7 +6076,6 @@ body { height: 800px; box-sizing: border-box; border-right: 1px solid var(--stats-line); - border-bottom: 1px solid var(--stats-line); border-left: 1px solid var(--stats-line); color: var(--stats-text); background: var(--stats-bg); @@ -6116,9 +6113,11 @@ body { [data-page="stats"] [data-slot="compare-radar-legend"] strong, [data-page="stats"] [data-slot="compare-radar-legend"] small { + overflow: hidden; font-size: 13px; line-height: 18px; - overflow-wrap: anywhere; + text-overflow: ellipsis; + white-space: nowrap; } [data-page="stats"] [data-slot="compare-radar-legend"] strong { @@ -6274,7 +6273,7 @@ body { white-space: nowrap; } -[data-page="stats"] [data-component="compare-detail-comparison"] { +[data-page="stats"] [data-component="compare-detail-table"] { --compare-detail-label-column: 292px; --compare-detail-model-column-min: 360px; position: relative; @@ -6331,10 +6330,10 @@ body { } [data-page="stats"] - [data-component="compare-detail-comparison"][data-model-count="3"] + [data-component="compare-detail-table"][data-model-count="3"] [data-section="compare-detail-selector"], [data-page="stats"] - [data-component="compare-detail-comparison"][data-model-count="3"] + [data-component="compare-detail-table"][data-model-count="3"] [data-section="compare-detail-matrix"] { min-width: calc( var(--compare-detail-label-column) + var(--compare-detail-model-column-min) + @@ -6343,10 +6342,10 @@ body { } [data-page="stats"] - [data-component="compare-detail-comparison"][data-model-count="4"] + [data-component="compare-detail-table"][data-model-count="4"] [data-section="compare-detail-selector"], [data-page="stats"] - [data-component="compare-detail-comparison"][data-model-count="4"] + [data-component="compare-detail-table"][data-model-count="4"] [data-section="compare-detail-matrix"] { min-width: calc( var(--compare-detail-label-column) + var(--compare-detail-model-column-min) + @@ -6356,10 +6355,10 @@ body { } [data-page="stats"] - [data-component="compare-detail-comparison"][data-model-count="5"] + [data-component="compare-detail-table"][data-model-count="5"] [data-section="compare-detail-selector"], [data-page="stats"] - [data-component="compare-detail-comparison"][data-model-count="5"] + [data-component="compare-detail-table"][data-model-count="5"] [data-section="compare-detail-matrix"] { min-width: calc( var(--compare-detail-label-column) + var(--compare-detail-model-column-min) + @@ -6369,10 +6368,10 @@ body { } [data-page="stats"] - [data-component="compare-detail-comparison"][data-model-count="6"] + [data-component="compare-detail-table"][data-model-count="6"] [data-section="compare-detail-selector"], [data-page="stats"] - [data-component="compare-detail-comparison"][data-model-count="6"] + [data-component="compare-detail-table"][data-model-count="6"] [data-section="compare-detail-matrix"] { min-width: calc( var(--compare-detail-label-column) + var(--compare-detail-model-column-min) + @@ -6428,11 +6427,13 @@ body { [data-page="stats"] [data-slot="compare-detail-select-name"] { min-width: 0; + overflow: hidden; color: var(--stats-text); font-size: 13px; font-weight: 500; line-height: 18px; - overflow-wrap: anywhere; + text-overflow: ellipsis; + white-space: nowrap; } [data-page="stats"] button[data-slot="compare-detail-select-model"] > svg { @@ -6662,7 +6663,7 @@ body { aspect-ratio: 1; } - [data-page="stats"] [data-component="compare-detail-comparison"] { + [data-page="stats"] [data-component="compare-detail-table"] { --compare-detail-label-column: 220px; --compare-detail-model-column-min: 320px; } @@ -6753,7 +6754,7 @@ body { margin-left: -6px; } - [data-page="stats"] [data-component="compare-detail-comparison"] { + [data-page="stats"] [data-component="compare-detail-table"] { --compare-detail-label-column: 188px; --compare-detail-model-column-min: 246px; } @@ -6908,11 +6909,13 @@ body { [data-page="stats"] [data-slot="compare-model-modal-row-main"] > span:last-child { min-width: 0; + overflow: hidden; color: var(--stats-text); font-size: 13px; font-weight: 500; line-height: 1.1; - overflow-wrap: anywhere; + text-overflow: ellipsis; + white-space: nowrap; } [data-page="stats"] [data-slot="compare-model-modal-badge"] { @@ -6969,11 +6972,13 @@ body { [data-page="stats"] [data-slot="compare-model-modal-detail-header"] strong { min-width: 0; + overflow: hidden; color: var(--stats-text); font-size: 13px; font-weight: 500; line-height: 1.1; - overflow-wrap: anywhere; + text-overflow: ellipsis; + white-space: nowrap; } [data-page="stats"] [data-slot="compare-model-modal-description"] { @@ -7422,15 +7427,13 @@ body { @media (min-width: 90rem) { [data-page="stats"] [data-slot="header-bar"] { - display: grid; - grid-template-columns: minmax(max-content, 1fr) auto minmax(max-content, 1fr); gap: 32px; } [data-page="stats"] [data-slot="brand"], [data-page="stats"] [data-component="section-nav"], [data-page="stats"] [data-slot="header-actions"] { - min-width: 0; + flex: 1 1 0; } [data-page="stats"] [data-slot="brand"] { diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index 7debf5c30b..7d882597bd 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -1,3 +1,4 @@ +import "./index.css" import { Link, Meta, Title } from "@solidjs/meta" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { geoEquirectangular, geoPath } from "d3-geo" diff --git a/packages/stats/app/src/routes/model-catalog.ts b/packages/stats/app/src/routes/model-catalog.ts index 47fa1cf347..efd6f6b2e0 100644 --- a/packages/stats/app/src/routes/model-catalog.ts +++ b/packages/stats/app/src/routes/model-catalog.ts @@ -1,8 +1,8 @@ import { query } from "@solidjs/router" -export const modelCatalogSourceUrl = "https://models.opencode.ai/catalog.json" -export const modelCatalogPricingUrl = "https://models.opencode.ai/api.json" -export const modelCatalogLabSourceUrl = "https://models.opencode.ai/labs" +export const modelCatalogSourceUrl = "https://models.dev/catalog.json" +export const modelCatalogPricingUrl = "https://models.dev/api.json" +export const modelCatalogLabSourceUrl = "https://models.dev/labs" export type ModelCatalogCost = { input: number diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index e02d8e1314..0acfce0a75 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-core", - "version": "1.18.11", + "version": "1.18.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index 9eb222ec80..deb26aa129 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-server", - "version": "1.18.11", + "version": "1.18.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/storybook/.storybook/main.ts b/packages/storybook/.storybook/main.ts index 3fb9dae29a..f592863e56 100644 --- a/packages/storybook/.storybook/main.ts +++ b/packages/storybook/.storybook/main.ts @@ -47,8 +47,6 @@ export default defineMain({ { find: /^@\/context\/language$/, replacement: path.resolve(mocks, "app/context/language.ts") }, { find: /^@\/context\/platform$/, replacement: path.resolve(mocks, "app/context/platform.ts") }, { find: /^@\/context\/global-sync$/, replacement: path.resolve(mocks, "app/context/global-sync.ts") }, - { find: /^@\/context\/server-sync$/, replacement: path.resolve(mocks, "app/context/server-sync.ts") }, - { find: /^@\/context\/server-sdk$/, replacement: path.resolve(mocks, "app/context/server-sdk.ts") }, { find: /^@\/hooks\/use-providers$/, replacement: path.resolve(mocks, "app/hooks/use-providers.ts") }, { find: /^@\/components\/dialog-select-model$/, diff --git a/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx b/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx index 0741f7e4ef..0c7ff8bbdd 100644 --- a/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx +++ b/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx @@ -1,8 +1,9 @@ -import { splitProps, type JSX } from "solid-js" +import { splitProps } from "solid-js" -export function ModelSelectorPopover(props: { trigger: (props: Record) => JSX.Element }) { - const [local] = splitProps(props, ["trigger"]) - return <>{local.trigger({})} +export function ModelSelectorPopover(props: { triggerAs: any; triggerProps?: Record; children: any }) { + const [local] = splitProps(props, ["triggerAs", "triggerProps", "children"]) + const Trigger = local.triggerAs + return {local.children} } export const ModelSelectorPopoverV2 = ModelSelectorPopover diff --git a/packages/storybook/.storybook/mocks/app/context/command.ts b/packages/storybook/.storybook/mocks/app/context/command.ts index 16ff08bef4..673019f2f0 100644 --- a/packages/storybook/.storybook/mocks/app/context/command.ts +++ b/packages/storybook/.storybook/mocks/app/context/command.ts @@ -22,8 +22,5 @@ export function useCommand() { keybind(id: string) { return keybinds[id] }, - keybindParts(id: string) { - return keybinds[id]?.split("+") ?? [] - }, } } diff --git a/packages/storybook/.storybook/mocks/app/context/language.ts b/packages/storybook/.storybook/mocks/app/context/language.ts index 081dc26bfd..2510acc6f9 100644 --- a/packages/storybook/.storybook/mocks/app/context/language.ts +++ b/packages/storybook/.storybook/mocks/app/context/language.ts @@ -10,7 +10,7 @@ const dict: Record = { "prompt.loading": "Loading prompt...", "prompt.placeholder.normal": "Ask anything...", "prompt.placeholder.simple": "Ask anything...", - "prompt.placeholder.shell": "Enter shell command... {{example}}", + "prompt.placeholder.shell": "Run a shell command... {{example}}", "prompt.placeholder.summarizeComment": "Summarize this comment", "prompt.placeholder.summarizeComments": "Summarize these comments", "prompt.action.attachFile": "Attach files", @@ -22,69 +22,6 @@ const dict: Record = { "prompt.mode.shell": "Shell", "prompt.mode.normal": "Prompt", "dialog.model.select.title": "Select model", - "dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode", - "dialog.model.unpaid.addMore.title": "Add more models from popular providers", - "dialog.model.unpaid.viewMoreProviders": "See 70+ more providers", - "dialog.provider.opencode.tagline": "Reliable optimized models", - "dialog.provider.opencodeGo.tagline": "Low cost subscription for everyone", - "dialog.provider.custom.label": "Custom OpenAI-compatible provider", - "dialog.provider.search.placeholder": "Search providers", - "dialog.provider.empty": "No providers found", - "dialog.provider.group.popular": "Popular", - "dialog.provider.group.other": "Other", - "dialog.provider.tag.recommended": "Recommended", - "settings.providers.tag.custom": "Custom", - "command.provider.connect": "Connect provider", - "provider.connect.title": "Connect {{provider}}", - "provider.connect.selectMethod": "Select login method for {{provider}}.", - "provider.connect.method.apiKey": "API key", - "provider.connect.apiKey.description": - "Enter your {{provider}} API key to connect your account and use {{provider}} models in OpenCode.", - "provider.connect.apiKey.label": "{{provider}} API key", - "provider.connect.apiKey.placeholder": "API key", - "provider.connect.apiKey.required": "API key is required", - "provider.connect.opencodeZen.line1": - "OpenCode Zen gives you access to a curated set of reliable optimized models for coding agents.", - "provider.connect.opencodeZen.line2": - "With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and more.", - "provider.connect.opencodeZen.visit.prefix": "Visit ", - "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", - "provider.connect.opencodeZen.visit.suffix": " to collect your API key.", - "provider.connect.oauth.code.visit.prefix": "Visit ", - "provider.connect.oauth.code.visit.link": "this link", - "provider.connect.oauth.code.visit.suffix": - " to collect your authorization code to connect your account and use {{provider}} models in OpenCode.", - "provider.connect.oauth.code.label": "{{method}} authorization code", - "provider.connect.oauth.code.placeholder": "Authorization code", - "provider.connect.oauth.code.required": "Authorization code is required", - "provider.connect.oauth.code.invalid": "Invalid authorization code", - "provider.connect.oauth.auto.visit.prefix": "Visit ", - "provider.connect.oauth.auto.visit.link": "this link", - "provider.connect.oauth.auto.visit.suffix": - " and enter the code below to connect your account and use {{provider}} models in OpenCode.", - "provider.connect.oauth.auto.confirmationCode": "Confirmation code", - "provider.connect.status.inProgress": "Authorization in progress...", - "provider.connect.status.waiting": "Waiting for authorization...", - "provider.connect.status.failed": "Authorization failed: {{error}}", - "provider.connect.toast.connected.title": "{{provider}} connected", - "provider.connect.toast.connected.description": "{{provider}} models are now available to use.", - "common.continue": "Continue", - "model.tag.free": "Free", - "model.tag.latest": "Latest", - "model.input.text": "text", - "model.input.image": "image", - "model.input.audio": "audio", - "model.input.video": "video", - "model.input.pdf": "pdf", - "model.tooltip.context.label": "Context", - "model.tooltip.inputs": "Inputs", - "model.tooltip.model": "Model", - "model.tooltip.provider": "Provider", - "model.tooltip.reasoning": "Reasoning", - "model.tooltip.reasoning.allowed": "Allows reasoning", - "model.tooltip.reasoning.none": "No reasoning", - "common.close": "Close", - "common.goBack": "Go back", "common.default": "Default", "common.key.esc": "Esc", "command.category.file": "File", @@ -136,7 +73,6 @@ function render(template: string, params?: Record) { export function useLanguage() { return { locale: () => "en" as const, - intl: () => "en-US", t(key: string, params?: Record) { return render(dict[key] ?? key, params) }, diff --git a/packages/storybook/.storybook/mocks/app/context/platform.ts b/packages/storybook/.storybook/mocks/app/context/platform.ts index bab923bff5..74233cd1e5 100644 --- a/packages/storybook/.storybook/mocks/app/context/platform.ts +++ b/packages/storybook/.storybook/mocks/app/context/platform.ts @@ -2,10 +2,13 @@ import type { Platform } from "../../../../../app/src/context/platform" const value: Platform = { platform: "web", - openExternal() {}, + openLink() {}, restart: async () => {}, + back() {}, + forward() {}, notify: async () => {}, fetch: globalThis.fetch.bind(globalThis), + parseMarkdown: async (markdown: string) => markdown, } export function usePlatform() { diff --git a/packages/storybook/.storybook/mocks/app/context/server-sdk.ts b/packages/storybook/.storybook/mocks/app/context/server-sdk.ts deleted file mode 100644 index d0026dcff8..0000000000 --- a/packages/storybook/.storybook/mocks/app/context/server-sdk.ts +++ /dev/null @@ -1,47 +0,0 @@ -const providers = [ - "opencode", - "opencode-go", - "anthropic", - "openai", - "google", - "openrouter", - "vercel", - "github-copilot", - "302ai", - "abacus", - "abliteration", - "alibaba", - "alibaba-cn", - "alibaba-coding-plan", -] - -const client = { - provider: { - auth: async () => ({ - data: Object.fromEntries(providers.map((provider) => [provider, [{ type: "api", label: "API key" }]])), - }), - oauth: { - authorize: async (input: { method?: number }) => ({ - data: { - url: "https://example.com/oauth", - method: input.method === 1 ? ("code" as const) : ("auto" as const), - instructions: input.method === 1 ? "Paste the authorization code" : "Confirmation code: ABCD-EFGH", - }, - }), - callback: async (input: { method?: number }) => { - if (input.method === 0) return new Promise(() => {}) - return { data: undefined } - }, - }, - }, - auth: { - set: async () => ({ data: true }), - }, - global: { - dispose: async () => ({ data: true }), - }, -} - -export function useServerSDK() { - return () => ({ client }) -} diff --git a/packages/storybook/.storybook/mocks/app/context/server-sync.ts b/packages/storybook/.storybook/mocks/app/context/server-sync.ts deleted file mode 100644 index 1e835afdb8..0000000000 --- a/packages/storybook/.storybook/mocks/app/context/server-sync.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" - -const data = { - provider: { - all: new Map(), - connected: [], - default: {}, - }, - provider_auth: {} as Record, - config: { disabled_providers: [] as string[] }, -} - -export function mockProviderAuth(provider: string, methods: ProviderAuthMethod[]) { - const previous = data.provider_auth[provider] - data.provider_auth[provider] = methods - return () => { - if (previous) { - data.provider_auth[provider] = previous - return - } - delete data.provider_auth[provider] - } -} - -export function useServerSync() { - return () => ({ - data, - set(key: "provider_auth", value: typeof data.provider_auth) { - data[key] = value - }, - updateConfig: async () => {}, - }) -} diff --git a/packages/storybook/.storybook/mocks/app/context/sync.ts b/packages/storybook/.storybook/mocks/app/context/sync.ts index 1942927a3e..6f554fd7dd 100644 --- a/packages/storybook/.storybook/mocks/app/context/sync.ts +++ b/packages/storybook/.storybook/mocks/app/context/sync.ts @@ -12,8 +12,6 @@ const [data, setData] = createStore({ session_working: () => false, agent: [{ name: "build", mode: "task", hidden: false }], command: [{ name: "fix", description: "Run fix command", source: "project" }], - reference: [], - mcp_resource: {}, }) const sync = { diff --git a/packages/storybook/.storybook/mocks/app/hooks/use-providers.ts b/packages/storybook/.storybook/mocks/app/hooks/use-providers.ts index 64ada6421b..caa5e759a0 100644 --- a/packages/storybook/.storybook/mocks/app/hooks/use-providers.ts +++ b/packages/storybook/.storybook/mocks/app/hooks/use-providers.ts @@ -13,7 +13,6 @@ export const popularProviders = [ const provider = { id: "anthropic", - name: "Anthropic", models: { [model_id]: { id: model_id, @@ -24,33 +23,12 @@ const provider = { }, } -const popular = [ - { id: "opencode", name: "OpenCode Zen", models: {} }, - { id: "opencode-go", name: "OpenCode Go", models: {} }, - { id: "openai", name: "OpenAI", models: {} }, - provider, - { id: "google", name: "Google", models: {} }, - { id: "github-copilot", name: "GitHub Copilot", models: {} }, -] - -const catalog = [ - ...popular, - { id: "openrouter", name: "OpenRouter", models: {} }, - { id: "vercel", name: "Vercel AI Gateway", models: {} }, - { id: "302ai", name: "302.AI", models: {} }, - { id: "abacus", name: "Abacus", models: {} }, - { id: "abliteration", name: "abliteration.ai", models: {} }, - { id: "alibaba", name: "Alibaba", models: {} }, - { id: "alibaba-cn", name: "Alibaba (China)", models: {} }, - { id: "alibaba-coding-plan", name: "Alibaba Coding Plan", models: {} }, -] - export function useProviders() { return { - all: () => new Map(catalog.map((item) => [item.id, item])), + all: () => [provider], default: () => ({ anthropic: model_id }), connected: () => [provider], paid: () => [provider], - popular: () => popular, + popular: () => [provider], } } diff --git a/packages/storybook/package.json b/packages/storybook/package.json index e611ed6ba8..f7089bafa3 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -21,7 +21,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", diff --git a/packages/tui/package.json b/packages/tui/package.json index e1bc700ac7..6496a65664 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "1.18.11", + "version": "1.18.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index 957f3151e9..bcebacc349 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.18.11", + "version": "1.18.0", "type": "module", "license": "MIT", "repository": { @@ -104,7 +104,6 @@ "remend": "catalog:", "shiki": "catalog:", "solid-list": "catalog:", - "solid-sonner": "catalog:", "strip-ansi": "7.1.2" }, "peerDependencies": { diff --git a/packages/ui/src/components/dock-surface.css b/packages/ui/src/components/dock-surface.css index 91fe502264..fd34304464 100644 --- a/packages/ui/src/components/dock-surface.css +++ b/packages/ui/src/components/dock-surface.css @@ -7,20 +7,6 @@ overflow: clip; } -[data-dock-border-underlay] { - /* Later shadows paint underneath, so this solid ring masks overlapping surfaces without changing border geometry. */ - box-shadow: - var(--dock-shell-visual-shadow, var(--shadow-xs-border)), - 0 0 0 var(--dock-shell-border-underlay-width, 1px) - var(--dock-shell-border-underlay-background, var(--surface-raised-stronger-non-alpha)); -} - -[data-dock-border-underlay="v2"] { - --dock-shell-visual-shadow: var(--v2-elevation-raised); - --dock-shell-border-underlay-width: 0.5px; - --dock-shell-border-underlay-background: var(--v2-background-bg-base); -} - [data-dock-surface="tray"] { background-color: var(--background-base); border: 1px solid var(--border-weak-base); diff --git a/packages/ui/src/components/resize-handle.tsx b/packages/ui/src/components/resize-handle.tsx index bc89b257b1..d7774a684b 100644 --- a/packages/ui/src/components/resize-handle.tsx +++ b/packages/ui/src/components/resize-handle.tsx @@ -8,8 +8,6 @@ export interface ResizeHandleProps extends Omit void onCollapse?: () => void - /** Called while dragging when size crosses `collapseThreshold`. */ - onCollapseChange?: (collapsed: boolean) => void collapseThreshold?: number } @@ -22,26 +20,17 @@ export function ResizeHandle(props: ResizeHandleProps) { "max", "onResize", "onCollapse", - "onCollapseChange", "collapseThreshold", "class", "classList", ]) const handleMouseDown = (e: MouseEvent) => { - if (e.detail > 1) return e.preventDefault() const edge = local.edge ?? (local.direction === "vertical" ? "start" : "end") const start = local.direction === "horizontal" ? e.clientX : e.clientY const startSize = local.size - const min = local.min - const max = local.max - const threshold = local.collapseThreshold ?? 0 - const onResize = local.onResize - const onCollapse = local.onCollapse - const onCollapseChange = local.onCollapseChange let current = startSize - let collapsed = false document.body.style.userSelect = "none" document.body.style.overflow = "hidden" @@ -57,12 +46,8 @@ export function ResizeHandle(props: ResizeHandleProps) { ? start - pos : pos - start current = startSize + delta - const nextCollapsed = threshold > 0 && current < threshold - if (nextCollapsed !== collapsed) { - collapsed = nextCollapsed - onCollapseChange?.(collapsed) - } - onResize(Math.min(max, Math.max(min, current))) + const clamped = Math.min(local.max, Math.max(local.min, current)) + local.onResize(clamped) } const onMouseUp = () => { @@ -71,11 +56,10 @@ export function ResizeHandle(props: ResizeHandleProps) { document.removeEventListener("mousemove", onMouseMove) document.removeEventListener("mouseup", onMouseUp) - if (collapsed) { - onCollapse?.() - return + const threshold = local.collapseThreshold ?? 0 + if (local.onCollapse && threshold > 0 && current < threshold) { + local.onCollapse() } - onCollapseChange?.(false) } document.addEventListener("mousemove", onMouseMove) diff --git a/packages/ui/src/components/scroll-view.css b/packages/ui/src/components/scroll-view.css index e2425a01e4..7c5d8ed0f7 100644 --- a/packages/ui/src/components/scroll-view.css +++ b/packages/ui/src/components/scroll-view.css @@ -27,7 +27,6 @@ transition: opacity 200ms ease; cursor: default; user-select: none; - pointer-events: auto; opacity: 0; } diff --git a/packages/ui/src/components/scroll-view.test.ts b/packages/ui/src/components/scroll-view.test.ts index 84131b1f17..d338fa59bc 100644 --- a/packages/ui/src/components/scroll-view.test.ts +++ b/packages/ui/src/components/scroll-view.test.ts @@ -72,19 +72,4 @@ describe("scrollTopFromThumbPointer", () => { expect(scrollTopFromThumbPointer({ ...input, pointer: 0 })).toBe(0) expect(scrollTopFromThumbPointer({ ...input, pointer: 1_000 })).toBe(5_400) }) - - test("uses scrollClientHeight when the thumb track differs from the viewport", () => { - const input = { - pointer: 400, - viewportTop: 100, - grabOffset: 0, - clientHeight: 400, - scrollClientHeight: 800, - scrollHeight: 8_000, - thumbHeight: 40, - } - // track usable = 400 - 16 - 40 = 344; thumbTop = 400 - 100 - 8 = 292 - // maxScroll = 8000 - 800 = 7200 → 292/344 * 7200 - expect(scrollTopFromThumbPointer(input)).toBeCloseTo((292 / 344) * 7200) - }) }) diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index 5116d94f9e..0a567ce1c5 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -1,15 +1,4 @@ -import { - createEffect, - createMemo, - mergeProps, - onCleanup, - onMount, - Show, - splitProps, - type Accessor, - type ComponentProps, -} from "solid-js" -import { Portal } from "solid-js/web" +import { onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js" import { createResizeObserver } from "@solid-primitives/resize-observer" import { createStore } from "solid-js/store" import { useI18n } from "../context/i18n" @@ -19,17 +8,7 @@ export type ScrollViewThumbVisibility = "hover" | "scroll" export interface ScrollViewProps extends ComponentProps<"div"> { viewportRef?: (el: HTMLDivElement) => void orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb - /** - * `hover`: show while hovered or scrolling. `scroll`: show only while scrolling. - * - * In most cases, scrolling a container = hovering over it, so this change has no effect. - * This is a special case to account for the home page scroll, where scrolling a container != hovering over it - * */ thumbVisibility?: ScrollViewThumbVisibility - /** Mount the thumb into an external track. Scroll metrics still come from this ScrollView. */ - thumbContainer?: HTMLElement | Accessor - /** Element whose hover reveals the thumb. Defaults to the ScrollView root when unset. */ - thumbHoverTarget?: HTMLElement | Accessor } export const scrollKey = (event: Pick) => { @@ -86,14 +65,12 @@ export function scrollTopFromThumbPointer(input: { clientHeight: number scrollHeight: number thumbHeight: number - /** Viewport height used for max scroll. Defaults to `clientHeight` (track == viewport). */ - scrollClientHeight?: number }) { const padding = 8 const maxThumbTop = input.clientHeight - padding * 2 - input.thumbHeight if (maxThumbTop <= 0) return 0 const thumbTop = Math.max(0, Math.min(input.pointer - input.viewportTop - padding - input.grabOffset, maxThumbTop)) - return (thumbTop / maxThumbTop) * Math.max(0, input.scrollHeight - (input.scrollClientHeight ?? input.clientHeight)) + return (thumbTop / maxThumbTop) * Math.max(0, input.scrollHeight - input.clientHeight) } export function ScrollView(props: ScrollViewProps) { @@ -101,16 +78,7 @@ export function ScrollView(props: ScrollViewProps) { const merged = mergeProps({ orientation: "vertical", thumbVisibility: "hover" }, props) const [local, events, rest] = splitProps( merged, - [ - "class", - "children", - "viewportRef", - "orientation", - "thumbVisibility", - "thumbContainer", - "thumbHoverTarget", - "style", - ], + ["class", "children", "viewportRef", "orientation", "thumbVisibility", "style"], [ "onScroll", "onWheel", @@ -128,15 +96,6 @@ export function ScrollView(props: ScrollViewProps) { let viewportRef!: HTMLDivElement let thumbRef!: HTMLDivElement - const resolveEl = (value: HTMLElement | Accessor | undefined) => { - if (typeof value === "function") return value() - return value - } - - const thumbMount = createMemo(() => resolveEl(local.thumbContainer)) - const thumbHover = createMemo(() => resolveEl(local.thumbHoverTarget)) - const hoverRoot = () => !local.thumbHoverTarget && !local.thumbContainer - const [state, setState] = createStore({ isHovered: false, isDragging: false, @@ -155,6 +114,7 @@ export function ScrollView(props: ScrollViewProps) { let scrollIdleTimer: ReturnType | undefined const markScrolling = () => { + if (local.thumbVisibility !== "scroll") return setState("isScrolling", true) if (scrollIdleTimer !== undefined) clearTimeout(scrollIdleTimer) scrollIdleTimer = setTimeout(() => setState("isScrolling", false), 800) @@ -162,8 +122,8 @@ export function ScrollView(props: ScrollViewProps) { const thumbVisible = () => { if (isDragging()) return true - if (isScrolling()) return true - return local.thumbVisibility === "hover" && isHovered() + if (local.thumbVisibility === "scroll") return isScrolling() + return isHovered() } onCleanup(() => { @@ -181,8 +141,7 @@ export function ScrollView(props: ScrollViewProps) { setState("showThumb", true) const trackPadding = 8 - const trackClientHeight = thumbMount()?.clientHeight || clientHeight - const trackHeight = trackClientHeight - trackPadding * 2 + const trackHeight = clientHeight - trackPadding * 2 const minThumbHeight = 32 // Calculate raw thumb height based on ratio @@ -206,40 +165,16 @@ export function ScrollView(props: ScrollViewProps) { local.viewportRef(viewportRef) } - createResizeObserver( - () => [viewportRef, viewportRef.firstElementChild, thumbMount()].filter(Boolean) as HTMLElement[], - updateThumb, - ) + createResizeObserver([viewportRef, viewportRef.firstElementChild], updateThumb) updateThumb() }) - createEffect(() => { - thumbMount() - updateThumb() - }) - - createEffect(() => { - const target = thumbHover() - if (!target) return - - const enter = () => setState("isHovered", true) - const leave = () => setState("isHovered", false) - target.addEventListener("pointerenter", enter) - target.addEventListener("pointerleave", leave) - onCleanup(() => { - target.removeEventListener("pointerenter", enter) - target.removeEventListener("pointerleave", leave) - setState("isHovered", false) - }) - }) - const onThumbPointerDown = (e: PointerEvent) => { e.preventDefault() e.stopPropagation() setState("isDragging", true) const grabOffset = e.clientY - thumbRef.getBoundingClientRect().top - const track = thumbMount() ?? viewportRef thumbRef.setPointerCapture(e.pointerId) @@ -247,10 +182,9 @@ export function ScrollView(props: ScrollViewProps) { const { scrollHeight, clientHeight } = viewportRef viewportRef.scrollTop = scrollTopFromThumbPointer({ pointer: e.clientY, - viewportTop: track.getBoundingClientRect().top, + viewportTop: viewportRef.getBoundingClientRect().top, grabOffset, - clientHeight: track.clientHeight, - scrollClientHeight: clientHeight, + clientHeight, scrollHeight, thumbHeight: thumbHeight(), }) @@ -269,23 +203,6 @@ export function ScrollView(props: ScrollViewProps) { thumbRef.addEventListener("pointercancel", done) } - const renderThumb = () => ( -
{ - thumbRef = el - }} - onPointerDown={onThumbPointerDown} - class="scroll-view__thumb" - data-visible={thumbVisible()} - data-dragging={isDragging()} - style={{ - height: `${thumbHeight()}px`, - transform: `translateY(${thumbTop()}px)`, - "z-index": 100, // ensure it displays over content - }} - /> - ) - // Keybinds implementation // We ensure the viewport has a tabindex so it can receive focus // We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior, @@ -336,12 +253,8 @@ export function ScrollView(props: ScrollViewProps) { ref={rootRef} class={`scroll-view ${local.class || ""}`} style={local.style} - onPointerEnter={() => { - if (hoverRoot()) setState("isHovered", true) - }} - onPointerLeave={() => { - if (hoverRoot()) setState("isHovered", false) - }} + onPointerEnter={() => setState("isHovered", true)} + onPointerLeave={() => setState("isHovered", false)} {...rest} > {/* Viewport */} @@ -377,11 +290,20 @@ export function ScrollView(props: ScrollViewProps) { {local.children}
- {/* Thumb Overlay — optionally portaled into an external track */} + {/* Thumb Overlay */} - - {(mount) => {renderThumb()}} - +
) diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index ef630e9547..0978ec5ad2 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -723,11 +723,7 @@ body[data-new-layout] #review-panel [data-component="tabs"][data-variant="normal } &:has([data-slot="tabs-trigger-close-button"]) { - padding-right: 8px; - - [data-slot="tabs-trigger-close-button"] { - margin-left: 2px; - } + padding-right: 10px; } &:has([data-selected]) { @@ -793,62 +789,6 @@ body[data-new-layout] [data-slot="tabs-list"] > .sticky { padding-right: 0; - - [data-component="icon-button-v2"] { - position: relative; - - &::after { - content: ""; - position: absolute; - left: 100%; - top: 50%; - transform: translateY(-50%); - width: 20px; - height: 28px; - background-color: var(--v2-background-bg-base); - pointer-events: none; - } - } -} - -body[data-new-layout] - #review-panel - [data-component="tabs"] - .session-review-v2-tabs-bar - [data-slot="tabs-list"] - > .session-review-v2-sidebar-toggle-slot.sticky { - padding-right: 0; - - &::before { - content: ""; - position: absolute; - top: 50%; - bottom: auto; - left: auto; - right: 100%; - transform: translateY(-50%); - width: 12px; - height: 28px; - background-color: var(--v2-background-bg-base); - background-image: none; - pointer-events: none; - } - - &::after { - content: ""; - position: absolute; - top: 50%; - left: 100%; - transform: translateY(-50%); - width: 8px; - height: 28px; - pointer-events: none; - background: linear-gradient(90deg, var(--v2-background-bg-base), transparent); - } - - [data-component="icon-button-v2"]::after { - display: none; - } } body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-open-in-app-slot { diff --git a/packages/ui/src/v2/components/button-v2.css b/packages/ui/src/v2/components/button-v2.css index 531efe9f81..97f38e6b8b 100644 --- a/packages/ui/src/v2/components/button-v2.css +++ b/packages/ui/src/v2/components/button-v2.css @@ -113,29 +113,6 @@ cursor: not-allowed; } -[data-component="button-v2"][data-variant="warning"] { - background-color: var(--v2-background-bg-button-neutral); - color: var(--v2-state-fg-warning); - box-shadow: var(--v2-elevation-button-neutral); -} - -[data-component="button-v2"][data-variant="warning"]:is(:hover, [data-state="hover"]):not(:disabled) { - background-image: - linear-gradient(90deg, var(--v2-overlay-simple-overlay-hover) 0%, var(--v2-overlay-simple-overlay-hover) 100%), - linear-gradient(90deg, var(--v2-background-bg-button-neutral) 0%, var(--v2-background-bg-button-neutral) 100%); -} - -[data-component="button-v2"][data-variant="warning"]:is(:active, [data-state="pressed"]):not(:disabled) { - background-image: - linear-gradient(90deg, var(--v2-overlay-simple-overlay-pressed) 0%, var(--v2-overlay-simple-overlay-pressed) 100%), - linear-gradient(90deg, var(--v2-background-bg-button-neutral) 0%, var(--v2-background-bg-button-neutral) 100%); -} - -[data-component="button-v2"][data-variant="warning"]:is(:disabled, [data-state="disabled"]) { - opacity: 0.5; - cursor: not-allowed; -} - /* Outline */ [data-component="button-v2"][data-variant="outline"] { background-color: transparent; diff --git a/packages/ui/src/v2/components/button-v2.stories.tsx b/packages/ui/src/v2/components/button-v2.stories.tsx index fbb38cc5a2..bc9b9f05f0 100644 --- a/packages/ui/src/v2/components/button-v2.stories.tsx +++ b/packages/ui/src/v2/components/button-v2.stories.tsx @@ -4,7 +4,7 @@ const docs = `### Overview Button v2 with visual variants and three sizes. ### API -- \`variant\`: "neutral" | "danger" | "warning" | "contrast" | "ghost" | "ghost-muted" | "loading". +- \`variant\`: "neutral" | "danger" | "contrast" | "ghost" | "ghost-muted" | "loading". - \`size\`: "small" | "normal" | "large". - \`icon\`: Optional icon name. - Inherits Kobalte Button props and native button attributes. @@ -39,7 +39,7 @@ export default { }, variant: { control: "select", - options: ["neutral", "danger", "warning", "contrast", "ghost", "ghost-muted", "loading"], + options: ["neutral", "danger", "contrast", "ghost", "ghost-muted", "loading"], }, size: { control: "select", @@ -62,7 +62,6 @@ export const Variants = { > Neutral Danger - Warning Contrast Ghost @@ -118,7 +117,7 @@ export const Icon = { export const AllStates = { render: () => { - const variants = ["neutral", "danger", "warning", "contrast", "ghost", "ghost-muted", "loading"] as const + const variants = ["neutral", "danger", "contrast", "ghost", "ghost-muted", "loading"] as const const states = ["default", "hover", "pressed", "focus", "disabled"] as const const toTitleCase = (value: string) => value.charAt(0).toUpperCase() + value.slice(1) return ( diff --git a/packages/ui/src/v2/components/button-v2.tsx b/packages/ui/src/v2/components/button-v2.tsx index 14b793ba11..c6c6099e21 100644 --- a/packages/ui/src/v2/components/button-v2.tsx +++ b/packages/ui/src/v2/components/button-v2.tsx @@ -7,7 +7,7 @@ export interface ButtonV2Props extends ComponentProps, Pick, "class" | "classList" | "children"> { size?: "small" | "normal" | "large" - variant?: "neutral" | "danger" | "warning" | "outline" | "contrast" | "ghost" | "ghost-muted" | "loading" + variant?: "neutral" | "danger" | "outline" | "contrast" | "ghost" | "ghost-muted" | "loading" icon?: IconProps["name"] } diff --git a/packages/ui/src/v2/components/dialog-v2.tsx b/packages/ui/src/v2/components/dialog-v2.tsx index b9dd2521e2..48a6f53712 100644 --- a/packages/ui/src/v2/components/dialog-v2.tsx +++ b/packages/ui/src/v2/components/dialog-v2.tsx @@ -101,7 +101,7 @@ export function Dialog(props: DialogProps) { const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null if (autofocusEl) { e.preventDefault() - autofocusEl.focus({ preventScroll: true }) + autofocusEl.focus() } }} > diff --git a/packages/ui/src/v2/components/divider-v2.css b/packages/ui/src/v2/components/divider-v2.css index 94a584b40e..94c89bbed2 100644 --- a/packages/ui/src/v2/components/divider-v2.css +++ b/packages/ui/src/v2/components/divider-v2.css @@ -1,8 +1,8 @@ [data-component="divider-v2"] { box-sizing: border-box; width: 100%; - height: 1px; - transform: scaleY(0.5); + height: 0.5px; + margin-block: 0.25px; border: none; background: var(--v2-border-border-strong); flex: none; diff --git a/packages/ui/src/v2/components/inline-input-v2.css b/packages/ui/src/v2/components/inline-input-v2.css index c2f13e605d..81d3fe508e 100644 --- a/packages/ui/src/v2/components/inline-input-v2.css +++ b/packages/ui/src/v2/components/inline-input-v2.css @@ -67,14 +67,9 @@ gap: 4px; background: var(--v2-background-bg-layer-01); border-radius: 4px 0 0 4px; - cursor: default; transition: background 85ms ease-out; } -[data-component="inline-input-v2"]:where([data-disabled]) [data-slot="inline-input-v2-prefix"] { - cursor: not-allowed; -} - [data-component="inline-input-v2"][data-label-width] [data-slot="inline-input-v2-prefix"] { width: var(--inline-input-v2-label-width); min-width: var(--inline-input-v2-label-width); @@ -94,7 +89,6 @@ letter-spacing: -0.04px; color: var(--v2-text-text-muted); font-variation-settings: "slnt" 0; - user-select: none; } [data-component="inline-input-v2"][data-numeric] [data-slot="inline-input-v2-prefix-text"] { @@ -155,7 +149,6 @@ [data-component="inline-input-v2"] [data-slot="inline-input-v2-input"]::placeholder { color: var(--v2-text-text-faint); - user-select: none; } [data-component="inline-input-v2"][data-numeric] [data-slot="inline-input-v2-input"] { diff --git a/packages/ui/src/v2/components/inline-input-v2.tsx b/packages/ui/src/v2/components/inline-input-v2.tsx index 9d41437c22..cce1af2693 100644 --- a/packages/ui/src/v2/components/inline-input-v2.tsx +++ b/packages/ui/src/v2/components/inline-input-v2.tsx @@ -37,8 +37,6 @@ export function InlineInputV2(props: InlineInputV2Props) { "style", ]) - let input: HTMLInputElement | undefined - return (
-
{ - if (local.disabled || event.button !== 0) return - // Keep focus on the input without using a native