diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 037020c03a..1c41a66faa 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -219,7 +219,6 @@ jobs: build-electron: needs: - - build-cli - version if: github.repository == 'anomalyco/opencode' continue-on-error: false @@ -316,10 +315,7 @@ 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 @@ -343,8 +339,7 @@ jobs: env: OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} GH_TOKEN: ${{ steps.committer.outputs.token }} - CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }} - CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + CSC_KEYCHAIN: build.keychain 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 8d493f4a81..de18ae2ee8 100644 --- a/.opencode/command/translate.md +++ b/.opencode/command/translate.md @@ -1,6 +1,6 @@ --- description: translate English to other languages -model: opencode/claude-opus-4-8 +model: opencode/gpt-5.6-sol --- 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 33847d6b2c..e905ada8c5 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.18.2", + "version": "1.18.11", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -37,6 +37,7 @@ "@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:*", @@ -65,7 +66,7 @@ "diff": "catalog:", "effect": "catalog:", "fuzzysort": "catalog:", - "ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553", + "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", "luxon": "catalog:", "marked": "catalog:", "marked-shiki": "catalog:", @@ -95,7 +96,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.18.2", + "version": "1.18.11", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -143,7 +144,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.18.2", + "version": "1.18.11", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -157,7 +158,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.18.2", + "version": "1.18.11", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -193,7 +194,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.18.2", + "version": "1.18.11", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -220,7 +221,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.18.2", + "version": "1.18.11", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -242,7 +243,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.18.2", + "version": "1.18.11", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -266,7 +267,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.18.2", + "version": "1.18.11", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -286,7 +287,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.18.2", + "version": "1.18.11", "bin": { "opencode": "./bin/opencode", }, @@ -302,7 +303,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.27", + "@ai-sdk/mistral": "3.0.51", "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", @@ -338,7 +339,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.11.1", + "gitlab-ai-provider": "6.12.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -380,16 +381,16 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.18.2", + "version": "1.18.11", "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", @@ -434,7 +435,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.18.2", + "version": "1.18.11", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -448,7 +449,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.18.2", + "version": "1.18.11", "dependencies": { "effect": "catalog:", }, @@ -460,7 +461,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.18.2", + "version": "1.18.11", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -492,7 +493,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.18.2", + "version": "1.18.11", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -508,7 +509,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.18.2", + "version": "1.18.11", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -539,7 +540,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.18.2", + "version": "1.18.11", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -558,7 +559,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.18.2", + "version": "1.18.11", "bin": { "opencode": "./bin/opencode", }, @@ -577,7 +578,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.27", + "@ai-sdk/mistral": "3.0.51", "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", @@ -631,7 +632,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.11.1", + "gitlab-ai-provider": "6.12.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -689,7 +690,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.18.2", + "version": "1.18.11", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -706,9 +707,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.4.3", - "@opentui/keymap": ">=0.4.3", - "@opentui/solid": ">=0.4.3", + "@opentui/core": ">=0.4.5", + "@opentui/keymap": ">=0.4.5", + "@opentui/solid": ">=0.4.5", }, "optionalPeers": [ "@opentui/core", @@ -765,7 +766,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.18.2", + "version": "1.18.11", "dependencies": { "cross-spawn": "catalog:", }, @@ -780,7 +781,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.18.2", + "version": "1.18.11", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -795,9 +796,10 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.18.2", + "version": "1.18.11", "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:*", @@ -809,7 +811,6 @@ "@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:", @@ -839,7 +840,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.18.2", + "version": "1.18.11", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -852,7 +853,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.18.2", + "version": "1.18.11", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -886,7 +887,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.18.2", + "version": "1.18.11", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -905,7 +906,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.18.2", + "version": "1.18.11", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -937,6 +938,7 @@ "@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", @@ -946,7 +948,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.18.2", + "version": "1.18.11", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -973,7 +975,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.18.2", + "version": "1.18.11", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -999,6 +1001,7 @@ "remend": "catalog:", "shiki": "catalog:", "solid-list": "catalog:", + "solid-sonner": "catalog:", "strip-ansi": "7.1.2", }, "devDependencies": { @@ -1024,7 +1027,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.18.2", + "version": "1.18.11", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -1073,11 +1076,13 @@ "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", - "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.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", "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:", @@ -1099,9 +1104,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.4.3", - "@opentui/keymap": "0.4.3", - "@opentui/solid": "0.4.3", + "@opentui/core": "0.4.5", + "@opentui/keymap": "0.4.5", + "@opentui/solid": "0.4.5", "@pierre/diffs": "1.2.10", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1141,6 +1146,7 @@ "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", @@ -1198,7 +1204,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.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/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/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=="], @@ -2038,27 +2044,27 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@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": ["@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-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p5+7AAxpxGuDGagyQfewKtmTFnN7THvTVY4FyKqUtJomNaHdQXPHztapNNzMx0DGWbwOUbVKzpL+yc3CZY3chQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-+fh0vEUE0lwVC7RW5ijYLRlTLp5NfvCRj8SzxDVd7IL2j2ssB6YXcfIbXq2EW7UGnrejwPRXf1tgUrIXW9KmOw=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-gl6qA5QJy6u8Cbt7gOtHbhhfMZ4qQDb0kEwFXHcMGmbnKzz4OHoq74D6tNjyvSQB9saoC7C6C0tvn2DcJOuNog=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="], - "@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-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="], - "@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": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/QiFpCrpU2O7vy8QYmLIQYbvAtKDgmqcVjR7dGtqSzkiQk3ktNJoo5RozG7ueXnjung1Wp0nKldKxo2Csg/OrA=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-Mx2zuOjrhm/z2SDS6RExIyjP/SnN/8QhhagxURUw0jQi/NssGSeAllu1cBAFFnhobJL5QLTE4FU4CRhUK9svgg=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.3", "", { "os": "win32", "cpu": "x64" }, "sha512-NuoqvWKGXaYnmlqvu7Gg2lLI6yVMnS9OfWBvxp+7Q+McSgHFSTQmYBXaPpvQ8HikpQXE1nCeMPtuSG4PdZHe2w=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="], - "@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/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/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=="], + "@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=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -3844,13 +3850,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#513463a", {}, "anomalyco-ghostty-web-513463a", "sha512-GZR8LSmgGzViWnBJrqRI8MpAZRCJxhcr1Hi9Tyeh7YRooHZQjK9J97FQRD3tbBaM2wjq05gzGY2UEsG+JtZeBw=="], + "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#83c0a07", {}, "anomalyco-ghostty-web-83c0a07", "sha512-Lf2v1agHkVUpMpHBWWuCZrhOEmcwwin5/Hboc9rZwQ7/CKkIh5rU1r1CvfLlhkMoFv+ed8z52RZ8hkzGZZj3MQ=="], "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.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=="], + "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=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], @@ -5122,6 +5128,8 @@ "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=="], @@ -5698,7 +5706,9 @@ "@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-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/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=="], @@ -6024,6 +6034,8 @@ "@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=="], @@ -6032,8 +6044,6 @@ "@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=="], @@ -6042,6 +6052,8 @@ "@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=="], @@ -6172,6 +6184,8 @@ "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=="], @@ -6988,6 +7002,8 @@ "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=="], @@ -7414,6 +7430,8 @@ "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 5fbf35de00..79556f5e0c 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -288,8 +288,12 @@ new sst.cloudflare.x.SolidStart("Console", { server: { placement: { region: "aws:us-east-2" }, transform: { - worker: { - tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }], + worker: (args) => { + args.compatibilityFlags = $resolve(args.compatibilityFlags).apply((flags) => [ + ...(flags ?? []), + "global_fetch_strictly_public", + ]) + args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }] }, }, }, diff --git a/infra/stage.ts b/infra/stage.ts index 8d80eefed8..f0db797448 100644 --- a/infra/stage.ts +++ b/infra/stage.ts @@ -8,6 +8,25 @@ 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 d0d7fa7eca..2df62f7a1c 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -8,6 +8,8 @@ makeWrapper, writableTmpDirAsHomeHook, autoPatchelfHook, + copyDesktopItems, + makeDesktopItem, opencode, }: let @@ -27,9 +29,12 @@ stdenv.mkDerivation (finalAttrs: { nodejs makeWrapper writableTmpDirAsHomeHook - ] ++ lib.optionals stdenv.hostPlatform.isLinux [ + ] + ++ lib.optionals stdenv.hostPlatform.isLinux [ autoPatchelfHook - ] ++ lib.optionals stdenv.hostPlatform.isDarwin [ + copyDesktopItems + ] + ++ lib.optionals stdenv.hostPlatform.isDarwin [ # Ad-hoc sign the .app: --config.mac.identity=null below skips signing. darwin.autoSignDarwinBinariesHook ]; @@ -38,20 +43,37 @@ 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"; }; - # 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 - ''; + 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 + ''; preBuild = '' cp -r "${electron.dist}" $HOME/.electron-dist @@ -76,27 +98,38 @@ 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 - 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 + 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 + ''; autoPatchelfIgnoreMissingDeps = [ "libc.musl-x86_64.so.1" diff --git a/nix/hashes.json b/nix/hashes.json index 9d85faba7f..25f0e76812 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-F1luclnqCPQk9yxfmeSYGaM/nScf28yBu9K3Fv+Xd24=", - "aarch64-linux": "sha256-XW0XZnsCRkU3MFJH9TjMRYZHffzVy3cQyiNCkec2gl4=", - "aarch64-darwin": "sha256-bf8kvORs3Fs2UYLp3PekF+AJR7NKOcHb+fIQA79RtMk=", - "x86_64-darwin": "sha256-sBdQPkzd7JXNW6Lbi9JHiAsfHwdLwTKWY+uPeXAv2Nw=" + "x86_64-linux": "sha256-a7NyYa9vRUEqDfZNDPXXmFO58RDEgioyuGSl5CPBvxo=", + "aarch64-linux": "sha256-l4OJtSEllHvRhktjcaJYwkXBSaJvsIoyoLusbZfYMcM=", + "aarch64-darwin": "sha256-IIl0BQGs1/HLFh0auiQjiwfSQ2nfHcK2G2BAphYW59c=", + "x86_64-darwin": "sha256-vVeuPyd4ZIRYrHouphTuEb4rkRZLKKTAHe840jNh9rU=" } } diff --git a/package.json b/package.json index cb8a1a5a27..2a8eef6356 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.3", - "@opentui/keymap": "0.4.3", - "@opentui/solid": "0.4.3", + "@opentui/core": "0.4.5", + "@opentui/keymap": "0.4.5", + "@opentui/solid": "0.4.5", "@tanstack/solid-virtual": "3.13.32", "@shikijs/stream": "4.2.0", "ulid": "3.0.1", @@ -90,6 +90,7 @@ "@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" } @@ -143,12 +144,14 @@ "@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 new file mode 100644 index 0000000000..2850f10740 --- /dev/null +++ b/packages/app/V1_API_MIGRATION.md @@ -0,0 +1,220 @@ +# 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 5095d95db0..df67da5a66 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -97,6 +97,7 @@ export async function setupTimeline( locale?: string deviceScaleFactor?: number seedHistory?: boolean + protocol?: "v1" | "v2" } = {}, ) { const sessions = input.sessions ?? [session()] @@ -114,6 +115,7 @@ 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 2a214831da..838af17c93 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,7 +41,12 @@ 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 lastPartID = assistants.at(-1)!.parts.at(-1)!.id +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}` benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => { benchmark.setTimeout(180_000) @@ -107,9 +112,25 @@ 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) => - route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }), - ) + 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 installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] }) await page.goto(stressSessionHref(fixture.sourceID)) await expectSessionTitle(page, fixture.expected.sourceTitle) @@ -144,8 +165,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) { parent: requests.filter((request) => request.type === "parent").length, } if (mode === "candidate") { - expect(requestCounts.parent).toBe(1) - expect(historyGates).toBe(1) + expect(requestCounts.parent).toBe(0) + expect(historyGates).toBe(0) } 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 159b5a5067..f09a2c7b63 100644 --- a/packages/app/e2e/regression/cross-server-tab-close.spec.ts +++ b/packages/app/e2e/regression/cross-server-tab-close.spec.ts @@ -1,5 +1,6 @@ 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" @@ -33,7 +34,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}/session/${sessionB.id}`))).toBe(true) + await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/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) @@ -84,17 +85,21 @@ 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") return sse(route) - if (url.pathname === "/global/health") return json(route, { healthy: true }) - if (url.pathname === "/session") return json(route, [current]) + 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 === `/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", "/session/status"].includes(url.pathname)) - return json(route, {}) + if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) if (url.pathname === "/provider") return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) @@ -116,7 +121,20 @@ 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 new file mode 100644 index 0000000000..37739d2fbb --- /dev/null +++ b/packages/app/e2e/regression/open-file-expand-folder.spec.ts @@ -0,0 +1,132 @@ +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 new file mode 100644 index 0000000000..2cdb0b4a03 --- /dev/null +++ b/packages/app/e2e/regression/project-picker-recent-search.spec.ts @@ -0,0 +1,60 @@ +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 new file mode 100644 index 0000000000..50dcc8820b --- /dev/null +++ b/packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts @@ -0,0 +1,50 @@ +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 4219699f28..9315c347c0 100644 --- a/packages/app/e2e/regression/prompt-thinking-level.spec.ts +++ b/packages/app/e2e/regression/prompt-thinking-level.spec.ts @@ -54,18 +54,15 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => { }) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - const composer = page.locator('[data-component="session-composer"]') + const composer = page.locator('[data-component="prompt-input-v2"]') const input = composer.locator('[data-component="prompt-input"]') - const control = composer.locator('[data-component="prompt-variant-control"]') + const control = composer.getByRole("button", { name: "Choose model variant" }) await expectAppVisible(composer) await idleComposer(page) - await expect(control).toBeHidden() - - await composer.hover() await expect(control).toBeVisible() - await control.locator('[data-action="prompt-model-variant"]').click() + await control.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 c17ae5c1c6..35a0aa44cd 100644 --- a/packages/app/e2e/regression/remote-session-settings.spec.ts +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -1,6 +1,7 @@ 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" @@ -17,7 +18,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(process.platform === "darwin" ? "Meta+," : "Control+,") + await page.keyboard.press("Control+,") const dialog = page.locator(".settings-v2-dialog") const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]') @@ -58,7 +59,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(process.platform === "darwin" ? "Meta+," : "Control+,") + await page.keyboard.press("Control+,") const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]') await autoAccept.locator('[data-slot="switch-control"]').click() await expect(autoAccept.getByRole("switch")).toBeChecked() @@ -180,10 +181,36 @@ 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") return sse(route) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") + return sse(route) if (url.pathname === "/global/health") return json(route, { healthy: true }) - if (url.pathname === "/session/status") return json(route, {}) - if (url.pathname === "/session") return json(route, sessions) + if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent") + return json(route, { data: [] }) + if (url.pathname === "/api/model/default") return json(route, { data: null }) + if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname)) + return json(route, { location: { directory }, data: [] }) + if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] }) + if (url.pathname === "/api/mcp/resource") + return json(route, { location: { directory }, data: { resources: [], templates: [] } }) + if (url.pathname === "/api/project") { + return json(route, [ + { + id: remote ? sessionB.projectID : "project-server-a", + worktree: directory, + vcs: "git", + time: { created: 1, updated: 1 }, + sandboxes: [], + }, + ]) + } + if (url.pathname === "/api/project/current") + return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory }) + if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) + if (url.pathname === "/api/session/active") return json(route, { data: {} }) + const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`) + if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) + if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`)) + return json(route, { data: [], cursor: {} }) const current = sessions.find((session) => url.pathname === `/session/${session.id}`) if (current) return json(route, current) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) @@ -216,7 +243,12 @@ 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 119fc7ee2d..2d9b1e2349 100644 --- a/packages/app/e2e/regression/remote-tab-busy.spec.ts +++ b/packages/app/e2e/regression/remote-tab-busy.spec.ts @@ -1,5 +1,6 @@ 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" @@ -57,11 +58,15 @@ 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") 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 === "/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 === `/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, []) @@ -90,7 +95,20 @@ 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, {}) }) } @@ -104,6 +122,10 @@ function json(route: Route, body: unknown, status = 200) { }) } -function sse(route: Route) { - return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) +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", + }) } diff --git a/packages/app/e2e/regression/review-line-comment.spec.ts b/packages/app/e2e/regression/review-line-comment.spec.ts index 042f926c53..7850f7820a 100644 --- a/packages/app/e2e/regression/review-line-comment.spec.ts +++ b/packages/app/e2e/regression/review-line-comment.spec.ts @@ -84,6 +84,7 @@ 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", @@ -143,9 +144,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 === "/vcs/diff") + const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff") await page.getByRole("tab", { name: "Changes" }).click() - expect(await (await diffResponse).json()).toHaveLength(1) + expect((await (await diffResponse).json()).data).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 25ebd3a37a..04e6d2cced 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.getByRole("tab", { name: /Review/ }).click() + await panel.locator("#session-side-panel-review-tab").click() await expect(sidebarToggle).toBeEnabled() await panel.getByRole("tab", { name: "Open file" }).click() await page.keyboard.press("Control+w") diff --git a/packages/app/e2e/regression/review-state-persistence.spec.ts b/packages/app/e2e/regression/review-state-persistence.spec.ts index aa42f1bb51..0d6756201e 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 }).click() + await page.getByRole("option", { name: next }).dispatchEvent("click") } async function selectFile(page: Page, file: string) { @@ -65,6 +65,7 @@ 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 154bab48c4..79b564820e 100644 --- a/packages/app/e2e/regression/review-terminal-stacked.spec.ts +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -20,10 +20,12 @@ 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, @@ -55,7 +57,7 @@ test("keeps the review tree and terminal sized when both panels are open", async time: { created: 1700000000000, updated: 1700000000000 }, }, ], - sessionStatus: { [sessionID]: { type: "idle" } }, + sessionStatus: () => sessionStatus, pageMessages: () => ({ items: [] }), events: () => events.splice(0, 1), eventRetry: 16, @@ -64,7 +66,10 @@ 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) => { @@ -86,15 +91,51 @@ 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({ id: "pty_review_terminal", title: "Terminal 1" }), + 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", (route) => - route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), + 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.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined) await page.addInitScript(() => { @@ -143,6 +184,7 @@ 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) => { @@ -152,6 +194,7 @@ 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 4a3855122a..3319514df6 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("**/path?*", async (route) => { - if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback() + await page.route("**/api/path?*", async (route) => { + if (!new URL(route.request().url()).searchParams.has("location[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 714d6ca96f..5ea9d4f761 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -42,7 +42,8 @@ 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 === "/question/question-request/reject") rejectRequests.push(request.url()) + if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`) + rejectRequests.push(request.url()) }) await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click() @@ -64,7 +65,9 @@ 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 === "/question/question-request/reply", + (request) => + request.method() === "POST" && + new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`, ) await question.getByRole("button", { name: "Submit" }).click() expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] }) @@ -97,8 +100,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(`/session/${sessionID}/permissions/permission-request`) - expect(request.postDataJSON()).toEqual({ response: "once" }) + expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`) + expect(request.postDataJSON()).toEqual({ reply: "once" }) }) test("restores the draft caret before typing after a request dock closes", async ({ page }) => { @@ -170,6 +173,7 @@ 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 3e2b171bca..b303071c87 100644 --- a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts @@ -32,6 +32,23 @@ 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 850e966d0b..778ff3a3af 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("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => { - const timeline = await setupTimeline(page, { eventRetry: 10 }) +test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => { + const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" }) const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), { id: "timeline-event-7", }) @@ -100,7 +100,7 @@ test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) = const connection = await timeline.transport.waitForConnection({ after: first.connectionID }) expect(first.eventID).toBe("timeline-event-7") - expect(connection.headers["last-event-id"]).toBe("timeline-event-7") + expect(connection.headers["last-event-id"]).toBeUndefined() }) 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 603c411d55..55e7121275 100644 --- a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts +++ b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts @@ -27,6 +27,7 @@ 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, @@ -56,9 +57,11 @@ 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) @@ -68,6 +71,7 @@ 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 19d2c29af0..019cc156ec 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 { mockOpenCodeServer } from "../utils/mock-server" +import { currentSession, mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" const directory = "C:/OpenCode/SubagentNavigation" @@ -72,16 +72,19 @@ async function setup(page: Page, events?: () => EventPayload[]) { events, eventRetry: events ? 16 : undefined, }) - // The child session resolves via /session/:id but is absent from the /session list, + // The child session resolves by 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 === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"), + (url) => url.pathname === "/api/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([session(parentID, parentTitle, 1700000000000)]), + body: JSON.stringify({ + data: [currentSession(session(parentID, parentTitle, 1700000000000))], + cursor: {}, + }), }), ) 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 94afbc9a9d..b969b590d8 100644 --- a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts +++ b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts @@ -1,9 +1,12 @@ 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) @@ -39,6 +42,34 @@ 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, @@ -52,13 +83,21 @@ function session(id: string, title: string) { } async function mockServer(page: Page) { - const sessions = [sessionA, sessionB] + const sessions = [sessionA, sessionB, sessionC] await page.route("**/*", async (route) => { const url = new URL(route.request().url()) if (url.origin !== server) return route.fallback() - if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) + 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/health") return json(route, { healthy: true }) - if (url.pathname === "/session") return json(route, sessions) + 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: {} }) 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) @@ -66,8 +105,7 @@ 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", "/session/status"].includes(url.pathname)) - return json(route, {}) + if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) if (url.pathname === "/provider") return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) @@ -89,7 +127,20 @@ 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 f672602782..99bf689085 100644 --- a/packages/app/e2e/regression/terminal-composer-focus.spec.ts +++ b/packages/app/e2e/regression/terminal-composer-focus.spec.ts @@ -13,6 +13,7 @@ test.use({ viewport: { width: 1440, height: 900 } }) test.beforeEach(async ({ page }) => { await mockOpenCodeServer(page, { + protocol: "v2", directory, project: { id: projectID, @@ -46,25 +47,30 @@ test.beforeEach(async ({ page }) => { ], pageMessages: () => ({ items: [] }), }) - await page.route("**/pty", (route) => + 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) => route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ id: ptyID, title: "Terminal 1" }), + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }), }), ) - await page.route(`**/pty/${ptyID}`, (route) => - route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), - ) - await page.route(`**/pty/${ptyID}/connect-token*`, (route) => + await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => route.fulfill({ status: 200, contentType: "application/json", headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify({ ticket: "e2e-ticket" }), + body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), }), ) - await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), () => undefined) + await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined) await page.addInitScript(() => { localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) }) @@ -95,12 +101,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("**/pty", (route) => { + await page.route("**/api/pty*", (route) => { created.count += 1 return route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ id: ptyID, title: "Terminal 1" }), + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }), }) }) await page.route(/ghostty-web/, async (route) => { @@ -155,27 +161,31 @@ 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("**/pty", (route) => { + await page.route("**/api/pty*", (route) => { created.count += 1 - const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" } + const next = created.count === 1 ? ptyInfo(ptyID, "Terminal 1") : ptyInfo(newPtyID, "Terminal 2") return route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify(next), + body: JSON.stringify({ location: ptyLocation(), data: next }), }) }) - await page.route(`**/pty/${newPtyID}`, (route) => - route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), + 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}/connect-token*`, (route) => + await page.route(`**/api/pty/${newPtyID}/connect-token*`, (route) => route.fulfill({ status: 200, contentType: "application/json", headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify({ ticket: "e2e-ticket" }), + body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), }), ) - await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined) + await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await expectSessionTitle(page, "Terminal composer focus") @@ -207,3 +217,11 @@ 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 73821580af..8e08d60ff2 100644 --- a/packages/app/e2e/regression/terminal-hidden.spec.ts +++ b/packages/app/e2e/regression/terminal-hidden.spec.ts @@ -10,6 +10,7 @@ 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, @@ -43,17 +44,53 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => { ], pageMessages: () => ({ items: [] }), }) - await page.route("**/pty", (route) => + await page.route("**/api/pty*", (route) => route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ id: "pty_hidden_terminal", title: "Terminal 1" }), + 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*", (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.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined) + 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.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 cbb72958ad..165920753c 100644 --- a/packages/app/e2e/regression/terminal-tab-switch.spec.ts +++ b/packages/app/e2e/regression/terminal-tab-switch.spec.ts @@ -29,6 +29,10 @@ 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) @@ -62,6 +66,7 @@ async function readProbe(page: Page) { async function setup(page: Page) { await mockOpenCodeServer(page, { + protocol: "v2", directory, project: { id: projectID, @@ -85,26 +90,33 @@ async function setup(page: Page) { sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], pageMessages: () => ({ items: [] }), }) - await page.route("**/pty", (route) => + await page.route("**/api/pty*", (route) => route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ id: ptyID, title: "Terminal 1" }), + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }), }), ) - await page.route(`**/pty/${ptyID}`, (route) => - route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), - ) - await page.route(`**/pty/${ptyID}/connect-token*`, (route) => + await page.route(`**/api/pty/${ptyID}*`, (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({ ticket: "e2e-ticket" }), - }), - ) + body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), + }) + }) const connections: string[] = [] - await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), (ws) => { + await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => { connections.push(ws.url()) }) @@ -143,3 +155,11 @@ 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 a73cc0ccdd..bdf3f55bdc 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: /Ask anything/i })) + await expectAppVisible(page.getByRole("textbox", { name: "Prompt" })) } diff --git a/packages/app/e2e/user-story/model-selection-flow.spec.ts b/packages/app/e2e/user-story/model-selection-flow.spec.ts new file mode 100644 index 0000000000..22b8bb41fe --- /dev/null +++ b/packages/app/e2e/user-story/model-selection-flow.spec.ts @@ -0,0 +1,97 @@ +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 2bfba5871a..76987421b6 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -4,7 +4,11 @@ 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 { - provider: unknown + protocol?: "v1" | "v2" + provider: unknown | (() => unknown) + integrationMethods?: Record + onConnectKey?: (input: { integrationID: string; body: unknown }) => void + onInstanceDispose?: () => void directory: string project: unknown sessions: ({ id: string } & Record)[] @@ -23,14 +27,13 @@ export interface MockServerConfig { fileList?: (path: string) => unknown | Promise fileContent?: (path: string) => unknown | Promise findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown - sessionStatus?: unknown + sessionStatus?: Record | (() => Record) } 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, @@ -54,19 +57,47 @@ 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") 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 === "/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 === "/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, config.sessionStatus ?? {}) + if (path === "/session/status") + return json( + route, + typeof config.sessionStatus === "function" ? config.sessionStatus() : (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") ?? "")) @@ -89,10 +120,138 @@ 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]) @@ -115,6 +274,24 @@ 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 @@ -137,12 +314,37 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }) } -function v2Session(session: { id: string } & Record, fallbackDirectory: string) { +function location(config: MockServerConfig) { + return { + directory: config.directory, + project: { id: (config.project as { id?: string }).id, directory: config.directory }, + } +} + +function currentPermission(value: unknown) { + const permission = value as Record + if (permission.action) return permission + const tool = permission.tool as { messageID?: string; callID?: string } | undefined + return { + id: permission.id, + sessionID: permission.sessionID, + action: permission.permission, + resources: permission.patterns ?? [], + save: permission.always, + metadata: permission.metadata, + source: + tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined, + } +} + +export function currentSession(session: { id: string } & Record, fallbackDirectory?: string) { const time = session.time && typeof session.time === "object" ? session.time : {} return { id: session.id, parentID: session.parentID, projectID: session.projectID ?? "project", + agent: session.agent ?? "build", + model: session.model ?? { id: "mock-model", providerID: "mock-provider" }, cost: session.cost ?? 0, tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { @@ -157,7 +359,67 @@ function v2Session(session: { id: string } & Record, fallbackDi directory: typeof session.directory === "string" ? session.directory : fallbackDirectory, ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}), }, - ...(typeof session.path === "string" ? { subpath: session.path } : {}), + 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: [] }, + }, + ] + }), } } @@ -181,3 +443,18 @@ 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 55420485f3..b0e3b74c6d 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" + path: "/global/event" | "/event" | "/api/event" headers: Record openedAt: number endedAt?: number @@ -93,6 +93,21 @@ 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, @@ -140,15 +155,13 @@ 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) => ({ - delivery, - bytes: encoder.encode(frame(delivery.payload, delivery.options)), - })) + 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)) } + }) encoded.forEach((item) => marker(item.delivery.options?.marker)) if (input.burst) { - const bytes = encoder.encode( - encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""), - ) + const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join("")) connection.controller.enqueue(bytes) return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id)) } @@ -161,7 +174,10 @@ 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")) + if ( + url.origin !== server || + (url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event") + ) return originalFetch(request) const id = ++nextConnectionID @@ -177,6 +193,18 @@ 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 5475299c41..2dd4a05d86 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.18.2", + "version": "1.18.11", "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 --only-failures --preload ./happydom.ts ./src", + "test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src", "test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser", - "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", + "test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src", "test:e2e": "playwright test", "test:e2e:local": "playwright test", "test:e2e:ui": "playwright test --ui", @@ -53,6 +53,7 @@ "@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:*", @@ -81,7 +82,7 @@ "diff": "catalog:", "effect": "catalog:", "fuzzysort": "catalog:", - "ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553", + "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", "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 6828e60f84..d45af34c43 100644 --- a/packages/app/src/addons/serialize.test.ts +++ b/packages/app/src/addons/serialize.test.ts @@ -37,6 +37,14 @@ 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 3823fb443a..515153488c 100644 --- a/packages/app/src/addons/serialize.ts +++ b/packages/app/src/addons/serialize.ts @@ -89,6 +89,13 @@ 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 // ============================================================================ @@ -544,7 +551,8 @@ export class SerializeAddon implements ITerminalAddon { return "" } - let content = options?.range + let content = !options?.excludeModes && getTerminalMode(this._terminal, 2031) ? "\u001b[?2031h" : "" + 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 5592e2f4ea..f47c432e42 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -9,7 +9,16 @@ 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, useNavigate, useParams, useSearchParams } from "@solidjs/router" +import { + type BaseRouterProps, + Navigate, + Route, + Router, + useLocation, + 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" @@ -29,6 +38,7 @@ 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" @@ -57,7 +67,8 @@ import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } import { createSessionLineage } from "@/pages/session/session-lineage" import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" -import { NewHome, LegacyHome } from "@/pages/home" +import { NewHome } from "@/pages/home" +import { LegacyHome } from "@/pages/home/legacy-home" const NewSession = lazy(() => import("@/pages/new-session")) @@ -226,6 +237,30 @@ 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 b61a61977e..8014ea1c43 100644 --- a/packages/app/src/components/command-palette.ts +++ b/packages/app/src/components/command-palette.ts @@ -1,17 +1,20 @@ -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 { useCommand, type CommandOption } from "@/context/command" +import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command" import { useFile } from "@/context/file" +import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" -import { useLayout } from "@/context/layout" -import { useServerSDK, type ServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" +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 { createSessionTabs } from "@/pages/session/helpers" import { useSessionLayout } from "@/pages/session/session-layout" -import { decode64 } from "@/utils/base64" +import { normalizeSessionInfo } from "@/utils/session" export type CommandPaletteEntry = { id: string @@ -24,6 +27,8 @@ export type CommandPaletteEntry = { path?: string directory?: string sessionID?: string + server?: ServerConnection.Key + project?: LocalProject archived?: number updated?: number } @@ -75,28 +80,25 @@ 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 serverSync = useServerSync() - const { params, tabs } = useSessionLayout() + const serverCtx = global.ensureServerCtx(serverSDK.server) + const appTabs = useTabs() + const { tabs: sessionTabs } = 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 command.options.filter( - (option) => - !option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open", - ) + return commandPaletteOptions(command.options) }) const commandEntries = createMemo(() => { const category = language.t("palette.group.commands") - return allowedCommands().map((option) => createCommandEntry(option, category)) + return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category)) }) const preferredCommandEntries = createMemo(() => { const all = allowedCommands() @@ -105,11 +107,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) => createCommandEntry(option, category)) + return sorted.map((option) => createCommandPaletteCommandEntry(option, category)) }) const tabState = createSessionTabs({ - tabs, + tabs: sessionTabs, pathFromTab: file.pathFromTab, normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), }) @@ -140,36 +142,11 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on .map((path) => createCommandPaletteFileEntry(path, category)) }) - 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 }), + 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 }), untitled: () => language.t("command.session.new"), category: () => language.t("command.category.session"), }) @@ -191,8 +168,17 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on return } if (item.type === "session") { - if (!item.directory || !item.sessionID) return - navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`) + 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) return } if (!item.path) return @@ -218,7 +204,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on } } -function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry { +export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry { return { id: "command:" + option.id, type: "command", @@ -230,96 +216,66 @@ function createCommandEntry(option: CommandOption, category: string): CommandPal } } -function createSessionEntries(props: { - workspaces: () => string[] - label: (directory: string) => string - load: (directory: string) => ReturnType +export function createServerSessionEntries(props: { + server: ServerConnection.Key + opened: () => LocalProject[] + stored: () => Project[] + load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }> untitled: () => string category: () => string }) { - const state: { - token: number - inflight: Promise | undefined - cached: CommandPaletteEntry[] | undefined - } = { token: 0, inflight: undefined, cached: undefined } + let abort: AbortController | undefined - return (text: string) => { - if (!text.trim()) { - state.token += 1 - state.inflight = undefined - state.cached = undefined - return [] as CommandPaletteEntry[] + onCleanup(() => abort?.abort()) + + return async (text: string): Promise => { + const search = text.trim() + if (!search) { + abort?.abort() + return [] } - 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 - }) + 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, + } + }), + ) .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 e55e128b82..adadeda303 100644 --- a/packages/app/src/components/debug-bar.tsx +++ b/packages/app/src/components/debug-bar.tsx @@ -5,6 +5,7 @@ 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?: { @@ -65,7 +66,7 @@ function Cell(props: {
- {props.label} -
-
- {props.value} +
+ {props.label} +
+
+ {props.value} +
) @@ -107,8 +116,55 @@ 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({ @@ -116,6 +172,7 @@ 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, @@ -142,6 +199,16 @@ 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 @@ -490,8 +557,11 @@ export function DebugBar(props: { inline?: boolean } = {}) { bad={bad(heap(), 0.8)} dim={state.heap.used === undefined} inline={props.inline} - wide + wide={!platform.setForceFocus} /> + {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 b1cafe1e7a..e996fd0be7 100644 --- a/packages/app/src/components/dialog-command-palette-v2.tsx +++ b/packages/app/src/components/dialog-command-palette-v2.tsx @@ -5,13 +5,20 @@ 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 { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js" -import { formatKeybindParts } from "@/context/command" +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 { 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" @@ -30,9 +37,6 @@ 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()] @@ -41,16 +45,104 @@ 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.filter((entry) => matchesEntry(entry, q)), + ...nextSessions, ...files.map((path) => createCommandPaletteFileEntry(path, category)), ] } - const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] }) + 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[] }) // 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() @@ -59,7 +151,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v }) createEffect(() => { - palette.highlight(activeEntry()) + props.highlight(activeEntry()) }) let resultsRef: HTMLDivElement | undefined @@ -86,12 +178,12 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v } if (event.key === "Enter") { event.preventDefault() - palette.select(activeEntry()) + props.select(activeEntry()) return } if (event.key === "Escape") { event.preventDefault() - palette.close() + props.close() } } @@ -105,7 +197,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v autocomplete="off" spellcheck={false} appearance="large" - placeholder={palette.language.t("palette.search.placeholder")} + placeholder={props.placeholder} leadingIcon={} onInput={(event) => setQuery(event.currentTarget.value)} onKeyDown={handleKeyDown} @@ -117,7 +209,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v when={visibleEntries().length > 0} fallback={
- {entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")} + {entries.loading ? language.t("common.loading") : language.t("palette.empty")}
} > @@ -132,9 +224,14 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v setActive(visibleEntries().findIndex((entry) => entry.id === item.id))} - onSelect={() => palette.select(item)} + onSelect={() => props.select(item)} /> )} @@ -153,13 +250,19 @@ 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 6499642b38..0267b4b4ef 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 { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" +import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -9,6 +9,9 @@ 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, @@ -16,6 +19,8 @@ import { createEffect, createMemo, createResource, + createUniqueId, + For, Match, onCleanup, onMount, @@ -23,14 +28,18 @@ import { Switch, } from "solid-js" import { createStore, produce } from "solid-js/store" -import { Link } from "@/components/link" +import { useParams } from "@solidjs/router" +import { ExternalLink } from "@/components/external-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 }) @@ -50,32 +59,22 @@ 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) } - return ( - - back.current()} - aria-label={language.t("common.goBack")} - /> - - } - > + function Content() { + return ( - + {(provider) => ( @@ -88,15 +87,77 @@ 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 }) { - const providers = useProviders(props.directory) +function ProviderPicker(props: { + directory?: Accessor + onSelect: (provider: string) => void + onPrepare?: () => void +}) { + const settings = useSettings() + if (settings.general.newLayoutDesigns()) + return + const providers = useProviders(() => props.directory?.()) const language = useLanguage() const popularGroup = () => language.t("dialog.provider.group.popular") const otherGroup = () => language.t("dialog.provider.group.other") @@ -163,6 +224,157 @@ function ProviderPicker(props: { directory?: Accessor; onSel ) } +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 @@ -172,8 +384,16 @@ function ProviderConnection(props: { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() + const params = useParams() const language = useLanguage() - const providers = useProviders(props.directory) + 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 alive = { value: true } const timer = { current: undefined as ReturnType | undefined } @@ -188,28 +408,32 @@ 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: "api" as const, + type: "key" as const, label: language.t("provider.connect.method.apiKey"), }, ]) - 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 [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 loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider]) - const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[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 [store, setStore] = createStore({ methodIndex: undefined as undefined | number, - authorization: undefined as undefined | ProviderAuthAuthorization, + authorization: undefined as undefined | IntegrationOauthConnectOutput["data"], promptInputs: undefined as undefined | Record, state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", error: undefined as string | undefined, @@ -221,7 +445,7 @@ function ProviderConnection(props: { | { type: "auth.prompt" } | { type: "auth.inputs"; inputs: Record } | { type: "auth.pending" } - | { type: "auth.complete"; authorization: ProviderAuthAuthorization } + | { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] } | { type: "auth.error"; error: string } function dispatch(action: Action) { @@ -275,10 +499,20 @@ function ProviderConnection(props: { const methodLabel = (value?: { type?: string; label?: string }) => { if (!value) return "" - if (value.type === "api") return language.t("provider.connect.method.apiKey") + if (value.type === "key") 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 @@ -306,46 +540,22 @@ 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() - .client.provider.oauth.authorize( - { - providerID: props.provider, - method: index, - inputs, - }, - { throwOnError: true }, - ) + .api.integration.oauth.connect({ + integrationID: props.provider, + methodID: method.id, + inputs: inputs ?? {}, + location: location(), + }) .then((x) => { if (!alive.value) return - 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! }) + dispatch({ type: "auth.complete", authorization: x.data }) }) .catch((e) => { if (!alive.value) return @@ -360,9 +570,9 @@ function ProviderConnection(props: { index: 0, }) - const prompts = createMemo>(() => { + const prompts = createMemo(() => { const value = method() - return value?.prompts ?? [] + return value?.type === "oauth" ? (value.prompts ?? []) : [] }) const matches = (prompt: NonNullable[number]>, value: Record) => { if (!prompt.when) return true @@ -393,10 +603,6 @@ function ProviderConnection(props: { setFormStore("index", next) return } - if (method()?.type === "api") { - dispatch({ type: "auth.inputs", inputs: value }) - return - } await selectMethod(store.methodIndex, value) } @@ -498,7 +704,9 @@ function ProviderConnection(props: { }) async function complete() { - await serverSDK().client.global.dispose() + await serverSync() + .refreshProviders() + .catch(() => undefined) dialog.close() showToast({ variant: "success", @@ -519,6 +727,37 @@ 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 ( <>
@@ -531,7 +770,7 @@ function ProviderConnection(props: { listRef = ref }} items={methods} - key={(m) => m?.label} + key={(m) => m?.label ?? m?.type} onSelect={async (selected, index) => { if (!selected) return void selectMethod(index) @@ -552,11 +791,18 @@ 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() @@ -570,17 +816,67 @@ function ProviderConnection(props: { } setFormStore("error", undefined) - await serverSDK().client.auth.set({ - providerID: props.provider, - auth: { - type: "api", - key: apiKey, - ...(store.promptInputs ? { metadata: store.promptInputs } : {}), - }, + await serverSDK().api.integration.connect.key({ + integrationID: props.provider, + location: location(), + key: apiKey, }) 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 (
@@ -590,9 +886,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")}
@@ -605,7 +901,8 @@ function ProviderConnection(props: {
{ + if (!newLayout()) return + codeInput?.focus({ preventScroll: true }) + }) + async function handleSubmit(e: SubmitEvent) { e.preventDefault() @@ -643,12 +947,13 @@ function ProviderConnection(props: { setFormStore("error", undefined) const result = await serverSDK() - .client.provider.oauth.callback({ - providerID: props.provider, - method: store.methodIndex, + .api.integration.oauth.complete({ + integrationID: props.provider, + attemptID: store.authorization!.attemptID, + location: location(), code, }) - .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) + .then(() => ({ ok: true as const })) .catch((error) => ({ ok: false as const, error })) if (result.ok) { await complete() @@ -657,16 +962,59 @@ 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 })}
{ - void (async () => { + const poll = async () => { + const authorization = store.authorization + if (!authorization || !alive.value) return const result = await serverSDK() - .client.provider.oauth.callback({ - providerID: props.provider, - method: store.methodIndex, + .api.integration.oauth.status({ + integrationID: props.provider, + attemptID: authorization.attemptID, + location: location(), }) - .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) + .then((value) => ({ ok: true as const, status: value.data })) .catch((error) => ({ ok: false as const, error })) - if (!alive.value) return - if (!result.ok) { - const message = formatError(result.error, language.t("common.requestFailed")) - dispatch({ type: "auth.error", error: message }) + dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) }) return } - - await complete() - })() + 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() }) 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")} @@ -750,8 +1121,12 @@ function ProviderConnection(props: {
-
-
+
+
@@ -783,15 +1158,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 363a2e390a..e34e4c39b8 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 { Link } from "@/components/link" +import { ExternalLink } from "@/components/external-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() { +export function CustomProviderForm(props: { autofocus?: boolean } = {}) { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() @@ -131,6 +131,7 @@ export function CustomProviderForm() { 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) @@ -184,15 +185,15 @@ export function CustomProviderForm() {

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

{ const dir = base64Encode(sdk().directory) sdk() - .client.session.fork({ sessionID, messageID: item.id }) + .api.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.data.id }) - navigate(`/${dir}/session/${forked.data.id}`) + prompt.set(restored, undefined, { dir, id: forked.id }) + navigate(`/${dir}/session/${forked.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 69d46ddcb6..f3376ad35f 100644 --- a/packages/app/src/components/dialog-select-directory-v2.tsx +++ b/packages/app/src/components/dialog-select-directory-v2.tsx @@ -8,6 +8,7 @@ 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, @@ -28,6 +29,7 @@ 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 @@ -67,11 +69,13 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) const [fallbackPath] = createResource( () => (missingBase() ? true : undefined), - () => - sdk.client.path + async (): Promise => { + if ((await sdk.protocol) !== "v1") return + return sdk.client.path .get() .then((result) => result.data) - .catch(() => undefined), + .catch(() => undefined) + }, { initialValue: undefined }, ) const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") @@ -85,18 +89,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { ) const search = createDirectorySearch({ sdk, home, base: () => root() || start() }) const [suggestions] = createResource(input, async (value) => { - const typed = cleanPickerInput(value).replace(/\/+$/, "") + const cleaned = cleanPickerInput(value) + const typed = cleaned.replace(/\/+$/, "") const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "") - if (!typed || typed === current) return { query: value, items: [] } + if (!cleaned || (root() && 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 files = await sdk.client.find - .files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 }) - .then((result) => result.data ?? []) + 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) .catch(() => []) const results = [ ...directories, - ...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })), + ...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })), ] return { query: value, @@ -115,9 +127,14 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { existing ?? loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => { if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined) - return sdk.client.file - .list({ directory: absolute, path: "" }) - .then((result) => result.data ?? []) + return sdk.api.file + .list({ location: { directory: absolute } }) + .then((result) => + result.data.map((entry) => ({ + name: getFilename(entry.path.replace(/[\\/]+$/, "")), + type: entry.type, + })), + ) .catch(() => undefined) }) listings.set(key, request) @@ -312,6 +329,7 @@ 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 0ce9f18c16..2d0c3a7305 100644 --- a/packages/app/src/components/dialog-select-model-unpaid-v2.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid-v2.tsx @@ -1,23 +1,26 @@ 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 { popularProviders, useProviders } from "@/hooks/use-providers" +import { 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() @@ -28,6 +31,7 @@ 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) => { @@ -62,111 +66,110 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro }) return ( - + {language.t("dialog.model.select.title")} -
- - -
-
-
-
- {language.t("dialog.model.unpaid.freeModels.title")} -
+ +
+
+
+
+ {language.t("dialog.model.unpaid.freeModels.title")}
- - {(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) => ( - - )} - + + {(item) => ( + + } + > + + )} + +
+ +
+
+
+
+ {language.t("dialog.model.unpaid.addMore.title")}
+
+ featuredProviders.includes(provider.id)) + .sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))} + > + {(provider) => ( + + )} + + +
- +
) diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 4fb7891ec9..9066f72434 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -1,15 +1,5 @@ import { Popover as Kobalte } from "@kobalte/core/popover" -import { - Component, - ComponentProps, - createEffect, - createMemo, - For, - JSX, - onCleanup, - Show, - ValidComponent, -} from "solid-js" +import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js" import { createStore } from "solid-js/store" import { useLocal } from "@/context/local" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -29,6 +19,7 @@ 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" @@ -122,14 +113,13 @@ 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 - children?: JSX.Element - triggerAs?: ValidComponent - triggerProps?: ModelSelectorTriggerProps + trigger: ModelSelectorTrigger onClose?: (cause: "escape" | "select") => void }) { const [store, setStore] = createStore<{ @@ -174,9 +164,7 @@ 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 [store, setStore] = createStore({ open: false, search: "", active: "" }) - let searchRef: HTMLInputElement | undefined - let contentRef: HTMLDivElement | undefined - let restoreTrigger = true + const controller = createModelSelectorController({ + model: props.model, + provider: () => props.provider, + onSelect: () => props.onClose?.(), + }) + 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) => (props.provider ? item.provider.id === props.provider : true)), + .filter((item) => (input.provider() ? item.provider.id === input.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 [...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 + 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())) + const keys = () => [...models().map(modelKey), manageKey] const initialActive = () => { - const selected = current() + const selected = props.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) { - restoreTrigger = true + dismiss.allowTriggerRestore() setStore({ open: true, active: initialActive() }) setTimeout(() => requestAnimationFrame(() => { @@ -308,23 +331,15 @@ export function ModelSelectorPopoverV2(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) => { - restoreTrigger = false + dismiss.preventTriggerRestore() setOpen(false) - afterClose(() => select(item)) + dismiss.afterClose(() => props.select(item)) } const manage = () => { - restoreTrigger = false + dismiss.preventTriggerRestore() setOpen(false) - afterClose(() => { - void import("./dialog-manage-models").then((x) => { - dialog.show(() => ) - }) - }) + dismiss.afterClose(props.onManage) } const selectActive = () => { const item = models().find((item) => modelKey(item) === store.active) @@ -343,10 +358,7 @@ export function ModelSelectorPopoverV2(props: { queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" })) } const setSearch = (value: string) => { - 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])) + const first = props.models(value)[0] setStore({ search: value, active: first ? modelKey(first) : manageKey }) } @@ -362,18 +374,14 @@ export function ModelSelectorPopoverV2(props: { return ( - - {props.children} - + (contentRef = el)} + ref={(element: HTMLDivElement) => (contentRef = element)} class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none" - onPointerDownOutside={() => (restoreTrigger = false)} - onFocusOutside={() => (restoreTrigger = false)} - onCloseAutoFocus={(event) => { - if (!restoreTrigger) event.preventDefault() - }} + onPointerDownOutside={dismiss.preventTriggerRestore} + onFocusOutside={dismiss.preventTriggerRestore} + onCloseAutoFocus={dismiss.onCloseAutoFocus} >
@@ -393,9 +401,9 @@ export function ModelSelectorPopoverV2(props: { event.stopPropagation() if (event.key === "Escape") { event.preventDefault() - restoreTrigger = false + dismiss.preventTriggerRestore() setOpen(false) - afterClose(() => props.onClose?.()) + dismiss.afterClose(props.onClose) return } if (event.altKey || event.metaKey) return @@ -445,7 +453,7 @@ export function ModelSelectorPopoverV2(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 0876906890..aa16976228 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -16,6 +16,7 @@ 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" @@ -263,6 +264,13 @@ 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) { @@ -307,6 +315,13 @@ 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 { @@ -344,7 +359,10 @@ export function useServerManagementController(options: { onSelect?: () => void; ) const sortedItems = createMemo(() => { - const list = items() + const raw = items() + const list = settings.general.newLayoutDesigns() + ? raw + : raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2") 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 e428d4c2bb..bf5da751e2 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.openLink(props.link) + if (props.link) platform.openExternal(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 5746410610..1bc9af0833 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 = { - client: { - find: { - files: (input: { directory: string }) => { - directories.push(input.directory) + api: { + file: { + find: (input: { location?: { directory?: string } }) => { + directories.push(input.location?.directory ?? "") return Promise.resolve({ data: [] }) }, }, @@ -152,6 +152,29 @@ 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 9900265962..9539ae1d01 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,14 +342,17 @@ 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.client.file - .list({ directory: key, path: "" }) - .then((result) => result.data ?? []) + const request = args.sdk.api.file + .list({ location: { directory: key } }) + .then((result) => result.data) .catch(() => []) .then((nodes) => nodes .filter((node) => node.type === "directory") - .map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })), + .map((node) => { + const relative = trimPickerPath(normalizePickerDrive(node.path)) + return { name: getFilename(relative), absolute: joinPickerPath(key, relative) } + }), ) cache.set(key, request) return request @@ -371,9 +374,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.client.find - .files({ directory: input.directory, query, type: "directory", limit: 50 }) - .then((result) => result.data ?? []) + 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)) .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 3ec999da06..0c5d576e93 100644 --- a/packages/app/src/components/edit-project.ts +++ b/packages/app/src/components/edit-project.ts @@ -1,6 +1,7 @@ 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" @@ -70,13 +71,26 @@ export function createEditProjectModel(props: { project: LocalProject; server: S const start = store.startup.trim() if (props.project.id && props.project.id !== "global") { - await serverCtx().sdk.client.project.update({ - projectID: props.project.id, - directory: props.project.worktree, - name, - icon: { color: store.color || "", override: store.iconOverride || "" }, - commands: { start }, - }) + if ((await serverCtx().sdk.protocol) !== "v1") return + const project = await serverCtx() + .sdk.client.project.update({ + projectID: props.project.id, + directory: props.project.worktree, + name, + icon: { color: store.color || "", override: store.iconOverride || "" }, + commands: { start }, + }) + .then((result) => result.data) + if (!project) return + // const project = await serverCtx().sdk.api.project.update({ + // projectID: props.project.id, + // name, + // icon: { color: store.color || "", override: store.iconOverride || "" }, + // commands: { start }, + // }) + serverCtx().sync.set("project", (items) => + items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)), + ) serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined) dialog.close() return diff --git a/packages/app/src/components/external-link.tsx b/packages/app/src/components/external-link.tsx new file mode 100644 index 0000000000..133e752eab --- /dev/null +++ b/packages/app/src/components/external-link.tsx @@ -0,0 +1,21 @@ +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 23a955a837..18dd727084 100644 --- a/packages/app/src/components/help-button.tsx +++ b/packages/app/src/components/help-button.tsx @@ -16,6 +16,7 @@ export function TabsInfoPopup() { const settings = useSettings() const platform = usePlatform() const [drawerOpen, setDrawerOpen] = createSignal(false) + const windows = () => platform.platform === "desktop" && platform.os === "windows" return ( @@ -70,12 +71,40 @@ export function TabsInfoPopup() {
- -
+ + + } + class="absolute top-[10px] left-[-36px]" + /> + +

July 14

- + , "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 new file mode 100644 index 0000000000..96b99aaf4a --- /dev/null +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -0,0 +1,586 @@ +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 362a741401..0b9c26ce41 100644 --- a/packages/app/src/components/prompt-input.stories.tsx +++ b/packages/app/src/components/prompt-input.stories.tsx @@ -30,8 +30,16 @@ 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: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }), + current: () => storyModel, + list: () => [storyModel], + visible: () => true, + set: () => {}, variant: { list: () => ["fast", "thinking"], current: () => controls.variant, @@ -67,7 +75,6 @@ function PromptInputExample() { open: () => setControls("reviewOpen", true), }, }, - newLayoutDesigns: true, } const addReviewComment = () => { const comment = controls.comments + 1 @@ -146,7 +153,6 @@ 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 a820067a4e..08c1ee3489 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -13,8 +13,6 @@ 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, @@ -49,7 +47,6 @@ 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" @@ -60,121 +57,34 @@ 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 { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit" +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 { 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 { promptPlaceholder } from "./prompt-input/placeholder" +import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/placeholder" import { createPromptInputTransientState } from "./prompt-input/transient-state" import { showToast } from "@/utils/toast" import { ImagePreview } from "@opencode-ai/ui/image-preview" import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" -export type PromptInputState = ReturnType - -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 -} +export { createPromptInputHistory } +export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission } const EXAMPLES = [ "prompt.example.1", @@ -681,7 +591,7 @@ export const PromptInput: Component = (props) => { type: "resource", name: resource.name, uri: resource.uri, - client: resource.client, + client: resource.server, display: resource.name, description: resource.description, mime: resource.mimeType, @@ -799,7 +709,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] @@ -1191,28 +1101,6 @@ 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()) } @@ -1539,50 +1427,11 @@ 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)} @@ -1607,517 +1456,281 @@ export const PromptInput: Component = (props) => { onSlashMenuKeyDown={handleSlashMenuKeyDown} commandKeybind={command.keybind} commandKeybindParts={command.keybindParts} - newLayoutDesigns={props.controls.newLayoutDesigns} + newLayoutDesigns={false} t={(key) => language.t(key as Parameters[0])} /> - - -
- - - !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() - }} - > -
(scrollRef = el)}> -
-
- {designPlaceholder()} -
-
-
-
-
- {fileAttachmentInput()} - - {language.t("prompt.menu.addImagesAndFiles")} - - - } - > - - } - variant="ghost-muted" - size="large" - style={buttons()} - disabled={store.mode !== "normal"} - tabIndex={store.mode === "normal" ? undefined : -1} - aria-label={language.t("prompt.menu.addImagesAndFiles")} - /> - - - - {language.t("prompt.menu.imagesAndFiles")} - - - - {language.t("prompt.menu.commands")} - - - {language.t("prompt.menu.context")} - - setMode("shell")} shortcut="!"> - {language.t("prompt.menu.shellCommand")} - - - - - - - - - {props.toolbar} - - -
- - {language.t("command.model.variant.cycle")} - - - } - > - setStore("variantOpen", open)} - > - - - {props.controls.model.selection.variant.current() ?? language.t("common.default")} - - - - - - - - { - props.controls.model.selection.variant.set(value === "default" ? undefined : value) - restoreFocus() - }} - > - {variants().map((value) => ( - - {value === "default" ? language.t("common.default") : value} - - ))} - - - - - -
-
-
- - - -
- -
- - - + + { + 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 }} > - - { - const active = comments.active() - return !!item.commentID && item.commentID === active?.id && item.path === active?.file +
{ - 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} + style={{ "padding-bottom": space }} />
{ - const target = e.target - if (!(target instanceof HTMLElement)) return - if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) { - return - } - editorRef?.focus() + class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate" + classList={{ "font-mono!": store.mode === "shell" }} + style={{ "padding-bottom": space, display: prompt.dirty() ? "none" : undefined }} + > + {placeholder()} +
+
+ + - )} + ) + }} diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx index f945fa33c6..acd73ddbe5 100644 --- a/packages/app/src/components/settings-v2/providers.tsx +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -4,9 +4,9 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { showToast } from "@/utils/toast" import { popularProviders, useProviders } from "@/hooks/use-providers" -import { createMemo, type Component, For, Show } from "solid-js" +import { createMemo, type Accessor, type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" -import { useServerSDK } from "@/context/server-sdk" +import { useServerProtocol, useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider" import { DialogCustomProvider } from "../dialog-custom-provider" @@ -29,17 +29,21 @@ const PROVIDER_NOTES = [ const PROVIDER_ICON_SIZE = 16 -export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) => { +export const SettingsProvidersV2: Component<{ + directory: Accessor + onBack?: () => void +}> = (props) => { const dialog = useDialog() const language = useLanguage() const serverSdk = useServerSDK() + const protocol = useServerProtocol() const serverSync = useServerSync() - const providers = useProviders() + const providers = useProviders(props.directory) const providerConnect = useProviderConnectController({ onBack: props.onBack }) const connect = (provider?: string) => { providerConnect.select(provider) - void dialog.show(() => ) + void dialog.show(() => ) } const connected = createMemo(() => { @@ -77,7 +81,8 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) = return language.t("settings.providers.tag.other") } - const canDisconnect = (item: ProviderItem) => source(item) !== "env" + const canDisconnect = (item: ProviderItem) => + source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id)) const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key @@ -90,6 +95,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) = } const disableProvider = async (providerID: string, name: string) => { + if (protocol() !== "v1") return const before = serverSync().data.config.disabled_providers ?? [] const next = before.includes(providerID) ? before : [...before, providerID] serverSync().set("config", "disabled_providers", next) @@ -218,33 +224,37 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) = )} -
-
- -
-
- {language.t("provider.custom.title")} - {language.t("settings.providers.tag.custom")} + +
+
+ +
+
+ {language.t("provider.custom.title")} + {language.t("settings.providers.tag.custom")} +
+

+ {language.t("settings.providers.custom.description")} +

-

{language.t("settings.providers.custom.description")}

+ { + dialog.show(() => ) + }} + > + {language.t("common.connect")} +
- { - dialog.show(() => ) - }} - > - {language.t("common.connect")} - -
+
- -
-
- 0} - fallback={
{pluginEmpty()}
} - > - - {(plugin) => ( -
-
- {plugin} -
- )} - - + + +
+
+ 0} + fallback={
{pluginEmpty()}
} + > + + {(plugin) => ( +
+
+ {plugin} +
+ )} + + +
-
-
+ +
) diff --git a/packages/app/src/components/status-popover-indicator.test.ts b/packages/app/src/components/status-popover-indicator.test.ts index e3c62d2a95..d6d6d07a1d 100644 --- a/packages/app/src/components/status-popover-indicator.test.ts +++ b/packages/app/src/components/status-popover-indicator.test.ts @@ -1,11 +1,21 @@ import { describe, expect, test } from "bun:test" -import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator" +import { + hasNonBlockingServiceIssue, + hasServiceNeedingAttention, + serverStatusDotClass, +} from "./status-popover-indicator" describe("serverStatusDotClass", () => { test("uses the success token while the server and services are healthy", () => { expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base") }) + test("uses the session attention token when a service needs attention", () => { + expect(serverStatusDotClass({ ready: true, serverHealth: true, attention: true, issue: true })).toBe( + "bg-v2-background-bg-accent", + ) + }) + test("uses the warning token for non-blocking issues while the server is online", () => { expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base") }) @@ -26,7 +36,7 @@ describe("hasNonBlockingServiceIssue", () => { expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true) expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true) expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true) - expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false) + expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false) }) test("detects LSP failures that do not block chatting", () => { @@ -34,3 +44,15 @@ describe("hasNonBlockingServiceIssue", () => { expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false) }) }) + +describe("hasServiceNeedingAttention", () => { + test("detects MCP states that need user attention", () => { + expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true) + expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true) + }) + + test("ignores states that do not need user attention", () => { + expect(hasServiceNeedingAttention({ mcp: ["failed"] })).toBe(false) + expect(hasServiceNeedingAttention({ mcp: ["connected", "pending", "disabled"] })).toBe(false) + }) +}) diff --git a/packages/app/src/components/status-popover-indicator.ts b/packages/app/src/components/status-popover-indicator.ts index efb7473753..62fc3bedbf 100644 --- a/packages/app/src/components/status-popover-indicator.ts +++ b/packages/app/src/components/status-popover-indicator.ts @@ -1,18 +1,29 @@ -import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client" +import type { LspStatus } from "@opencode-ai/sdk/v2/client" +import type { McpServer } from "@opencode-ai/client/promise" + +export function hasServiceNeedingAttention(input: { mcp: Array }) { + return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration") +} export function hasNonBlockingServiceIssue(input: { - mcp: Array + mcp: Array lsp: Array }) { return ( - input.mcp.some((status) => status !== "connected" && status !== "disabled") || + input.mcp.some((status) => status !== "connected" && status !== "pending" && status !== "disabled") || input.lsp.some((status) => status === "error") ) } -export function serverStatusDotClass(input: { ready: boolean; serverHealth: boolean | undefined; issue: boolean }) { +export function serverStatusDotClass(input: { + ready: boolean + serverHealth: boolean | undefined + attention?: boolean + issue: boolean +}) { if (input.serverHealth === false) return "bg-icon-critical-base" if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base" + if (input.attention) return "bg-v2-background-bg-accent" if (input.issue) return "bg-icon-warning-base" if (input.serverHealth === true) return "bg-icon-success-base" return "bg-border-weak-base" diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 2aed45702a..767e7e62c6 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -9,7 +9,11 @@ import { ServerConnection, useServer } from "@/context/server" import { useServerSDK } from "@/context/server-sdk" import { useSync } from "@/context/sync" import { useGlobal } from "@/context/global" -import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator" +import { + hasNonBlockingServiceIssue, + hasServiceNeedingAttention, + serverStatusDotClass, +} from "./status-popover-indicator" const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody }))) const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody }))) @@ -22,6 +26,11 @@ export function StatusPopover() { const [shown, setShown] = createSignal(false) const serverHealth = () => global.servers.health[server.key]?.healthy const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) + const attention = createMemo(() => + hasServiceNeedingAttention({ + mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), + }), + ) const issue = createMemo(() => hasNonBlockingServiceIssue({ mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), @@ -49,6 +58,7 @@ export function StatusPopover() { class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({ ready: ready(), serverHealth: serverHealth(), + attention: attention(), issue: issue(), })}`} /> @@ -85,6 +95,11 @@ function DirectoryStatusPopover() { const [shown, setShown] = createSignal(false) const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) + const attention = createMemo(() => + hasServiceNeedingAttention({ + mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), + }), + ) const issue = createMemo(() => hasNonBlockingServiceIssue({ mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), @@ -95,6 +110,7 @@ function DirectoryStatusPopover() { shown: shown(), ready: ready(), serverHealth: serverHealth(), + attention: attention(), issue: issue(), label: language.t("status.popover.trigger"), onOpenChange: setShown, @@ -118,6 +134,7 @@ function ServerStatusPopover() { shown: shown(), ready: serverHealth() !== undefined, serverHealth: serverHealth(), + attention: false, issue: false, label: language.t("status.popover.trigger"), onOpenChange: setShown, @@ -135,6 +152,7 @@ type StatusPopoverState = { shown: boolean ready: boolean serverHealth: boolean | undefined + attention: boolean issue: boolean label: string onOpenChange: (value: boolean) => void diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 5a0b510787..7d25e7c649 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -178,7 +178,6 @@ export const Terminal = (props: TerminalProps) => { // Terminal captures its connection for the PTY lifetime, so callers must key it per server/session. const connection = useServerSDK()().server const directory = sdk().directory - const client = sdk().client const url = sdk().url const auth = connection.http const username = auth?.username ?? "opencode" @@ -241,10 +240,21 @@ export const Terminal = (props: TerminalProps) => { } } - const pushSize = (cols: number, rows: number) => { - return client.pty - .update({ + const pushSize = async (cols: number, rows: number) => { + if ((await sdk().protocol) === "v1") { + return sdk() + .client.pty.update({ + ptyID: id, + size: { cols, rows }, + }) + .catch((err) => { + debugTerminal("failed to sync terminal size", err) + }) + } + return sdk() + .api.pty.update({ ptyID: id, + location: { directory }, size: { cols, rows }, }) .catch((err) => { @@ -292,7 +302,12 @@ export const Terminal = (props: TerminalProps) => { const scheduleSize = (cols: number, rows: number) => { if (disposed) return - if (lastSize?.cols === cols && lastSize?.rows === rows) return + if (lastSize?.cols === cols && lastSize?.rows === rows) { + pendingSize = undefined + if (sizeTimer !== undefined) clearTimeout(sizeTimer) + sizeTimer = undefined + return + } pendingSize = { cols, rows } @@ -317,8 +332,10 @@ export const Terminal = (props: TerminalProps) => { createEffect(() => { const colors = terminalColors() + const mode = theme.mode() === "dark" ? "dark" : "light" if (!term) return setOptionIfSupported(term, "theme", colors) + setOptionIfSupported(term, "colorScheme", mode) }) createEffect(() => { @@ -365,7 +382,11 @@ export const Terminal = (props: TerminalProps) => { event.preventDefault() event.stopImmediatePropagation() - platform.openLink(text) + if (URL.canParse(text) && new URL(text).protocol === "file:" && platform.openLocalFile) { + platform.openLocalFile(text) + return + } + platform.openExternal(text) } onMount(() => { @@ -396,6 +417,7 @@ export const Terminal = (props: TerminalProps) => { } _ghostty = g term = t + setOptionIfSupported(t, "colorScheme", theme.mode() === "dark" ? "dark" : "light") output = terminalWriter((data, done) => t.write(data, () => { done?.() @@ -514,34 +536,54 @@ export const Terminal = (props: TerminalProps) => { local.onConnectError?.(err) } - const gone = () => - client.pty - .get({ ptyID: id }, { throwOnError: false }) - .then((result) => result.response.status === 404) + const gone = async () => { + if ((await sdk().protocol) === "v1") { + return sdk() + .client.pty.get({ ptyID: id }, { throwOnError: false }) + .then((result) => result.response.status === 404) + .catch((err) => { + debugTerminal("failed to inspect terminal session", err) + return false + }) + } + return sdk() + .api.pty.get({ ptyID: id, location: { directory } }) + .then((result) => result.data.status === "exited") .catch((err) => { + if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true debugTerminal("failed to inspect terminal session", err) return false }) + } const connectToken = async () => { - const result = await client.pty - .connectToken( - { ptyID: id, directory }, - { - throwOnError: false, - headers: { "x-opencode-ticket": "1" }, - }, - ) - .catch((err: unknown) => { - if (err instanceof Error && err.message.includes("Request is not supported")) return - throw err - }) - if (!result) return - if (result.response.status === 200 && result.data?.ticket) return result.data.ticket - if (result.response.status === 404 || result.response.status === 405) return - if (result.response.status === 403) - throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") - throw new Error(`PTY connect ticket failed with ${result.response.status}`) + if ((await sdk().protocol) === "v1") { + const result = await sdk() + .client.pty.connectToken( + { ptyID: id, directory }, + { + throwOnError: false, + headers: { "x-opencode-ticket": "1" }, + }, + ) + .catch((err: unknown) => { + if (err instanceof Error && err.message.includes("Request is not supported")) return + throw err + }) + if (!result) return + if (result.response.status === 200 && result.data?.ticket) return result.data.ticket + if (result.response.status === 404 || result.response.status === 405) return + if (result.response.status === 403) + throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") + throw new Error(`PTY connect ticket failed with ${result.response.status}`) + } + // return sdk() + // .api.pty.connectToken({ + // ptyID: id, + // location: { directory }, + // "x-opencode-ticket": "1", + // }) + // .then((result) => result.data.ticket) } const retry = (err: unknown) => { @@ -571,11 +613,14 @@ export const Terminal = (props: TerminalProps) => { fail(err) return undefined }) + const protocol = await sdk().protocol + // if (protocol === "v2" && !ticket) return if (once.value) return if (disposed) return const socket = new WebSocket( terminalWebSocketURL({ + protocol, url, id, directory, @@ -595,6 +640,7 @@ export const Terminal = (props: TerminalProps) => { tries = 0 local.onConnect?.() scheduleSize(t.cols, t.rows) + if (t.getMode(2031)) t.write("\x1b[?996n") } const handleMessage = (event: MessageEvent) => { diff --git a/packages/app/src/components/titlebar-tab-gesture.ts b/packages/app/src/components/titlebar-tab-gesture.ts index 329bf5e864..0aa5118936 100644 --- a/packages/app/src/components/titlebar-tab-gesture.ts +++ b/packages/app/src/components/titlebar-tab-gesture.ts @@ -12,6 +12,6 @@ export function forwardTabRef(ref: Ref | undefined, element: HTM if (typeof ref === "function") ref(element) } -export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) { - return !dragging && !editing && !committing +export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, pending: boolean) { + return !dragging && !editing && !pending } diff --git a/packages/app/src/components/titlebar-tab-nav.css b/packages/app/src/components/titlebar-tab-nav.css index 08bed7cf26..13dfbe04fc 100644 --- a/packages/app/src/components/titlebar-tab-nav.css +++ b/packages/app/src/components/titlebar-tab-nav.css @@ -9,53 +9,86 @@ justify-content: center; } +[data-titlebar-tab] { + --tab-base: var(--v2-background-bg-deep); + --tab-overlay: transparent; + background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base); +} + +[data-titlebar-tab]:is(:hover, :has(> [data-slot="tab-link"]:focus-visible)):not([data-state="pressed"]):not( + [data-dragging="true"] + ):not([data-editing="true"]) { + --tab-base: var(--v2-background-bg-layer-02); + --tab-overlay: var(--v2-overlay-simple-overlay-hover); +} + +[data-titlebar-tab]:is([data-state="pressed"], [data-dragging="true"], [data-editing="true"]) { + --tab-base: var(--v2-background-bg-layer-02); + --tab-overlay: var(--v2-overlay-simple-overlay-pressed); +} + +[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] { + background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base); +} + [data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] { display: none; } [data-titlebar-tab-list] { - gap: 13.5px; + gap: 6px; } [data-titlebar-tab-slot] { + --tab-separator: var(--v2-background-bg-layer-03); position: relative; } +[data-color-scheme="dark"] [data-titlebar-tab-slot] { + --tab-separator: var(--v2-background-bg-layer-02); +} + [data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before { content: ""; position: absolute; top: 8px; - left: -6.75px; + left: -3.75px; width: 1.5px; height: 12px; border-radius: 9999px; - background: var(--v2-background-bg-layer-02); + background: var(--tab-separator); } [data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before { display: none; } -[data-titlebar-tab] [data-slot="tab-close"]::before { - content: ""; - position: absolute; - top: 0; - height: 28px; - width: 16px; - pointer-events: none; - background: linear-gradient(to right, transparent, var(--tab-bg)); +[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover)::before, +[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover) + [data-titlebar-tab-slot]::before { display: none; } -[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before { - display: block; - right: 0; +[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] { + --tab-title-fade-offset: 4px; + -webkit-mask-image: linear-gradient( + to right, + black 0, + black calc(100% - var(--tab-title-fade-offset) - 16px), + transparent calc(100% - var(--tab-title-fade-offset)), + transparent 100% + ); + mask-image: linear-gradient( + to right, + black 0, + black calc(100% - var(--tab-title-fade-offset) - 16px), + transparent calc(100% - var(--tab-title-fade-offset)), + transparent 100% + ); } -[data-titlebar-tab][data-title-overflow="true"]:hover:not([data-editing="true"]) [data-slot="tab-close"]::before, -[data-titlebar-tab][data-title-overflow="true"][data-active="true"]:not([data-editing="true"]) - [data-slot="tab-close"]::before { - right: 100%; +[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"]) + [data-slot="tab-link"] { + --tab-title-fade-offset: 24px; } [data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"]) diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index a397046f9b..5003d0ed30 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -1,14 +1,13 @@ import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import { createResizeObserver } from "@solid-primitives/resize-observer" +import { createMutation } from "@tanstack/solid-query" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { useGlobal } from "@/context/global" -import { useLanguage } from "@/context/language" import { ServerConnection, serverName } from "@/context/server" import { displayName, projectForSession } from "@/pages/layout/helpers" import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" -import { showToast } from "@/utils/toast" import type { Session } from "@opencode-ai/sdk/v2" import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture" import { TabPreviewPopover } from "./titlebar-tab-popover" @@ -23,8 +22,7 @@ export function TabNavItem(props: { server: ServerConnection.Key session: () => Session | undefined fallbackTitle?: string - onTitleChange?: (title: string) => void - onTitleChangeFailed?: (title: string) => void + onRename: (title: string) => Promise onClose: () => void onNavigate: () => void active?: boolean @@ -34,13 +32,12 @@ export function TabNavItem(props: { pressed?: boolean hidden?: boolean }) { - const language = useLanguage() const [editing, setEditing] = createSignal(false) const [titleOverflowing, setTitleOverflowing] = createSignal(false) let tabRoot!: HTMLDivElement let titleEl!: HTMLSpanElement - let committing = false let measureFrame: number | undefined + const rename = createMutation(() => ({ mutationFn: props.onRename })) const closeTab = (event: MouseEvent) => { event.preventDefault() @@ -116,41 +113,20 @@ export function TabNavItem(props: { selection?.addRange(range) } - const rename = async (title: string) => { - const ctx = serverCtx() - const session = props.session() - if (!ctx || !session) return - const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true }) - await client.session.update({ sessionID: session.id, title }) - } - const closeRename = async (save: boolean) => { - if (committing || !editing()) return - committing = true + if (rename.isPending || !editing()) return const original = props.session()?.title ?? "" const next = (titleEl.textContent ?? "").trim() titleEl.scrollLeft = 0 - if (save && next && next !== original) props.onTitleChange?.(next) setEditing(false) if (!save || !next || next === original) { - committing = false return } - try { - await rename(next) - } catch (err) { - props.onTitleChangeFailed?.(original) - showToast({ - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : undefined, - }) - } - - committing = false + await rename.mutateAsync(next) } createEffect(() => { @@ -164,7 +140,7 @@ export function TabNavItem(props: { const openRename = (event: MouseEvent) => { event.preventDefault() event.stopPropagation() - if (!canOpenTabRename(props.dragging, editing(), committing)) return + if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return const session = props.session() if (!session) return titleEl.textContent = session.title @@ -204,11 +180,11 @@ export function TabNavItem(props: { data-slot="titlebar-tab-item" data-title-overflow={titleOverflowing()} data-editing={editing()} - class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]" + class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] px-1.5 [container-type:inline-size]" classList={{ invisible: props.hidden }} data-active={props.active} data-dragging={props.dragging} - data-pressed={props.pressed} + data-state={props.active || props.pressed ? "pressed" : undefined} onMouseDown={(event) => { if (event.button !== MIDDLE_MOUSE_BUTTON) return event.preventDefault() @@ -219,90 +195,89 @@ export function TabNavItem(props: { closeTab(event) }} > - - { + { + event.preventDefault() + event.stopPropagation() + }} + onMouseDown={(event) => { + // Navigate on mousedown to shave the press-release delay off tab switches. + if (event.button !== 0) return + if (editing()) return + if (props.suppressNavigation?.()) return + props.onNavigate() + }} + onClick={(event) => { + event.preventDefault() + // Mouse navigation already happened on mousedown; detail 0 means keyboard activation. + if (event.detail > 0) return + if (editing()) return + if (props.suppressNavigation?.()) return + props.onNavigate() + }} + class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]" + > + + + + { + titleEl = el + titleEl.textContent = title() ?? "" + }} + data-slot="tab-title" + data-titlebar-tab-title + class="min-w-0 flex-1 outline-none leading-4" + classList={{ + "overflow-hidden text-clip whitespace-nowrap": !editing(), + "select-text": editing(), + }} + contenteditable={editing() ? true : undefined} + onDblClick={openRename} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + void closeRename(true) + return + } + if (event.key !== "Escape") return event.preventDefault() + titleEl.textContent = props.session()?.title ?? "" + void closeRename(false) + }} + onBlur={() => void closeRename(true)} + onPointerDown={(event) => { + if (!editing()) return event.stopPropagation() }} - onMouseDown={(event) => { - // Navigate on mousedown to shave the press-release delay off tab switches. - if (event.button !== 0) return - if (editing()) return - if (props.suppressNavigation?.()) return - props.onNavigate() - }} onClick={(event) => { + if (!editing()) return event.preventDefault() - // Mouse navigation already happened on mousedown; detail 0 means keyboard activation. - if (event.detail > 0) return - if (editing()) return - if (props.suppressNavigation?.()) return - props.onNavigate() }} - class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]" - > - - - - { - titleEl = el - titleEl.textContent = title() ?? "" - }} - data-slot="tab-title" - data-titlebar-tab-title - class="min-w-0 flex-1 outline-none leading-4" - classList={{ - "overflow-hidden text-clip whitespace-nowrap": !editing(), - "select-text": editing(), - }} - contenteditable={editing() ? true : undefined} - onDblClick={openRename} - onKeyDown={(event) => { - event.stopPropagation() - if (event.key === "Enter") { - event.preventDefault() - void closeRename(true) - return - } - if (event.key !== "Escape") return - event.preventDefault() - titleEl.textContent = props.session()?.title ?? "" - void closeRename(false) - }} - onBlur={() => void closeRename(true)} - onPointerDown={(event) => { - if (!editing()) return - event.stopPropagation() - }} - onClick={(event) => { - if (!editing()) return - event.preventDefault() - }} - /> - - + /> + -
+
{ if (event.button !== MIDDLE_MOUSE_BUTTON) return @@ -407,7 +382,7 @@ export function DraftTabItem(props: { {props.title} -
+
{ + test("follows the visible left-to-right order", () => { + expect(adjacentTabKey(["c", "a", "b"], "c", 1)).toBe("a") + expect(adjacentTabKey(["c", "a", "b"], "a", -1)).toBe("c") + }) + + test("skips tabs omitted from the visible order", () => { + expect(adjacentTabKey(["a", "c"], "a", 1)).toBe("c") + expect(adjacentTabKey(["a", "c"], "c", 1)).toBe("a") + }) +}) + +test("merges reordered visible tabs around hidden tabs", () => { + expect(mergeVisibleTabOrder(["a", "hidden", "b", "c"], ["a", "b", "c"], ["c", "a", "b"])).toEqual([ + "c", + "hidden", + "a", + "b", + ]) +}) diff --git a/packages/app/src/components/titlebar-tab-order.ts b/packages/app/src/components/titlebar-tab-order.ts new file mode 100644 index 0000000000..a906b1fb32 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-order.ts @@ -0,0 +1,12 @@ +export function adjacentTabKey(order: string[], current: string | undefined, offset: -1 | 1) { + if (!current || order.length === 0) return + const index = order.indexOf(current) + if (index === -1) return + return order[(index + offset + order.length) % order.length] +} + +export function mergeVisibleTabOrder(all: string[], current: string[], next: string[]) { + const visible = new Set(current) + const reordered = next.values() + return all.map((key) => (visible.has(key) ? (reordered.next().value ?? key) : key)) +} diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx index 7ef53324cd..0a4b4da12d 100644 --- a/packages/app/src/components/titlebar-tab-strip.tsx +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -1,4 +1,5 @@ -import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js" +import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount, Show } from "solid-js" +import { createStore } from "solid-js/store" import { createResizeObserver } from "@solid-primitives/resize-observer" import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" import { isSortable, useSortable } from "@dnd-kit/solid/sortable" @@ -15,7 +16,10 @@ import { useCommand } from "@/context/command" import { useTabs } from "@/context/tabs" import { createTabPromptState } from "@/context/prompt" import { base64Encode } from "@opencode-ai/core/util/encode" +import { showToast } from "@/utils/toast" import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture" +import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order" +import type { Session } from "@opencode-ai/sdk/v2" function SessionTabSlot(props: { tab: SessionTab @@ -23,12 +27,12 @@ function SessionTabSlot(props: { index: () => number active: () => boolean forceTruncate: boolean - serverCtx: () => ServerCtx | undefined + session: () => Session | undefined + fallbackTitle?: string + onRename: (title: string) => Promise onNavigate: (element: HTMLDivElement) => void onClose: () => void }) { - const tabs = useTabs() - const language = useLanguage() const sortable = useSortable({ get id() { return props.id @@ -38,6 +42,47 @@ function SessionTabSlot(props: { }, }) let ref!: HTMLDivElement + + return ( +
+ { + ref = el + }} + href={tabHref(props.tab)} + server={props.tab.server} + session={props.session} + fallbackTitle={props.fallbackTitle} + onRename={props.onRename} + onNavigate={() => props.onNavigate(ref)} + onClose={props.onClose} + active={props.active()} + forceTruncate={props.forceTruncate} + dragging={sortable.isDragSource()} + /> +
+ ) +} + +function SessionTabEntry(props: { + tab: SessionTab + id: string + index: () => number + active: () => boolean + forceTruncate: boolean + serverCtx: () => ServerCtx | undefined + onVisibleChange: (visible: boolean) => void + onNavigate: (element: HTMLDivElement) => void + onClose: () => void +}) { + const tabs = useTabs() + const language = useLanguage() const sdk = createMemo(() => props.serverCtx()?.sdk ?? null) const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId)) const persisted = createMemo(() => tabs.info[props.id]) @@ -50,8 +95,30 @@ function SessionTabSlot(props: { ) const session = createMemo(() => cachedSession() ?? loadedSession()) const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session()) + const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title) let prefetched = false + const rename = async (title: string) => { + const value = session() + const ctx = props.serverCtx() + if (!value || !ctx) return + + ctx.sync.session.remember({ ...value, title }) + try { + await ctx.sdk.api.session.rename({ sessionID: value.id, title }) + } catch (err) { + const current = session() + const currentCtx = props.serverCtx() + if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title }) + showToast({ + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : undefined, + }) + } + } + + createEffect(() => props.onVisibleChange(visible())) + createEffect(() => { const ctx = props.serverCtx() const value = session() @@ -83,39 +150,20 @@ function SessionTabSlot(props: { }) return ( -
- { - ref = el - }} - href={tabHref(props.tab)} - server={props.tab.server} + + { - const value = session() - const ctx = props.serverCtx() - if (value && ctx) ctx.sync.session.remember({ ...value, title }) - }} - onTitleChangeFailed={(title) => { - const value = session() - const ctx = props.serverCtx() - if (value && ctx) ctx.sync.session.remember({ ...value, title }) - }} - onNavigate={() => props.onNavigate(ref)} + onRename={rename} + onNavigate={props.onNavigate} onClose={props.onClose} - active={props.active()} - forceTruncate={props.forceTruncate} - dragging={sortable.isDragSource()} /> -
+ ) } @@ -172,11 +220,39 @@ export function TitlebarTabStrip(props: { }) { const global = useGlobal() const language = useLanguage() + const command = useCommand() let scrollRef!: HTMLDivElement let listRef!: HTMLDivElement let resizeFrame: number | undefined + const [visibility, setVisibility] = createStore>({}) + const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)])) + const visibleTabIds = () => visibleTabs().map(tabKey) - const tabIds = () => props.tabs.map(tabKey) + command.register("titlebar-tab-cycle", () => [ + { + id: `tab.prev`, + category: "tab", + title: "", + keybind: `mod+option+ArrowLeft,ctrl+shift+tab`, + hidden: true, + onSelect: () => selectAdjacentTab(-1), + }, + { + id: `tab.next`, + category: "tab", + title: "", + keybind: `mod+option+ArrowRight,ctrl+tab`, + hidden: true, + onSelect: () => selectAdjacentTab(1), + }, + ]) + + function selectAdjacentTab(offset: -1 | 1) { + const current = props.currentTab() + const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset) + const next = props.tabs.find((tab) => tabKey(tab) === key) + if (next) props.onNavigate(next) + } function refreshOverflow() { if (!scrollRef) return @@ -204,7 +280,7 @@ export function TitlebarTabStrip(props: { createEffect(() => { props.tabs.length - tabIds() + visibleTabIds() refreshOverflow() }) @@ -240,22 +316,29 @@ export function TitlebarTabStrip(props: { props.onNavigate(tab, tabEl ?? undefined) }} onDragEnd={(event) => { - const current = tabIds() + const current = visibleTabIds() const source = event.operation.source if (event.canceled || !isSortable(source)) return const { initialIndex, index } = source if (initialIndex !== index) { - props.onReorder(arrayMove(current, source.initialIndex, source.index)) + props.onReorder( + mergeVisibleTabOrder( + props.tabs.map(tabKey), + current, + arrayMove(current, source.initialIndex, source.index), + ), + ) } }} >
- {(tab, index) => { + {(tab) => { const id = tabKey(tab) let ref!: HTMLDivElement - useTabShortcut(index, () => props.onNavigate(tab, ref)) + const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id) + useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref)) const serverCtx = createMemo(() => { if (tab.type !== "session") return const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server) @@ -264,13 +347,14 @@ export function TitlebarTabStrip(props: { if (tab.type === "session") { return ( - props.currentTab() === tab} forceTruncate={props.forceTruncate} serverCtx={serverCtx} + onVisibleChange={(visible) => setVisibility(id, visible)} onNavigate={(element) => { ref = element props.onNavigate(tab, element) @@ -284,7 +368,7 @@ export function TitlebarTabStrip(props: { props.currentTab() === tab} title={language.t("command.session.new")} onNavigate={(element) => { @@ -318,7 +402,7 @@ function useTabShortcut(index: () => number, onSelect: () => void) { command.register(() => { const number = index() + 1 - if (number > 9) return [] + if (number < 1 || number > 9) return [] return [ { id: `tab.${number}`, diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 1a2a8a81b6..649a8f5777 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -5,7 +5,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Icon } from "@opencode-ai/ui/icon" import { Button } from "@opencode-ai/ui/button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { useTheme } from "@opencode-ai/ui/theme/context" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" @@ -28,32 +27,13 @@ import { tabKey, useTabs } from "@/context/tabs" import type { PromptSession } from "@/context/prompt" import "./titlebar.css" import { newTabTooltipKeybind } from "./command-tooltip-keybind" +import { normalizeSessionInfo } from "@/utils/session" -type TauriDesktopWindow = { - startDragging?: () => Promise - toggleMaximize?: () => Promise -} - -type TauriThemeWindow = { - setTheme?: (theme?: "light" | "dark" | null) => Promise -} - -type TauriApi = { - window?: { - getCurrentWindow?: () => TauriDesktopWindow - } - webviewWindow?: { - getCurrentWebviewWindow?: () => TauriThemeWindow - } -} - -const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__ -const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.() -const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.() const legacyTitlebarHeight = 40 const v2TitlebarHeight = 36 const minTitlebarZoom = 0.25 const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each. +const macTrafficLightsBaseWidth = 84 export type TitlebarUpdate = { version: () => string | undefined @@ -67,13 +47,12 @@ export function useTitlebarRightMount() { return mount } -export function Titlebar(props: { update?: TitlebarUpdate }) { +export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) { const layout = useLayout() const platform = usePlatform() const command = useCommand() const language = useLanguage() const settings = useSettings() - const theme = useTheme() const server = useServer() const navigate = useNavigate() const location = useLocation() @@ -84,9 +63,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") - const electronWindows = createMemo(() => windows() && !tauriApi()) const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") const web = createMemo(() => platform.platform === "web") + const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.()) const zoom = () => platform.webviewZoom?.() ?? 1 const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom()) const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1) @@ -175,56 +154,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { }, ]) - const getWin = () => { - if (platform.platform !== "desktop") return - return currentDesktopWindow() - } - - createEffect(() => { - if (platform.platform !== "desktop") return - - const scheme = theme.colorScheme() - const value = scheme === "system" ? null : scheme - - const win = currentThemeWindow() - if (!win?.setTheme) return - - void win.setTheme(value).catch(() => undefined) - }) - - const interactive = (target: EventTarget | null) => { - if (!(target instanceof Element)) return false - - const selector = - "button, a, input, textarea, select, option, [role='button'], [role='menuitem'], [contenteditable='true'], [contenteditable='']" - - return !!target.closest(selector) - } - - const drag = (e: MouseEvent) => { - if (platform.platform !== "desktop") return - if (e.buttons !== 1) return - if (interactive(e.target)) return - - const win = getWin() - if (!win?.startDragging) return - - e.preventDefault() - void win.startDragging().catch(() => undefined) - } - - const maximize = (e: MouseEvent) => { - if (platform.platform !== "desktop") return - if (interactive(e.target)) return - if (e.target instanceof Element && e.target.closest("[data-tauri-decorum-tb]")) return - - const win = getWin() - if (!win?.toggleMaximize) return - - e.preventDefault() - void win.toggleMaximize().catch(() => undefined) - } - return (
@@ -267,9 +192,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined }, ({ route, sdk }) => - sdk.client.session + sdk.api.session .get({ sessionID: route.sessionId }) - .then((x) => x.data) + .then(normalizeSessionInfo) .catch(() => {}), ) @@ -413,40 +338,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { keybind: "mod+shift+t", onSelect: () => tabsStoreActions.reopenClosedTab(), }, - { - id: `tab.prev`, - category: "tab", - title: "", - keybind: `mod+option+ArrowLeft,ctrl+shift+tab`, - hidden: true, - onSelect: () => { - let index = tabsStore.findIndex((tab) => tab === currentTab()) - if (index === -1) return - - index -= 1 - if (index === -1) index = tabsStore.length - 1 - - const next = tabsStore[index] - if (next) tabs.select(next) - }, - }, - { - id: `tab.next`, - category: "tab", - title: "", - keybind: `mod+option+ArrowRight,ctrl+tab`, - hidden: true, - onSelect: () => { - let index = tabsStore.findIndex((tab) => tab === currentTab()) - if (index === -1) return - - index += 1 - if (index === tabsStore.length) index = 0 - - const next = tabsStore[index] - if (next) tabs.select(next) - }, - }, ].filter((v) => v !== undefined) }) @@ -458,11 +349,11 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { classList={{ "pt-2": !bottom(), "pb-2": bottom(), - "md:pl-2": mac(), - "md:pl-4": !mac(), + "md:pl-2": macTrafficLights(), + "md:pl-4": !macTrafficLights(), }} > - + @@ -504,32 +395,27 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { }} onReorder={(keys) => tabsStoreActions.reorder(keys)} /> - - - {language.t("command.session.new")} - - - } - > - } - onClick={openNewTab} - aria-label={language.t("command.session.new")} - /> - - + + {language.t("command.session.new")} + + + } + > + } + onClick={openNewTab} + aria-label={language.t("command.session.new")} + /> +
- -
-
) }} @@ -542,14 +428,13 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
- {/*
*/}
-
+
@@ -679,12 +564,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { "pr-2": !windows(), }} data-tauri-drag-region - onMouseDown={drag} >
- {!tauriApi() &&
} -
+
@@ -747,12 +630,27 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) { ) } -function ChannelIndicator() { +function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) { + const channel = import.meta.env.VITE_OPENCODE_CHANNEL + if (channel === "dev" && props.debugTools) { + return ( + + ) + } + return ( <> - {["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && ( + {["beta", "dev"].includes(channel) && (
- {import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()} + {channel.toUpperCase()}
)} diff --git a/packages/app/src/components/windows-app-menu.tsx b/packages/app/src/components/windows-app-menu.tsx index 017ac4cf8d..e41c2a584a 100644 --- a/packages/app/src/components/windows-app-menu.tsx +++ b/packages/app/src/components/windows-app-menu.tsx @@ -43,7 +43,7 @@ export function WindowsAppMenu(props: { runAction(entry.action) return } - if (entry.href) props.platform.openLink(entry.href) + if (entry.href) props.platform.openExternal(entry.href) } return ( diff --git a/packages/app/src/context/command.test.ts b/packages/app/src/context/command.test.ts index 4297dd89bd..e0d9057511 100644 --- a/packages/app/src/context/command.test.ts +++ b/packages/app/src/context/command.test.ts @@ -1,22 +1,51 @@ import { describe, expect, test } from "bun:test" -import { resolveKeybindOption, upsertCommandRegistration } from "./command" +import { + activeCommandRegistrations, + addCommandRegistration, + commandPaletteOptions, + resolveKeybindOption, + type CommandOption, +} from "./command" -describe("upsertCommandRegistration", () => { - test("replaces keyed registrations", () => { +const paletteOptions: CommandOption[] = [ + { id: "settings.open", title: "Open settings" }, + { id: "session.undo", title: "Undo" }, + { id: "file.open", title: "Open file" }, + { id: "hidden", title: "Hidden", hidden: true }, + { id: "disabled", title: "Disabled", disabled: true }, +] + +describe("commandPaletteOptions", () => { + test("keeps visible enabled commands", () => { + expect(commandPaletteOptions(paletteOptions).map((option) => option.id)).toEqual(["settings.open", "session.undo"]) + }) +}) + +describe("command registrations", () => { + test("shadows keyed registrations while retaining the previous owner", () => { const one = () => [{ id: "one", title: "One" }] const two = () => [{ id: "two", title: "Two" }] - const next = upsertCommandRegistration([{ key: "layout", options: one }], { key: "layout", options: two }) + const registrations = addCommandRegistration([{ key: "layout", options: one }], { + key: "layout", + options: two, + }) + const active = activeCommandRegistrations(registrations) - expect(next).toHaveLength(1) - expect(next[0]?.options).toBe(two) + expect(registrations).toHaveLength(2) + expect(active).toHaveLength(1) + expect(active[0]?.options).toBe(two) + + const restored = activeCommandRegistrations(registrations.filter((entry) => entry.options !== two)) + expect(restored).toHaveLength(1) + expect(restored[0]?.options).toBe(one) }) test("keeps unkeyed registrations additive", () => { const one = () => [{ id: "one", title: "One" }] const two = () => [{ id: "two", title: "Two" }] - const next = upsertCommandRegistration([{ options: one }], { options: two }) + const next = activeCommandRegistrations(addCommandRegistration([{ options: one }], { options: two })) expect(next).toHaveLength(2) expect(next[0]?.options).toBe(two) diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index 467a1115b5..054b99e806 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -11,7 +11,7 @@ import { Persist, persisted } from "@/utils/persist" const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) const PALETTE_ID = "command.palette" -const DEFAULT_PALETTE_KEYBIND = "mod+shift+p" +export const DEFAULT_PALETTE_KEYBIND = "mod+k,mod+shift+p" const SUGGESTED_PREFIX = "suggested." const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"]) @@ -87,6 +87,13 @@ export interface CommandOption { onHighlight?: () => (() => void) | void } +export function commandPaletteOptions(options: CommandOption[]) { + return options.filter( + (option) => + !option.disabled && !option.hidden && !option.id.startsWith(SUGGESTED_PREFIX) && option.id !== "file.open", + ) +} + export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) { return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when) } @@ -107,9 +114,18 @@ export type CommandRegistration = { options: Accessor } -export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) { - if (entry.key === undefined) return [entry, ...registrations] - return [entry, ...registrations.filter((x) => x.key !== entry.key)] +export function addCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) { + return [entry, ...registrations] +} + +export function activeCommandRegistrations(registrations: CommandRegistration[]) { + const keys = new Set() + return registrations.filter((entry) => { + if (entry.key === undefined) return true + if (keys.has(entry.key)) return false + keys.add(entry.key) + return true + }) } export function parseKeybind(config: string): Keybind[] { @@ -274,7 +290,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const seen = new Set() const all: CommandOption[] = [] - for (const reg of store.registrations) { + for (const reg of activeCommandRegistrations(store.registrations)) { for (const opt of reg.options()) { if (seen.has(opt.id)) { if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) { @@ -375,7 +391,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex } const showPalette = () => { - run("file.open", "palette") + run(PALETTE_ID, "palette") } const handleKeyDown = (event: KeyboardEvent) => { @@ -418,7 +434,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex key: id, options, } - setStore("registrations", (arr) => upsertCommandRegistration(arr, entry)) + setStore("registrations", (arr) => addCommandRegistration(arr, entry)) onCleanup(() => { setStore("registrations", (arr) => arr.filter((x) => x !== entry)) }) diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index 68e6b19cef..befd5b61e0 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/packages/app/src/context/directory-sync.ts @@ -5,6 +5,7 @@ import { produce, reconcile, type SetStoreFunction } from "solid-js/store" import type { createServerSdkContext } from "./server-sdk" import type { createServerSyncContextInner } from "./server-sync" import type { State } from "./global-sync/types" +import { normalizeSessionInfo } from "@/utils/session" const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const sessionFields = new Set([ @@ -15,6 +16,7 @@ const sessionFields = new Set([ "permission", "question", "message", + "session_message", "part", "part_text_accum_delta", ]) @@ -114,7 +116,6 @@ export const createDirSyncContext = ( await serverSync.session.sync(sessionID, options) index(sessionID) }, - diff: serverSync.session.diff, todo: serverSync.session.todo, history: serverSync.session.history, evict(sessionID: string) { @@ -123,9 +124,9 @@ export const createDirSyncContext = ( fetch: async (count = 10) => { const [store, setStore] = current() setStore("limit", (value) => value + count) - const response = await client.session.list() - const sessions = (response.data ?? []) - .filter((session) => !!session?.id) + const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" }) + const sessions = response.data + .map(normalizeSessionInfo) .sort((a, b) => cmp(a.id, b.id)) .slice(0, store.limit) sessions.forEach(serverSync.session.remember) @@ -133,7 +134,8 @@ export const createDirSyncContext = ( }, more: createMemo(() => current()[0].session.length >= current()[0].limit), archive: async (sessionID: string) => { - await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } }) + if ((await serverSDK.protocol) !== "v1") return + await serverSDK.client.session.update({ sessionID, directory, time: { archived: Date.now() } }) current()[1]( "session", produce((draft) => { diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 6032b81dde..fbbef3a2a8 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -204,10 +204,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ } const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) => - sdk() - .client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal }) + serverSDK() + .api.file.find( + { + location: { directory: sdk().directory }, + query, + type: dirs === "true" ? "directory" : "file", + limit: options?.limit, + }, + { signal: options?.signal }, + ) .then( - (x) => (x.data ?? []).map(path.normalize), + (x) => x.data.map((entry) => path.normalize(entry.path)), (error) => { if (options?.signal?.aborted) throw error return [] diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts index feef6d466e..99dd88ae0e 100644 --- a/packages/app/src/context/file/path.test.ts +++ b/packages/app/src/context/file/path.test.ts @@ -21,6 +21,29 @@ describe("file path helpers", () => { expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts") }) + test("normalizes Windows directory separators", () => { + const path = createPathHelpers(() => "C:\\repo") + expect(path.normalizeDir("frontend\\")).toBe("frontend") + expect(path.normalizeDir("frontend\\src\\")).toBe("frontend/src") + expect(path.normalizeDir("C:\\repo\\frontend\\")).toBe("frontend") + }) + + test("normalizes separators for Windows roots written with forward slashes", () => { + const path = createPathHelpers(() => "C:/repo") + expect(path.normalizeDir("frontend\\src\\")).toBe("frontend/src") + }) + + test("normalizes separators for Windows UNC roots", () => { + const path = createPathHelpers(() => "\\\\server\\share") + expect(path.normalizeDir("\\\\server\\share\\frontend\\")).toBe("frontend") + }) + + test("preserves backslashes in POSIX directory names", () => { + const path = createPathHelpers(() => "/repo") + expect(path.normalizeDir("literal\\name\\")).toBe("literal\\name\\") + expect(path.normalizeDir("literal\\name/")).toBe("literal\\name") + }) + test("keeps query/hash stripping behavior stable", () => { expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts") expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts") diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts index 53f072b6cb..2bc4bde5e9 100644 --- a/packages/app/src/context/file/path.ts +++ b/packages/app/src/context/file/path.ts @@ -140,7 +140,12 @@ export function createPathHelpers(scope: () => string) { return normalize(tabValue) } - const normalizeDir = (input: string) => normalize(input).replace(/\/+$/, "") + const normalizeDir = (input: string) => { + const path = normalize(input) + const root = scope() + const windows = /^[A-Za-z]:/.test(root) || root.startsWith("\\\\") + return (windows ? path.replace(/\\/g, "/") : path).replace(/\/+$/, "") + } return { normalize, diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index 40735fb822..a95706a3db 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -1,14 +1,42 @@ import { describe, expect, test } from "bun:test" import { createStore } from "solid-js/store" import { QueryClient } from "@tanstack/solid-query" -import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client" +import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client" +import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise" import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" -import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap" +import { + bootstrapDirectory, + loadAgentsQuery, + loadCommands, + loadGlobalConfigQuery, + loadPathQuery, + loadProjectsQuery, + loadProvidersQuery, + loadReferencesQuery, +} from "./bootstrap" import type { State, VcsCache } from "./types" -import { createServerSession } from "../server-session" import { ServerScope } from "@/utils/server-scope" +import type { ServerApi } from "@/utils/server" + +type ProjectApi = ServerApi["project"] const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse +const api = { + agent: { list: async () => ({ location: {}, data: [] }) }, + provider: { list: async () => ({ location: {}, data: [] }) }, + model: { + list: async () => ({ location: {}, data: [] }), + default: async () => ({ location: {}, data: null }), + }, + permission: { request: { list: async () => ({ location: {}, data: [] }) } }, + project: { + list: async () => [], + current: async () => ({ id: "project", directory: "/project" }), + }, + question: { request: { list: async () => ({ location: {}, data: [] }) } }, + reference: { list: async () => ({ location: {}, data: [] }) }, + vcs: { get: async () => ({ location: {}, data: {} }) }, +} as unknown as ServerApi function directoryState() { return createStore({ @@ -41,20 +69,22 @@ function directoryState() { vcs: undefined, limit: 5, message: {}, + session_message: {}, part: {}, part_text_accum_delta: {}, }) } describe("bootstrapDirectory", () => { - test("marks a loading directory partial during bootstrap and complete after success", async () => { + test("uses legacy MCP endpoints while refreshing a v1 directory", async () => { + const legacyConfigReads: string[] = [] const mcpReads: string[] = [] const [store, setStore] = directoryState() await bootstrapDirectory({ directory: "/project", scope: ServerScope.local, - mcp: false, + mcp: true, global: { config: {} satisfies Config, path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" }, @@ -63,7 +93,12 @@ describe("bootstrapDirectory", () => { }, sdk: { app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) }, - config: { get: async () => ({ data: {} }) }, + config: { + get: async () => { + legacyConfigReads.push("directory") + return { data: {} } + }, + }, session: { status: async () => ({ data: {} }) }, vcs: { get: async () => ({ data: undefined }) }, command: { @@ -81,14 +116,24 @@ describe("bootstrapDirectory", () => { return { data: {} } }, }, + experimental: { + resource: { + list: async () => { + mcpReads.push("resource") + return { data: {} } + }, + }, + }, provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, } as unknown as OpencodeClient, + api, store, setStore, vcsCache: { setStore() {} } as unknown as VcsCache, loadSessions() {}, translate: (key) => key, queryClient: new QueryClient(), + protocol: Promise.resolve("v1"), }) expect(store.status).toBe("partial") @@ -96,39 +141,12 @@ describe("bootstrapDirectory", () => { await new Promise((resolve) => setTimeout(resolve, 80)) expect(store.status).toBe("complete") - expect(mcpReads).toEqual([]) + expect(legacyConfigReads).toEqual(["directory"]) + expect(mcpReads.sort()).toEqual(["command", "resource", "status"]) }) - test("seeds session status even while warming session info stalls", async () => { + test("skips legacy config while refreshing a v2 directory", async () => { const [store, setStore] = directoryState() - const stalled = Promise.withResolvers() - const client = { - app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) }, - config: { get: async () => ({ data: {} }) }, - session: { - status: async () => ({ data: { ses_busy: { type: "busy" } } }), - get: () => stalled.promise, - }, - vcs: { get: async () => ({ data: undefined }) }, - command: { list: async () => ({ data: [] }) }, - permission: { list: async () => ({ data: [] }) }, - question: { list: async () => ({ data: [] }) }, - v2: { reference: { list: async () => ({ data: { data: [] } }) } }, - mcp: { status: async () => ({ data: {} }) }, - provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, - } as unknown as OpencodeClient - const session = createServerSession(client) - const stale: Session = { - id: "ses_stale", - slug: "ses_stale", - projectID: "project", - directory: "/project", - title: "stale", - version: "1", - time: { created: 1, updated: 1 }, - } - session.remember(stale) - session.set("session_status", stale.id, { type: "busy" }) await bootstrapDirectory({ directory: "/project", @@ -140,37 +158,173 @@ describe("bootstrapDirectory", () => { project: [{ id: "project", worktree: "/project" } as Project], provider, }, - sdk: client, + sdk: { + config: { + get: async () => { + throw new Error("legacy directory config should not be called") + }, + }, + } as unknown as OpencodeClient, + api, store, setStore, vcsCache: { setStore() {} } as unknown as VcsCache, loadSessions() {}, translate: (key) => key, queryClient: new QueryClient(), - session, + protocol: Promise.resolve("v2"), }) - const deadline = Date.now() + 500 - while (!session.data.session_working("ses_busy") && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 10)) - } + expect(store.status).toBe("partial") - expect(session.data.session_status["ses_busy"]?.type).toBe("busy") - expect(session.data.session_status[stale.id]).toBeUndefined() + await new Promise((resolve) => setTimeout(resolve, 80)) + + expect(store.status).toBe("complete") + }) +}) + +describe("config queries", () => { + test("skips legacy global config for v2 servers", async () => { + const sdk = { + global: { + config: { + get: async () => { + throw new Error("legacy global config should not be called") + }, + }, + }, + } as unknown as OpencodeClient + + const result = await new QueryClient().fetchQuery( + loadGlobalConfigQuery(ServerScope.local, sdk, Promise.resolve("v2")), + ) + + expect(result).toEqual({}) + }) + + test("loads legacy global config for v1 servers", async () => { + const calls: string[] = [] + const config = { shell: "zsh" } satisfies Config + const sdk = { + global: { + config: { + get: async () => { + calls.push("global") + return { data: config } + }, + }, + }, + } as unknown as OpencodeClient + + const result = await new QueryClient().fetchQuery( + loadGlobalConfigQuery(ServerScope.local, sdk, Promise.resolve("v1")), + ) + + expect(result).toEqual(config) + expect(calls).toEqual(["global"]) }) }) describe("query keys", () => { test("partitions identical directories by server scope", () => { - const client = {} as OpencodeClient + const client = {} as Parameters[2] + const api = {} as CatalogApi const remote = "https://debian.example" as typeof ServerScope.local expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"]) expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"]) - expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual([ - "https://debian.example", - null, - "providers", + expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"]) + }) + + test("loads the current provider and model catalog", async () => { + const calls: unknown[] = [] + const api = { + provider: { + list: async (input: unknown) => { + calls.push(["provider", input]) + return { location: {}, data: [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] } + }, + }, + model: { + list: async (input: unknown) => { + calls.push(["model", input]) + return { location: {}, data: [] } + }, + default: async (input: unknown) => { + calls.push(["default", input]) + return { location: {}, data: null } + }, + }, + } as unknown as CatalogApi + + const result = await new QueryClient().fetchQuery(loadProvidersQuery(ServerScope.local, "/repo", api)) + + expect(calls).toEqual([ + ["provider", { location: { directory: "/repo" } }], + ["model", { location: { directory: "/repo" } }], + ["default", { location: { directory: "/repo" } }], ]) + expect(result.connected).toEqual(["openai"]) + }) + + test("loads agents from the current location-scoped endpoint", async () => { + const calls: unknown[] = [] + const api = { + list: async (input: unknown) => { + calls.push(input) + return { location: {}, data: [] } + }, + } as unknown as AgentApi + + const result = await new QueryClient().fetchQuery(loadAgentsQuery(ServerScope.local, "/repo", api)) + + expect(calls).toEqual([{ location: { directory: "/repo" } }]) + expect(result).toEqual([]) + }) + + test("loads commands from the current location-scoped endpoint", async () => { + const calls: unknown[] = [] + const api = { + list: async (input: unknown) => { + calls.push(input) + return { + location: {}, + data: [{ name: "review", template: "Review files" /* source: "command" as const */ }], + } + }, + } as unknown as CommandApi + + const result = await loadCommands("/repo", api) + + expect(calls).toEqual([{ location: { directory: "/repo" } }]) + expect(result).toEqual([{ name: "review", template: "Review files" /* source: "command" */ }]) + }) + + test("loads projects from the current endpoint", async () => { + const api = { + list: async () => [ + { id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] }, + { id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] }, + ], + } as unknown as ProjectApi + + const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api)) + + expect(result.map((project) => project.id)).toEqual(["a", "b"]) + }) + + test("loads references from the current location-scoped endpoint", async () => { + const calls: unknown[] = [] + const api = { + list: async (input: unknown) => { + calls.push(input) + return { location: {}, data: [{ name: "AGENTS.md", path: "/repo/AGENTS.md", source: "instructions" }] } + }, + } as unknown as ReferenceApi + + const result = await new QueryClient().fetchQuery(loadReferencesQuery(ServerScope.local, "/repo", api)) + + expect(calls).toEqual([{ location: { directory: "/repo" } }]) + expect(result).toHaveLength(1) }) }) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index c63b702522..0f3e473816 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -9,6 +9,20 @@ import type { ReferenceInfo, Session, } from "@opencode-ai/sdk/v2/client" +import type { + AgentListInput, + AgentListOutput, + CatalogApi, + CommandInfo, + CommandListInput, + CommandListOutput, + ProjectCurrentInput, + ProjectCurrentOutput, + ProjectListOutput, + ReferenceListInput, + ReferenceListOutput, + SessionApi, +} from "@opencode-ai/client/promise" import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" import { retry } from "@opencode-ai/core/util/retry" @@ -16,12 +30,21 @@ import { batch } from "solid-js" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { State, VcsCache } from "./types" import type { ServerSession } from "../server-session" -import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" +import { + cmp, + normalizeAgentList, + normalizePermissionRequest, + normalizeProjectInfo, + normalizeProviderList, +} from "./utils" import { formatServerError } from "@/utils/server-errors" import { QueryClient, queryOptions } from "@tanstack/solid-query" import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { ScopedKey, type ServerScope } from "@/utils/server-scope" +import { normalizeSessionInfo } from "@/utils/session" +import type { ServerProtocol } from "@/utils/server-protocol" +import type { ServerApi } from "@/utils/server" type GlobalStore = { ready: boolean @@ -82,21 +105,35 @@ function showErrors(input: { }) } -export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) => +export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient, protocol?: Promise) => queryOptions({ queryKey: [scope, "config"], - queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)), + queryFn: async () => { + if ((await protocol) !== "v1") return {} + return retry(() => sdk.global.config.get().then((x) => x.data!)) + }, }) -export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) => +type ProjectApi = { + readonly list: () => Promise + readonly current: (input?: ProjectCurrentInput) => Promise +} + +type McpApi = ServerApi["mcp"] +type PermissionApi = ServerApi["permission"] +type QuestionApi = ServerApi["question"] +type VcsApi = ServerApi["vcs"] + +export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) => queryOptions({ queryKey: [scope, "project"], queryFn: () => retry(() => - sdk.project.list().then((x) => { - return (x.data ?? []) + api.list().then((projects) => { + return projects .filter((p) => !!p?.id) .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) + .map(normalizeProjectInfo) .slice() .sort((a, b) => cmp(a.id, b.id)) }), @@ -105,6 +142,8 @@ export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) => export async function bootstrapGlobal(input: { serverSDK: OpencodeClient + serverAPI: CatalogApi & { readonly project: ProjectApi } + protocol?: Promise scope: ServerScope requestFailedTitle: string translate: (key: string, vars?: Record) => string @@ -113,12 +152,15 @@ export async function bootstrapGlobal(input: { queryClient: QueryClient }) { const slow = [ - () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)), - () => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)), - () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)), + () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK, input.protocol)), + () => + input.queryClient.fetchQuery( + loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol), + ), + () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)), () => input.queryClient - .fetchQuery(loadProjectsQuery(input.scope, input.serverSDK)) + .fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)) .then((data) => input.setGlobalStore("project", data)), ] await runAll(slow) @@ -162,44 +204,126 @@ function warmSessions(input: { ids: string[] store: Store setStore: SetStoreFunction - sdk: OpencodeClient + api: SessionApi }) { const known = new Set(input.store.session.map((item) => item.id)) const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id)) if (ids.length === 0) return Promise.resolve() return Promise.all( ids.map((sessionID) => - retry(() => input.sdk.session.get({ sessionID })).then((x) => { - const session = x.data - if (!session?.id) return - mergeSession(input.setStore, session) - }), + retry(() => input.api.get({ sessionID })).then((session) => + mergeSession(input.setStore, normalizeSessionInfo(session)), + ), ), ).then(() => undefined) } -export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => +export const loadProvidersQuery = ( + scope: ServerScope, + directory: string | null, + sdk: CatalogApi, + legacy?: OpencodeClient, + protocol?: Promise, +) => queryOptions({ queryKey: [scope, directory, "providers"], - queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))), + queryFn: () => + retry(async () => { + if ((await protocol) === "v1" && legacy) { + const result = await legacy.provider.list() + return normalizeProviderList(result.data!) + } + const location = directory ? { location: { directory } } : undefined + const [providers, models, defaultModel] = await Promise.all([ + sdk.provider.list(location), + sdk.model.list(location), + sdk.model.default(location), + ]) + return normalizeProviderList(providers.data, models.data, defaultModel.data) + }), }) -export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => +type AgentListApi = { + readonly list: (input?: AgentListInput) => Promise +} + +type CommandListApi = { + readonly list: (input?: CommandListInput) => Promise +} + +type ReferenceListApi = { + readonly list: (input?: ReferenceListInput) => Promise +} + +export const loadAgentsQuery = ( + scope: ServerScope, + directory: string, + sdk: AgentListApi, + legacy?: OpencodeClient, + protocol?: Promise, +) => queryOptions({ queryKey: [scope, directory, "agents"], - queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))), + queryFn: () => + retry(async () => { + if ((await protocol) === "v1" && legacy) return normalizeAgentList((await legacy.app.agents()).data ?? []) + return sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data)) + }), }) -export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => +export const loadCommands = ( + directory: string, + api: CommandListApi, + legacy?: OpencodeClient, + protocol?: Promise, +): Promise => + retry(async () => { + if ((await protocol) === "v1" && legacy) { + return ((await legacy.command.list()).data ?? []).map((command) => { + const [providerID, id] = command.model?.split("/") ?? [] + return { + name: command.name, + template: command.template, + description: command.description, + agent: command.agent, + model: providerID && id ? { providerID, id } : undefined, + subtask: command.subtask, + // source: command.source === "skill" ? undefined : command.source, + } + }) + } + return api.list({ location: { directory } }).then((result) => result.data) + }) + +export const loadPathQuery = ( + scope: ServerScope, + directory: string | null, + sdk: OpencodeClient, + protocol?: Promise, +) => queryOptions({ queryKey: [scope, directory, "path"], - queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)), + queryFn: async () => { + if ((await protocol) !== "v1") + return { state: "", config: "", worktree: "", directory: directory ?? "", home: "" } + return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!)) + }, }) -export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => +export const loadReferencesQuery = ( + scope: ServerScope, + directory: string, + api: ReferenceListApi, + legacy?: OpencodeClient, + protocol?: Promise, +) => queryOptions({ queryKey: [scope, directory, "references"] as const, - queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []), + queryFn: () => + retry(async () => { + if ((await protocol) === "v1" && legacy) return (await legacy.v2.reference.list()).data?.data ?? [] + return api.list({ location: { directory } }).then((result) => result.data) + }).catch(() => []), placeholderData: [], }) @@ -208,6 +332,17 @@ export async function bootstrapDirectory(input: { scope: ServerScope mcp: boolean sdk: OpencodeClient + api: CatalogApi & { + readonly agent: AgentListApi + readonly command: CommandListApi + readonly mcp: McpApi + readonly permission: PermissionApi + readonly project: ProjectApi + readonly question: QuestionApi + readonly reference: ReferenceListApi + readonly session: SessionApi + readonly vcs: VcsApi + } store: Store setStore: SetStoreFunction vcsCache: VcsCache @@ -221,6 +356,7 @@ export async function bootstrapDirectory(input: { } queryClient: QueryClient session?: ServerSession + protocol?: Promise }) { const loading = input.store.status !== "complete" const seededProject = projectID(input.directory, input.global.project) @@ -240,13 +376,18 @@ export async function bootstrapDirectory(input: { () => Promise.resolve(input.loadSessions(input.directory)), () => input.queryClient - .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk)) + .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol)) .then((data) => input.setStore("agent", data)), () => - retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))), + retry(async () => { + if ((await input.protocol) !== "v1") return + return input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false }))) + }), () => retry(() => - input.sdk.session.status().then(async (x) => { + (async () => { + if ((await input.protocol) !== "v1") return + const x = await input.sdk.session.status() if (!input.session) { input.setStore("session_status", x.data!) return @@ -264,42 +405,57 @@ export async function bootstrapDirectory(input: { for (const [sessionID, status] of Object.entries(statuses)) { input.session.set("session_status", sessionID, reconcile(status)) } - // Warm session info only after seeding statuses so a stalled session - // fetch cannot park busy indicators behind it, mirroring how live - // session.status events apply first and resolve info in the background. await Promise.all( Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)), ) - }), + })(), ), !seededProject && - (() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))), + (() => + retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) => + input.setStore("project", project.id), + )), !seededPath && (() => - input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => { - const next = projectID(data.directory ?? input.directory, input.global.project) - if (next) input.setStore("project", next) - })), + input.queryClient + .ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk, input.protocol)) + .then((data) => { + const next = projectID(data.directory ?? input.directory, input.global.project) + if (next) input.setStore("project", next) + })), () => - retry(() => - input.sdk.vcs.get().then((x) => { - const next = x.data ?? input.store.vcs + retry(async () => { + if ((await input.protocol) !== "v1") return + return input.sdk.vcs.get().then((result) => { + const next = { branch: result.data?.branch, default_branch: result.data?.default_branch } input.setStore("vcs", next) if (next) input.vcsCache.setStore("value", next) - }), + }) + }), + input.mcp && + (() => + loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) => + input.setStore("command", commands), + )), + () => + input.queryClient.fetchQuery( + loadReferencesQuery(input.scope, input.directory, input.api.reference, input.sdk, input.protocol), ), - input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))), - () => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)), () => retry(() => - input.sdk.permission.list().then((x) => { - const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id) + (async () => { + if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? [] + return input.api.permission.request + .list({ location: { directory: input.directory } }) + .then((result) => result.data.map(normalizePermissionRequest)) + })().then((permissions) => { + const ids = permissions.map((permission) => permission.sessionID) const grouped = groupBySession( - (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + permissions.filter((permission) => !!permission.id && !!permission.sessionID), ) const warm = input.session ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) - : warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }) + : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session }) return warm.then(() => batch(() => { const current = input.session?.data.permission ?? input.store.permission @@ -323,12 +479,19 @@ export async function bootstrapDirectory(input: { ), () => retry(() => - input.sdk.question.list().then((x) => { - const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id) - const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) + (async () => { + if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? [] + return input.api.question.request + .list({ location: { directory: input.directory } }) + .then((result) => result.data) + })().then((questions) => { + const ids = questions.map((question) => question.sessionID) + const grouped = groupBySession( + questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[], + ) const warm = input.session ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) - : warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }) + : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session }) return warm.then(() => batch(() => { const current = input.session?.data.question ?? input.store.question @@ -351,17 +514,27 @@ export async function bootstrapDirectory(input: { }), ), () => Promise.resolve(input.loadSessions(input.directory)), - input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))), - input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))), + input.mcp && + (() => + input.queryClient.fetchQuery( + loadMcpQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol), + )), + input.mcp && + (() => + input.queryClient.fetchQuery( + loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol), + )), () => - input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => { - const project = getFilename(input.directory) - showToast({ - variant: "error", - title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(err, input.translate), - }) - }), + input.queryClient + .fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol)) + .catch((err) => { + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: input.translate("toast.project.reloadFailed.title", { project }), + description: formatServerError(err, input.translate), + }) + }), ].filter(Boolean) as (() => Promise)[] await waitForPaint() diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index b36973e7f0..4eaa785789 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -255,6 +255,7 @@ export function createChildStoreManager(input: { vcs: vcsStore.value, limit: 5, message: {}, + session_message: {}, part: {}, part_text_accum_delta: {}, }) diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index fb58fc4832..b53fb691b3 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -80,6 +80,7 @@ const baseState = (input: Partial = {}) => vcs: undefined, limit: 10, message: {}, + session_message: {}, part: {}, part_text_accum_delta: {}, ...input, @@ -261,8 +262,8 @@ describe("applyDirectoryEvent", () => { test("cleans session caches when deleted and decrements only root totals", () => { const cases = [ - { info: rootSession({ id: "ses_1" }), expectedTotal: 1 }, - { info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2 }, + { info: rootSession({ id: "ses_1" }), expectedTotal: 1, current: false }, + { info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2, current: true }, ] for (const item of cases) { @@ -286,7 +287,10 @@ describe("applyDirectoryEvent", () => { ) applyDirectoryEvent({ - event: { type: "session.deleted", properties: { info: item.info } }, + event: { + type: "session.deleted", + properties: item.current ? { sessionID: item.info.id } : { info: item.info }, + }, store, setStore, push() {}, diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index b12df5eb55..8f203715a6 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -8,9 +8,9 @@ import type { QuestionRequest, Session, SessionStatus, - SnapshotFileDiff, Todo, } from "@opencode-ai/sdk/v2/client" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { State, VcsCache } from "./types" import { trimSessions } from "./session-trim" import { dropSessionCaches } from "./session-cache" @@ -171,8 +171,11 @@ export function applyDirectoryEvent(input: { break } case "session.deleted": { - const info = (event.properties as { info: Session }).info - const result = Binary.search(input.store.session, info.id, (s) => s.id) + const properties = event.properties as { sessionID?: string; info?: Session } + const sessionID = properties.info?.id ?? properties.sessionID + if (!sessionID) break + const result = Binary.search(input.store.session, sessionID, (s) => s.id) + const info = properties.info ?? (result.found ? input.store.session[result.index] : undefined) if (result.found) { input.setStore( "session", @@ -181,14 +184,77 @@ export function applyDirectoryEvent(input: { }), ) } - cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo) - if (info.parentID) break + cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo) + if (info?.parentID) break input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) break } + case "session.renamed": { + const properties = event.properties as { sessionID: string; title: string } + const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) + if (!result.found) break + input.setStore("session", result.index, (session) => ({ + ...session, + title: properties.title, + time: { ...session.time, updated: Date.now() }, + })) + break + } + case "session.usage.updated": { + const properties = event.properties as Pick & { sessionID: string } + const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) + if (!result.found) break + input.setStore("session", result.index, (session) => ({ + ...session, + cost: properties.cost, + tokens: properties.tokens, + })) + break + } + // case "session.archived": { + // const properties = event.properties as { sessionID: string } + // const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) + // if (!result.found) break + // const info = input.store.session[result.index] + // input.setStore( + // "session", + // produce((draft) => void draft.splice(result.index, 1)), + // ) + // cleanupSessionCaches(input.setStore, properties.sessionID) + // if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) + // break + // } + case "session.moved": { + const properties = event.properties as { + sessionID: string + location: { directory: string; workspaceID?: string } + projectID?: string + subpath?: string + } + const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) + if (!result.found) break + if (properties.location.directory === input.directory) { + input.setStore("session", result.index, (session) => ({ + ...session, + projectID: properties.projectID ?? session.projectID, + workspaceID: properties.location.workspaceID, + directory: properties.location.directory, + path: properties.subpath, + time: { ...session.time, updated: Date.now() }, + })) + break + } + const info = input.store.session[result.index] + input.setStore( + "session", + produce((draft) => void draft.splice(result.index, 1)), + ) + if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) + break + } case "session.diff": { - const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] } - input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" })) + const props = event.properties as { sessionID: string; diff: FileDiffInfo[] } + input.setStore("session_diff", props.sessionID, reconcile(list(props.diff) as FileDiffInfo[], { key: "file" })) break } case "todo.updated": { diff --git a/packages/app/src/context/global-sync/mcp.test.ts b/packages/app/src/context/global-sync/mcp.test.ts index a292d23df9..ebfd9738ee 100644 --- a/packages/app/src/context/global-sync/mcp.test.ts +++ b/packages/app/src/context/global-sync/mcp.test.ts @@ -31,4 +31,24 @@ describe("toggleMcp", () => { await toggleMcp(input("disabled")) expect(calls).toEqual(["connect", "refresh"]) }) + + test("does not toggle a server while its connection is pending", async () => { + const calls: string[] = [] + await toggleMcp({ + status: "pending", + connect: async () => { + calls.push("connect") + }, + disconnect: async () => { + calls.push("disconnect") + }, + authenticate: async () => { + calls.push("authenticate") + }, + refresh: async () => { + calls.push("refresh") + }, + }) + expect(calls).toEqual([]) + }) }) diff --git a/packages/app/src/context/global-sync/mcp.ts b/packages/app/src/context/global-sync/mcp.ts index 2eeb297b95..cd91f396d0 100644 --- a/packages/app/src/context/global-sync/mcp.ts +++ b/packages/app/src/context/global-sync/mcp.ts @@ -1,12 +1,13 @@ -import type { McpStatus } from "@opencode-ai/sdk/v2/client" +import type { McpServer } from "@opencode-ai/client/promise" export async function toggleMcp(input: { - status: McpStatus["status"] + status: McpServer["status"]["status"] connect: () => Promise disconnect: () => Promise authenticate: () => Promise refresh: () => Promise }) { + if (input.status === "pending") return await { connected: input.disconnect, needs_auth: input.authenticate, diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts index 4b2be505ea..45fbe38abe 100644 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -1,13 +1,6 @@ import { describe, expect, test } from "bun:test" -import type { - Message, - Part, - PermissionRequest, - QuestionRequest, - SessionStatus, - SnapshotFileDiff, - Todo, -} from "@opencode-ai/sdk/v2/client" +import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" const msg = (id: string, sessionID: string) => @@ -33,9 +26,10 @@ describe("app session cache", () => { test("dropSessionCaches clears orphaned parts without message rows", () => { const store: { session_status: Record - session_diff: Record + session_diff: Record todo: Record message: Record + session_message: Record part: Record permission: Record question: Record @@ -45,6 +39,7 @@ describe("app session cache", () => { session_diff: { ses_1: [] }, todo: { ses_1: [] as Todo[] }, message: {}, + session_message: {}, part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, permission: { ses_1: [] as PermissionRequest[] }, question: { ses_1: [] as QuestionRequest[] }, @@ -67,9 +62,10 @@ describe("app session cache", () => { const m = msg("msg_1", "ses_1") const store: { session_status: Record - session_diff: Record + session_diff: Record todo: Record message: Record + session_message: Record part: Record permission: Record question: Record @@ -79,6 +75,7 @@ describe("app session cache", () => { session_diff: {}, todo: {}, message: { ses_1: [m] }, + session_message: {}, part: { [m.id]: [part("prt_1", "ses_1", m.id)] }, permission: {}, question: {}, diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index 05cdc84643..7d684a5a1a 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -1,20 +1,15 @@ -import type { - Message, - Part, - PermissionRequest, - QuestionRequest, - SessionStatus, - SnapshotFileDiff, - Todo, -} from "@opencode-ai/sdk/v2/client" +import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client" +import type { FileDiffInfo } from "@opencode-ai/client/promise" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" export const SESSION_CACHE_LIMIT = 40 type SessionCache = { session_status: Record - session_diff: Record + session_diff: Record todo: Record message: Record + session_message: Record part: Record permission: Record question: Record @@ -37,6 +32,7 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable; directory: string; limit: number }) { + const result = await input.api.list({ + directory: input.directory, + parentID: null, + limit: input.limit, + order: "desc", + }) + return { + data: result.data.map(normalizeSessionInfo), + limit: input.limit, + limited: true, + } as const +} + +export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) { try { - const result = await input.list({ directory: input.directory, roots: true, limit: input.limit }) - return { - data: result.data, - limit: input.limit, - limited: true, - } as const + const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit }) + return { data: result.data, limit: input.limit, limited: true } as const } catch { - const result = await input.list({ directory: input.directory, roots: true }) - return { - data: result.data, - limit: input.limit, - limited: false, - } as const + const result = await input.client.session.list({ directory: input.directory, roots: true }) + return { data: result.data, limit: input.limit, limited: false } as const } } diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 86b489cd09..74191e10b0 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -1,10 +1,7 @@ import type { Agent, - Command, Config, LspStatus, - McpResource, - McpStatus, Message, Part, Path, @@ -13,11 +10,12 @@ import type { ReferenceInfo, Session, SessionStatus, - SnapshotFileDiff, Todo, VcsInfo, } from "@opencode-ai/sdk/v2/client" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" +import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise" import type { Accessor } from "solid-js" import type { SetStoreFunction, Store } from "solid-js/store" @@ -35,7 +33,7 @@ export type ProjectMeta = { export type State = { status: "loading" | "partial" | "complete" agent: Agent[] - command: Command[] + command: CommandInfo[] reference: ReferenceInfo[] project: string projectMeta: ProjectMeta | undefined @@ -51,7 +49,7 @@ export type State = { } session_working(id: string): boolean session_diff: { - [sessionID: string]: SnapshotFileDiff[] + [sessionID: string]: FileDiffInfo[] } todo: { [sessionID: string]: Todo[] @@ -64,7 +62,7 @@ export type State = { } mcp_ready: boolean mcp: { - [name: string]: McpStatus + [name: string]: McpServer["status"] } mcp_resource: { [key: string]: McpResource @@ -76,6 +74,9 @@ export type State = { message: { [sessionID: string]: Message[] } + session_message: { + [sessionID: string]: SessionMessageInfo[] + } part: { [messageID: string]: Part[] } @@ -128,18 +129,6 @@ export type DisposeCheck = { loadingSessions: boolean } -export type RootLoadArgs = { - directory: string - limit: number - list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }> -} - -export type RootLoadResult = { - data?: Session[] - limit: number - limited: boolean -} - export const MAX_DIR_STORES = 30 export const DIR_IDLE_TTL_MS = 20 * 60 * 1000 export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000 diff --git a/packages/app/src/context/global-sync/utils.test.ts b/packages/app/src/context/global-sync/utils.test.ts index 406c0f124e..69ca494992 100644 --- a/packages/app/src/context/global-sync/utils.test.ts +++ b/packages/app/src/context/global-sync/utils.test.ts @@ -1,36 +1,117 @@ import { describe, expect, test } from "bun:test" -import type { Agent } from "@opencode-ai/sdk/v2/client" -import { directoryKey, normalizeAgentList } from "./utils" - -const agent = (name = "build") => - ({ - name, - mode: "primary", - permission: {}, - options: {}, - }) as Agent +import type { + AgentListOutput, + ModelDefaultOutput, + ModelListOutput, + ProviderListOutput, +} from "@opencode-ai/client/promise" +import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils" describe("normalizeAgentList", () => { - test("keeps array payloads", () => { - expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")]) - }) + test("adapts current agents to the app agent shape", () => { + const result = normalizeAgentList([ + { + id: "build", + name: "Build", + mode: "primary", + hidden: false, + color: "primary", + model: { id: "gpt-5", providerID: "openai", variant: "high" }, + request: { settings: { temperature: 0.2, topP: 0.9 }, headers: {}, body: {} }, + system: "Build software", + permissions: [{ action: "read", resource: "*", effect: "allow" }], + }, + ] as AgentListOutput["data"]) - test("wraps a single agent payload", () => { - expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")]) + expect(result).toEqual([ + { + name: "build", + description: undefined, + mode: "primary", + hidden: false, + temperature: 0.2, + topP: 0.9, + color: "primary", + permission: [{ permission: "read", pattern: "*", action: "allow" }], + model: { providerID: "openai", modelID: "gpt-5" }, + variant: "high", + prompt: "Build software", + options: { temperature: 0.2, topP: 0.9 }, + steps: undefined, + }, + ]) }) +}) - test("extracts agents from keyed objects", () => { +describe("normalizePermissionRequest", () => { + test("adapts the current permission request to app state", () => { expect( - normalizeAgentList({ - build: agent("build"), - docs: agent("docs"), + normalizePermissionRequest({ + id: "permission-1", + sessionID: "session-1", + action: "read", + resources: ["README.md"], + save: ["*.md"], + metadata: { path: "README.md" }, + source: { type: "tool", messageID: "message-1", callID: "call-1" }, }), - ).toEqual([agent("build"), agent("docs")]) + ).toEqual({ + id: "permission-1", + sessionID: "session-1", + permission: "read", + patterns: ["README.md"], + always: ["*.md"], + metadata: { path: "README.md" }, + tool: { messageID: "message-1", callID: "call-1" }, + }) }) +}) - test("drops invalid payloads", () => { - expect(normalizeAgentList({ name: "AbortError" })).toEqual([]) - expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")]) +describe("normalizeProviderList", () => { + test("groups current models into the app provider catalog", () => { + const result = normalizeProviderList( + [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] as ProviderListOutput["data"], + [ + { + id: "gpt-5", + modelID: "gpt-5", + providerID: "openai", + name: "GPT-5", + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + variants: [{ id: "high" }], + time: { released: 1 }, + cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }], + status: "active", + enabled: true, + limit: { context: 128_000, output: 8_192 }, + }, + { + id: "gpt-old", + modelID: "gpt-old", + providerID: "openai", + name: "GPT Old", + capabilities: { tools: false, input: ["text"], output: ["text"] }, + variants: [], + time: { released: 0 }, + cost: [], + status: "deprecated", + enabled: true, + limit: { context: 1, output: 1 }, + }, + ] as ModelListOutput["data"], + { id: "gpt-5", providerID: "openai" } as ModelDefaultOutput["data"], + ) + + expect(result.connected).toEqual(["openai"]) + expect(result.default).toEqual({ openai: "gpt-5" }) + expect(result.all.get("openai")?.models["gpt-old"]).toBeUndefined() + expect(result.all.get("openai")?.models["gpt-5"]).toMatchObject({ + id: "gpt-5", + providerID: "openai", + capabilities: { toolcall: true, attachment: true }, + cost: { input: 1, output: 2 }, + variants: { high: {} }, + }) }) }) diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts index e54bc88d4d..59632e53c9 100644 --- a/packages/app/src/context/global-sync/utils.ts +++ b/packages/app/src/context/global-sync/utils.ts @@ -1,39 +1,152 @@ -import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client" +import type { + AgentListOutput, + ModelDefaultOutput, + ModelListOutput, + PermissionV2Request, + ProviderListOutput, +} from "@opencode-ai/client/promise" +import type { Agent, PermissionRequest, Project, Provider, ProviderListResponse } from "@opencode-ai/sdk/v2/client" +import type { Project as CurrentProject } from "@opencode-ai/client/promise" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key" export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) -function isAgent(input: unknown): input is Agent { - if (!input || typeof input !== "object") return false - const item = input as { name?: unknown; mode?: unknown } - if (typeof item.name !== "string") return false - return item.mode === "subagent" || item.mode === "primary" || item.mode === "all" +export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] { + if (input.every((agent) => !("request" in agent))) return input as Agent[] + return (input as AgentListOutput["data"]).map((agent) => ({ + name: agent.id, + description: agent.description, + mode: agent.mode, + hidden: agent.hidden, + temperature: + typeof agent.request.settings.temperature === "number" ? agent.request.settings.temperature : undefined, + topP: typeof agent.request.settings.topP === "number" ? agent.request.settings.topP : undefined, + color: agent.color, + permission: agent.permissions.map((rule) => ({ + permission: rule.action, + pattern: rule.resource, + action: rule.effect, + })), + model: agent.model && { providerID: agent.model.providerID, modelID: agent.model.id }, + variant: agent.model?.variant, + prompt: agent.system, + options: agent.request.settings, + steps: agent.steps, + })) } -export function normalizeAgentList(input: unknown): Agent[] { - if (Array.isArray(input)) return input.filter(isAgent) - if (isAgent(input)) return [input] - if (!input || typeof input !== "object") return [] - return Object.values(input).filter(isAgent) -} - -export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse { +export function normalizePermissionRequest(input: PermissionV2Request | PermissionRequest): PermissionRequest { + if ("permission" in input) return input return { - ...input, - all: new Map( - input.all.map( - (provider) => - [ - provider.id, - { - ...provider, - models: Object.fromEntries( - Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"), - ), - }, - ] as const, + id: input.id, + sessionID: input.sessionID, + permission: input.action, + patterns: input.resources, + always: input.save ?? [], + metadata: input.metadata ?? {}, + tool: + input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.callID } : undefined, + } +} + +export function normalizeProviderList( + providers: ProviderListOutput["data"] | ProviderListResponse, + models?: ModelListOutput["data"], + defaultModel?: ModelDefaultOutput["data"], +): NormalizedProviderListResponse { + if (!Array.isArray(providers)) { + return { + ...providers, + all: new Map( + providers.all.map((provider) => [ + provider.id, + { + ...provider, + models: Object.fromEntries( + Object.entries(provider.models).filter(([, model]) => model.status !== "deprecated"), + ), + }, + ]), ), + } + } + const all = new Map() + + for (const provider of providers) { + all.set(provider.id, { + id: provider.id, + name: provider.name, + source: "custom", + env: [], + options: provider.settings ?? {}, + models: {}, + }) + } + + for (const model of models ?? []) { + const provider = all.get(model.providerID) + if (!provider || model.status === "deprecated") continue + const cost = model.cost.find((item) => item.tier === undefined) ?? model.cost[0] + provider.models[model.id] = { + id: model.id, + providerID: model.providerID, + api: { + id: model.modelID, + url: "", + npm: model.package ?? provider.id, + }, + name: model.name, + family: model.family, + capabilities: { + temperature: false, + reasoning: false, + attachment: model.capabilities.input.some((item) => item !== "text"), + toolcall: model.capabilities.tools, + input: { + text: model.capabilities.input.includes("text"), + audio: model.capabilities.input.includes("audio"), + image: model.capabilities.input.includes("image"), + video: model.capabilities.input.includes("video"), + pdf: model.capabilities.input.includes("pdf"), + }, + output: { + text: model.capabilities.output.includes("text"), + audio: model.capabilities.output.includes("audio"), + image: model.capabilities.output.includes("image"), + video: model.capabilities.output.includes("video"), + pdf: model.capabilities.output.includes("pdf"), + }, + interleaved: false, + }, + cost: { + input: cost?.input ?? 0, + output: cost?.output ?? 0, + cache: { + read: cost?.cache.read ?? 0, + write: cost?.cache.write ?? 0, + }, + }, + limit: model.limit, + status: model.status, + options: model.settings ?? {}, + headers: model.headers ?? {}, + release_date: new Date(model.time.released).toISOString().slice(0, 10), + variants: Object.fromEntries(model.variants.map((variant) => [variant.id, variant.settings ?? {}])), + } + } + + return { + all, + connected: providers.map((provider) => provider.id), + default: Object.fromEntries( + providers.flatMap((provider) => { + const model = + defaultModel?.providerID === provider.id + ? defaultModel + : models?.find((item) => item.providerID === provider.id && item.status !== "deprecated") + return model ? [[provider.id, model.id]] : [] + }), ), } } @@ -49,3 +162,10 @@ export function sanitizeProject(project: Project) { }, } } + +export function normalizeProjectInfo(project: Project | CurrentProject): Project { + return { + ...project, + vcs: project.vcs === "git" ? "git" : undefined, + } +} diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 7ac248dc1d..6235f35c45 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -8,6 +8,7 @@ import { useServerSDK } from "./server-sdk" import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server" import { usePlatform } from "./platform" import { Project } from "@opencode-ai/sdk/v2" +import { normalizeProjectInfo } from "./global-sync/utils" import { Persist, persisted, removePersisted } from "@/utils/persist" import { pathKey } from "@/utils/path-key" import { decode64 } from "@/utils/base64" @@ -126,7 +127,7 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => { } } -const currentRoute = (pathname: string, search: string): LayoutRoute => { +export const currentRoute = (pathname: string, search: string): LayoutRoute => { const parts = pathname.split("/").filter(Boolean) if (parts.length === 0) return { type: "home" } @@ -569,11 +570,22 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( continue } - void serverSdk() - .client.project.update({ projectID: project.id, directory: worktree, icon: { color } }) - .catch(() => { - if (colorRequested.get(worktree) === color) colorRequested.delete(worktree) - }) + const projectID = project.id + void (async () => { + const sdk = serverSdk() + if ((await sdk.protocol) !== "v1") return + return sdk.client.project + .update({ projectID, directory: worktree, icon: { color } }) + .then((response) => response.data) + .then((result) => { + if (!result) return + serverSync().set("project", (items) => + items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)), + ) + }) + })().catch(() => { + if (colorRequested.get(worktree) === color) colorRequested.delete(worktree) + }) } }) diff --git a/packages/app/src/context/models.tsx b/packages/app/src/context/models.tsx index 736dc3a7c5..a80cf2e580 100644 --- a/packages/app/src/context/models.tsx +++ b/packages/app/src/context/models.tsx @@ -26,7 +26,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext( name: "Models", gate: false, init: (props: { directory?: Accessor } = {}) => { - const providers = useProviders(props.directory) + const providers = useProviders(() => props.directory?.()) const [store, setStore, _, ready] = persisted( Persist.global("model", ["model.v1"]), diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index e12f8f1250..799c4713dd 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -1,6 +1,6 @@ import { createStore, reconcile } from "solid-js/store" import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" -import { useParams, useSearchParams } from "@solidjs/router" +import { useNavigate, useParams, useSearchParams } from "@solidjs/router" import { createSimpleContext } from "@opencode-ai/ui/context" import type { ServerSDK } from "./server-sdk" import type { ServerSync } from "./server-sync" @@ -118,6 +118,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi const global = useGlobal() const server = useServer() const tabs = useTabs() + const navigate = useNavigate() const platform = usePlatform() const settings = useSettings() const language = useLanguage() @@ -153,6 +154,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi platform, settings, language, + navigate, }), }), owner ?? undefined, @@ -176,7 +178,14 @@ export const { use: useNotification, provider: NotificationProvider } = createSi onCleanup(() => states.forEach((value) => value.dispose())) - const selected = () => ensure(activeServer()) + const selected = () => { + const list = global.servers.list() + const key = activeServer() + if (list.some((conn) => ServerConnection.key(conn) === key)) return ensure(key) + const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0] + if (!conn) throw new Error("Notification server not found") + return ensure(ServerConnection.key(conn)) + } return { ready: () => selected().ready(), @@ -210,6 +219,7 @@ function createServerNotificationState(input: { platform: ReturnType settings: ReturnType language: ReturnType + navigate: (href: string) => void }) { const serverSDK = () => input.sdk const serverSync = () => input.sync @@ -346,7 +356,9 @@ function createServerNotificationState(input: { const href = `/${base64Encode(directory)}/session/${sessionID}` if (settings.notifications.agent()) { - void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href) + void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () => + input.navigate(href), + ) } }) } @@ -379,7 +391,7 @@ function createServerNotificationState(input: { (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription")) const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}` if (settings.notifications.errors()) { - void platform.notify(language.t("notification.session.error.title"), description, href) + void platform.notify(language.t("notification.session.error.title"), description, () => input.navigate(href)) } }) } diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index 496d1ab4a2..d6d8019262 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -13,6 +13,7 @@ import { type DraftTab, useTabs } from "./tabs" import { useSettings } from "./settings" import { requireServerKey } from "@/utils/session-route" import type { ServerScope } from "@/utils/server-scope" +import { normalizePermissionRequest } from "./global-sync/utils" import { acceptKey, directoryAcceptKey, @@ -211,6 +212,7 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync } ) function enableConfiguredDirectory(directory: string) { + if (input.sdk.protocolKind() !== "v1") return if (meta.disposed || !ready()) return const [childStore] = input.sync.child(directory) if (childStore.config.permission !== "allow") return @@ -243,9 +245,25 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync } const respond: PermissionRespondFn = (request) => { if (meta.disposed) return - input.sdk.client.permission.respond(request).catch(() => { - responded.delete(request.permissionID) - }) + input.sdk.api.permission + .reply({ + sessionID: request.sessionID, + requestID: request.permissionID, + reply: request.response, + location: request.directory ? { directory: request.directory } : undefined, + }) + .catch(() => { + responded.delete(request.permissionID) + }) + } + + const list = async (directory: string) => { + if ((await input.sdk.protocol) === "v1") { + return (await input.sdk.client.permission.list({ directory })).data ?? [] + } + return input.sdk.api.permission.request + .list({ location: { directory } }) + .then((result) => result.data.map(normalizePermissionRequest)) } function respondOnce(permission: PermissionRequest, directory?: string) { @@ -343,14 +361,12 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync } }), ) - input.sdk.client.permission - .list({ directory }) - .then((x) => { + list(directory) + .then((permissions) => { if (meta.disposed) return if (!isAutoAcceptingDirectory(directory)) return - for (const perm of x.data ?? []) { - if (!perm?.id) continue - void respondPending(perm, directory, () => isAutoAcceptingDirectory(directory)) + for (const permission of permissions) { + void respondPending(permission, directory, () => isAutoAcceptingDirectory(directory)) } }) .catch(() => undefined) @@ -377,16 +393,14 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync } }), ) - input.sdk.client.permission - .list({ directory }) - .then((x) => { + list(directory) + .then((permissions) => { if (meta.disposed) return if (enableVersion.get(key) !== version) return if (!isAutoAccepting(sessionID, directory)) return - for (const perm of x.data ?? []) { - if (!perm?.id) continue + for (const permission of permissions) { void respondPending( - perm, + permission, directory, () => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory), ) diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 1a061a6675..0408b233ff 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -5,6 +5,7 @@ import type { DesktopMenuAction } from "../desktop-menu" import { ServerConnection } from "./server" import type { WslServersPlatform } from "../wsl/types" import type { UpdaterPlatform } from "../updater" +import type { DraftStore } from "@/utils/draft-store" type PickerPaths = string | string[] | null type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean } @@ -31,26 +32,23 @@ type PlatformBase = { /** App version */ version?: string - /** Open a URL in the default browser */ - openLink(url: string): void + /** Open a web or mail URL in the default system application */ + openExternal(url: string): void /** Open a local path in a local app (desktop only) */ openPath?(path: string, app?: string): Promise + /** Open a local file URL in its default app (desktop only) */ + openLocalFile?(url: string): void + /** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */ revealPath?(path: string): Promise /** Restart the app */ restart(): Promise - /** Navigate back in history */ - back(): void - - /** Navigate forward in history */ - forward(): void - - /** Send a system notification (optional deep link) */ - notify(title: string, description?: string, href?: string): Promise + /** Send a system notification */ + notify(title: string, description?: string, onClick?: () => void): Promise /** Open a native attachment picker and read selected files sequentially (desktop only) */ openAttachmentPickerDialog?( @@ -67,6 +65,9 @@ type PlatformBase = { /** Storage mechanism, defaults to localStorage */ storage?: (name?: string) => SyncStorage | AsyncStorage + /** Prompt drafts, history, and their blobs. */ + draftStore?: DraftStore + /** Stable platform window identity for window-scoped persistence */ windowID?: string @@ -91,12 +92,12 @@ type PlatformBase = { /** Set the preferred display backend (desktop only) */ setDisplayBackend?(backend: DisplayBackend): Promise - /** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */ - parseMarkdown?(markdown: string): Promise - /** Webview zoom level (desktop only) */ webviewZoom?: Accessor + /** Whether the native desktop window is fullscreen */ + windowFullscreen?: Accessor + /** Get whether native pinch/Ctrl-scroll zoom gestures are enabled (desktop only) */ getPinchZoomEnabled?(): Promise | boolean @@ -115,6 +116,9 @@ type PlatformBase = { /** Export collected diagnostic logs (desktop only) */ exportDebugLogs?(): Promise + /** Force focus styles on interactive elements through desktop devtools (desktop only) */ + setForceFocus?(enabled: boolean): Promise + /** Record a fatal renderer error in platform logs (desktop only) */ recordFatalRendererError?(error: FatalRendererErrorLog): Promise } diff --git a/packages/app/src/context/prompt-state.ts b/packages/app/src/context/prompt-state.ts index 462cfa638b..ea639a5bdd 100644 --- a/packages/app/src/context/prompt-state.ts +++ b/packages/app/src/context/prompt-state.ts @@ -5,6 +5,7 @@ import { createStore, type SetStoreFunction } from "solid-js/store" import type { FileSelection } from "@/context/file" import { Persist, persisted } from "@/utils/persist" import type { ServerScope } from "@/utils/server-scope" +import type { BlobReference } from "@/utils/draft-store" interface PartBase { content: string @@ -37,7 +38,7 @@ export interface ImageAttachmentPart { filename: string sourcePath?: string mime: string - dataUrl: string + blob: BlobReference } export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart @@ -64,7 +65,7 @@ export type PromptScope = { draftID: string } | { dir: string; id?: string } export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] -type PromptStore = { +export type PromptStore = { prompt: Prompt cursor?: number model?: PromptModel @@ -168,9 +169,9 @@ function createPromptActions(setStore: SetStoreFunction) { } function promptTarget(serverScope: ServerScope, scope: PromptScope) { - if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt") + if ("draftID" in scope) return Persist.prompt(Persist.draft(scope.draftID, "prompt")) const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` - return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy]) + return Persist.prompt(Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])) } function promptStore(initial?: InitialPrompt): PromptStore { @@ -189,6 +190,7 @@ function promptStore(initial?: InitialPrompt): PromptStore { function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction) { const actions = createPromptActions(setStore) const value = { + store: [() => store, setStore] as [Accessor, SetStoreFunction], current: () => store.prompt, cursor: createMemo(() => store.cursor), dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT), @@ -244,7 +246,7 @@ export function createPromptSession(serverScope: ServerScope, scope: PromptScope } export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) { - return createPersistedPrompt(Persist.draft(draftID, "prompt"), initial) + return createPersistedPrompt(Persist.prompt(Persist.draft(draftID, "prompt")), initial) } export type PromptSession = ReturnType diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 14147b6616..41a99f3b67 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -1,7 +1,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { createSimpleContext } from "@opencode-ai/ui/context" import { useParams, useSearchParams } from "@solidjs/router" -import { createMemo, createRoot, getOwner, onCleanup } from "solid-js" +import { createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js" import { requireServerKey } from "@/utils/session-route" import { ServerConnection } from "./server" import { useServerSDK } from "./server-sdk" @@ -36,6 +36,7 @@ export type { ImageAttachmentPart, Prompt, PromptModel, + PromptStore, PromptScope, PromptSession, TextPart, @@ -132,18 +133,29 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( const pick = (scope?: PromptScope) => (scope ? load(scope) : session()) const ready = createPromptReady(session) + const withSuspense = (cb: () => T): (() => T) => + createResource( + async () => { + const value = cb() + await session().ready.promise + return value + }, + cb, + { initialValue: cb() }, + )[0] + return { ready, capture: (scope?: PromptScope) => pick(scope).capture(), - current: () => session().current(), - cursor: () => session().cursor(), - dirty: () => session().dirty(), + current: withSuspense(() => session().current()), + cursor: withSuspense(() => session().cursor()), + dirty: withSuspense(() => session().dirty()), model: { - current: () => session().model.current(), + current: withSuspense(() => session().model.current()), set: (model: PromptModel | undefined) => session().model.set(model), }, context: { - items: () => session().context.items(), + items: withSuspense(() => session().context.items()), add: (item: ContextItem) => session().context.add(item), remove: (key: string) => session().context.remove(key), removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID), diff --git a/packages/app/src/context/server-sdk.test.ts b/packages/app/src/context/server-sdk.test.ts index 7b592178fa..57e1cd86f3 100644 --- a/packages/app/src/context/server-sdk.test.ts +++ b/packages/app/src/context/server-sdk.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" -import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" +import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" +import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { Event } from "@opencode-ai/sdk/v2/client" describe("resumeStreamAfterPageShow", () => { @@ -14,6 +15,23 @@ describe("resumeStreamAfterPageShow", () => { }) }) +describe("adaptServerEvent", () => { + test("preserves V2 events while adapting permission requests for existing consumers", () => { + const current = { + id: "evt_1", + created: 1, + type: "permission.v2.asked", + data: { id: "perm_1", sessionID: "ses_1", action: "read", resources: ["src/**"] }, + } as OpenCodeEvent + + expect(adaptServerEvent(current)).toMatchObject({ + type: "permission.asked", + properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] }, + current, + }) + }) +}) + describe("coalesceServerEvents", () => { const delta = (value: string, field = "text", partID = "part") => ({ directory: "/repo", @@ -34,6 +52,24 @@ describe("coalesceServerEvents", () => { expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } }) }) + test("merges adjacent current text deltas", () => { + const current = (id: string, value: string) => + adaptServerEvent({ + id, + created: 1, + type: "session.text.delta", + location: { directory: "/repo" }, + data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value }, + } as OpenCodeEvent) + const result = coalesceServerEvents([ + { directory: "/repo", payload: current("evt_1", "hello ") }, + { directory: "/repo", payload: current("evt_2", "world") }, + ]) + + expect(result).toHaveLength(1) + expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } }) + }) + test("preserves event boundaries and distinct fields", () => { const status = { directory: "/repo", diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 06597e56e7..7dd2a6e59e 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -1,21 +1,60 @@ +import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { Event } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { makeEventListener } from "@solid-primitives/event-listener" -import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js" -import { createSdkForServer } from "@/utils/server" +import { type Accessor, batch, createMemo, createResource, onCleanup, onMount } from "solid-js" +import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server" import { useLanguage } from "./language" import { usePlatform } from "./platform" import { ServerConnection, useServer } from "./server" import { createRefCountMap } from "@/utils/refcount" import { useGlobal } from "./global" import { ServerScope } from "@/utils/server-scope" +import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol" +import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat" const isAbortError = (error: unknown) => error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true -type QueuedServerEvent = { directory: string; payload: Event } +export type ServerEvent = Event & { current?: OpenCodeEvent } +type QueuedServerEvent = { directory: string; payload: ServerEvent } +type CurrentDelta = Extract< + OpenCodeEvent, + { type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" } +> + +export function adaptServerEvent(event: OpenCodeEvent): ServerEvent { + if (event.type === "permission.v2.asked") { + return { + id: event.id, + type: "permission.asked", + properties: { + id: event.data.id, + sessionID: event.data.sessionID, + permission: event.data.action, + patterns: event.data.resources, + always: event.data.save ?? [], + metadata: event.data.metadata ?? {}, + tool: + event.data.source?.type === "tool" + ? { messageID: event.data.source.messageID, callID: event.data.source.callID } + : undefined, + }, + current: event, + } as ServerEvent + } + if (event.type === "permission.v2.replied") + return { id: event.id, type: "permission.replied", properties: event.data, current: event } as ServerEvent + if (event.type === "question.v2.asked") + return { id: event.id, type: "question.asked", properties: event.data, current: event } as ServerEvent + if (event.type === "question.v2.replied") + return { id: event.id, type: "question.replied", properties: event.data, current: event } as ServerEvent + if (event.type === "question.v2.rejected") + return { id: event.id, type: "question.rejected", properties: event.data, current: event } as ServerEvent + return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent +} const coalescedKey = (event: QueuedServerEvent) => { if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}` @@ -40,6 +79,34 @@ export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServ export function coalesceServerEvents(events: QueuedServerEvent[]) { const output: QueuedServerEvent[] = [] events.forEach((event) => { + const current = currentDelta(event.payload.current) + if (current) { + const previous = output[output.length - 1] + const prior = currentDelta(previous?.payload.current) + if ( + previous && + prior && + previous.directory === event.directory && + currentDeltaKey(prior) === currentDeltaKey(current) + ) { + const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current) + const data = + current.type === "session.compaction.delta" + ? { ...current.data, text: fragment } + : { ...current.data, delta: fragment } + output[output.length - 1] = { + directory: event.directory, + payload: { + ...event.payload, + properties: data, + current: { ...current, data } as CurrentDelta, + } as ServerEvent, + } + return + } + output.push(event) + return + } if (event.payload.type !== "message.part.delta") { output.push(event) return @@ -71,12 +138,53 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) { return output } +function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined { + if ( + event?.type === "session.text.delta" || + event?.type === "session.reasoning.delta" || + event?.type === "session.tool.input.delta" || + event?.type === "session.compaction.delta" + ) + return event +} + +function currentDeltaKey(event: CurrentDelta) { + if (event.type === "session.tool.input.delta") + return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.callID}` + if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}` + return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}` +} + +function currentDeltaFragment(event: CurrentDelta) { + return event.type === "session.compaction.delta" ? event.data.text : event.data.delta +} + export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) { if (!event.persisted) return start() } -function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) { +type ServerEventEmitter = ReturnType> +type ServerSDKBase = { + server: ServerConnection.Any + scope: ServerScope + protocol: Promise + protocolKind: Accessor + url: string + client: ReturnType + api: CompatibleApi + currentApi: ServerApi + event: { + on: ServerEventEmitter["on"] + listen: ServerEventEmitter["listen"] + start: () => Promise | undefined + } + createClient: ( + opts: Omit[0], "server" | "fetch">, + ) => ReturnType +} + +function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase { const platform = usePlatform() const abort = new AbortController() @@ -91,13 +199,19 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } })() + const eventApi = createApiForServer({ server: server.http, fetch: eventFetch }) const eventSdk = createSdkForServer({ signal: abort.signal, fetch: eventFetch, server: server.http, }) + const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch) + const [protocolKind] = createResource( + () => protocol, + (value) => value, + ) const emitter = createGlobalEmitter<{ - [key: string]: Event + [key: string]: ServerEvent }>() type Queued = QueuedServerEvent @@ -142,21 +256,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS let run: Promise | undefined let started = false let generation = 0 - const HEARTBEAT_TIMEOUT_MS = 15_000 - let lastEventAt = Date.now() - let heartbeat: ReturnType | undefined - const resetHeartbeat = () => { - lastEventAt = Date.now() - if (heartbeat) clearTimeout(heartbeat) - heartbeat = setTimeout(() => { - attempt?.abort() - }, HEARTBEAT_TIMEOUT_MS) - } - const clearHeartbeat = () => { - if (!heartbeat) return - clearTimeout(heartbeat) - heartbeat = undefined - } const start = () => { if (started) return run @@ -168,35 +267,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit while (!abort.signal.aborted && started && generation === active) { attempt = new AbortController() - lastEventAt = Date.now() const onAbort = () => { attempt?.abort() } abort.signal.addEventListener("abort", onAbort) try { - const events = await eventSdk.global.event({ - signal: attempt.signal, - onSseError: (error) => { - if (isStreamClosed(error, attempt?.signal)) return - if (streamErrorLogged) return - streamErrorLogged = true - console.error("[global-sdk] event stream error", { - url: server.http.url, - fetch: eventFetch ? "platform" : "webview", - error, - }) - }, - }) + const kind = await protocol + const events = + kind === "v1" + ? (await eventSdk.global.event({ signal: attempt.signal })).stream + : eventApi.event.subscribe({ signal: attempt.signal }) let yielded = Date.now() - resetHeartbeat() - for await (const event of events.stream) { - resetHeartbeat() + for await (const event of events) { streamErrorLogged = false - if (event.payload.type !== "sync") { - const directory = event.directory ?? "global" - const payload = event.payload as Event - if (enqueueServerEvent(queue, { directory, payload })) schedule() - } + const legacy = "payload" in event + if (legacy && event.payload.type === "sync") continue + const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global") + const payload = legacy ? (event.payload as Event) : adaptServerEvent(event) + if (enqueueServerEvent(queue, { directory, payload })) schedule() if (Date.now() - yielded < STREAM_YIELD_MS) continue yielded = Date.now() @@ -214,7 +302,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } finally { abort.signal.removeEventListener("abort", onAbort) attempt = undefined - clearHeartbeat() } if (abort.signal.aborted || !started || generation !== active) return @@ -233,18 +320,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS started = false generation++ attempt?.abort() - clearHeartbeat() } onMount(() => { makeEventListener(window, "pagehide", stop) makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start)) - makeEventListener(document, "visibilitychange", () => { - if (document.visibilityState !== "visible") return - if (!started) return - if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return - attempt?.abort() - }) }) onCleanup(() => { @@ -258,12 +338,25 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS fetch: platform.fetch, throwOnError: true, }) + const currentApi: ServerApi = createApiForServer({ server: server.http, fetch: platform.fetch }) + const legacy = (directory?: string) => + createSdkForServer({ + server: server.http, + fetch: platform.fetch, + throwOnError: true, + directory, + }) + const api = createCompatibleApi({ protocol, current: currentApi, legacy }) return { server, scope, + protocol, + protocolKind, url: server.http.url, client: sdk, + api, + currentApi, event: { on: emitter.on.bind(emitter), listen: emitter.listen.bind(emitter), @@ -279,7 +372,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } } -type ServerSDKBase = ReturnType export type ServerSDK = ServerSDKBase & { ensureDirSdkContext: (directory: string) => ReturnType } @@ -308,8 +400,13 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo }, }) +export function useServerProtocol() { + const serverSDK = useServerSDK() + return createMemo(() => serverSDK().protocolKind()) +} + type SDKEventMap = { - [key in Event["type"]]: Extract + [key in Event["type"]]: Extract } function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { @@ -327,8 +424,15 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { return { scope: serverSDK.scope, + protocol: serverSDK.protocol, directory, client, + api: createCompatibleApi({ + protocol: serverSDK.protocol, + current: serverSDK.currentApi, + legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }), + directory, + }), event: emitter, get url() { return serverSDK.url diff --git a/packages/app/src/context/server-session-v2-reducer.test.ts b/packages/app/src/context/server-session-v2-reducer.test.ts new file mode 100644 index 0000000000..00cc37cf52 --- /dev/null +++ b/packages/app/src/context/server-session-v2-reducer.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from "bun:test" +import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise" +import { createV2SessionReducer } from "./server-session-v2-reducer" + +const event = (input: object) => input as OpenCodeEvent +const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } } + +describe("v2 session reducer", () => { + test("projects promoted input and streaming assistant content", () => { + const reducer = createV2SessionReducer() + let messages: SessionMessageInfo[] = [] + const apply = (input: object) => { + const result = reducer.reduce(messages, event(input)) + if (result) messages = result.messages + return result + } + + apply({ + ...base, + id: "evt_admitted", + type: "session.input.admitted", + data: { + sessionID: "ses_1", + inputID: "msg_user", + input: { type: "user", delivery: "steer", data: { text: "hello" } }, + }, + }) + apply({ + ...base, + id: "evt_promoted", + type: "session.input.promoted", + data: { sessionID: "ses_1", inputID: "msg_user" }, + }) + apply({ + ...base, + id: "evt_step", + type: "session.step.started", + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + apply({ + ...base, + id: "evt_text_start", + type: "session.text.started", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0 }, + }) + apply({ + ...base, + id: "evt_text_delta", + type: "session.text.delta", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, delta: "hel" }, + }) + apply({ + ...base, + id: "evt_text_end", + type: "session.text.ended", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, text: "hello" }, + }) + + expect(messages[0]).toMatchObject({ id: "msg_user", type: "user", text: "hello" }) + expect(messages[1]).toMatchObject({ + id: "msg_assistant", + type: "assistant", + content: [{ type: "text", text: "hello" }], + }) + }) + + test("folds tool, retry, and completion events", () => { + const reducer = createV2SessionReducer() + let messages: SessionMessageInfo[] = [] + const apply = (input: object) => { + const result = reducer.reduce(messages, event(input)) + if (result) messages = result.messages + } + + apply({ + ...base, + id: "evt_step", + type: "session.step.started", + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + apply({ + ...base, + id: "evt_tool_start", + type: "session.tool.input.started", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", name: "bash" }, + }) + apply({ + ...base, + id: "evt_tool_delta", + type: "session.tool.input.delta", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", delta: "{}" }, + }) + apply({ + ...base, + id: "evt_tool_called", + type: "session.tool.called", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", input: {}, executed: true }, + }) + apply({ + ...base, + id: "evt_tool_success", + type: "session.tool.success", + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + callID: "call_1", + metadata: {}, + content: [{ type: "text", text: "done" }], + executed: true, + }, + }) + apply({ + ...base, + id: "evt_retry", + type: "session.retry.scheduled", + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + attempt: 2, + at: 10, + error: { type: "ProviderError", message: "retry" }, + }, + }) + apply({ ...base, id: "evt_done", type: "session.execution.succeeded", data: { sessionID: "ses_1" } }) + + expect(messages[0]).toMatchObject({ + type: "assistant", + retry: undefined, + content: [{ type: "tool", id: "call_1", state: { status: "completed", content: [{ text: "done" }] } }], + }) + }) + + test("requests hydration when promotion admission was missed", () => { + const result = createV2SessionReducer().reduce( + [], + event({ + ...base, + id: "evt_promoted", + type: "session.input.promoted", + data: { sessionID: "ses_1", inputID: "msg_user" }, + }), + ) + + expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] }) + }) +}) diff --git a/packages/app/src/context/server-session-v2-reducer.ts b/packages/app/src/context/server-session-v2-reducer.ts new file mode 100644 index 0000000000..b34ab3985e --- /dev/null +++ b/packages/app/src/context/server-session-v2-reducer.ts @@ -0,0 +1,509 @@ +import type { OpenCodeEvent, SessionMessageInfo, SessionPendingMessage } from "@opencode-ai/client/promise" + +type Assistant = Extract +type Compaction = Extract +type Shell = Extract + +export type V2SessionReduction = { + sessionID: string + messages: SessionMessageInfo[] + touched: string[] + missing?: string +} + +export function createV2SessionReducer() { + const pending = new Map() + + const reduce = (source: readonly SessionMessageInfo[], event: OpenCodeEvent): V2SessionReduction | undefined => { + if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return + const sessionID = event.data.sessionID + const result = (messages: SessionMessageInfo[], touched: string[] = []): V2SessionReduction => ({ + sessionID, + messages, + touched, + }) + const append = (message: SessionMessageInfo) => + result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id]) + + switch (event.type) { + case "session.input.admitted": + pending.set(key(sessionID, event.data.inputID), event.data.input) + return result([...source]) + case "session.input.promoted": { + const input = pending.get(key(sessionID, event.data.inputID)) + pending.delete(key(sessionID, event.data.inputID)) + if (!input) return { ...result([...source]), missing: event.data.inputID } + if (input.type === "user") + return append({ + id: event.data.inputID, + type: "user", + metadata: input.data.metadata, + text: input.data.text, + files: input.data.files, + agents: input.data.agents, + time: { created: event.created }, + }) + return append({ + id: event.data.inputID, + type: "synthetic", + metadata: input.data.metadata, + text: input.data.text, + description: input.data.description, + time: { created: event.created }, + }) + } + case "session.agent.selected": + return append({ + id: messageID(event.id), + type: "agent-switched", + metadata: event.metadata, + agent: event.data.agent, + time: { created: event.created }, + }) + case "session.model.selected": + return append({ + id: messageID(event.id), + type: "model-switched", + metadata: event.metadata, + model: event.data.model, + previous: source.findLast( + (item): item is Extract => + item.type === "model-switched" || item.type === "assistant", + )?.model, + time: { created: event.created }, + }) + case "session.synthetic": + return append({ + id: messageID(event.id), + type: "synthetic", + metadata: event.data.metadata, + text: event.data.text, + description: event.data.description, + time: { created: event.created }, + }) + case "session.skill.activated": + return append({ + id: messageID(event.id), + type: "skill", + metadata: event.metadata, + skill: event.data.id, + name: event.data.name, + text: event.data.text, + time: { created: event.created }, + }) + case "session.shell.started": + return append({ + id: messageID(event.id), + type: "shell", + metadata: event.metadata, + shellID: event.data.shell.id, + command: event.data.shell.command, + status: event.data.shell.status, + exit: event.data.shell.exit, + time: { created: event.created }, + }) + case "session.shell.ended": + return updateMessage( + source, + (item): item is Shell => item.type === "shell" && item.shellID === event.data.shell.id, + (item) => ({ + ...item, + status: event.data.shell.status, + exit: event.data.shell.exit, + output: event.data.output, + time: { ...item.time, completed: event.created }, + }), + sessionID, + ) + case "session.step.started": { + const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed) + const completed = + current && current.id !== event.data.assistantMessageID + ? update(source, current.id, (item) => + item.type === "assistant" + ? { ...item, retry: undefined, time: { ...item.time, completed: event.created } } + : item, + ) + : [...source] + const existing = completed.find((item) => item.id === event.data.assistantMessageID) + if (existing?.type === "assistant") + return result( + update(completed, existing.id, (item) => + item.type === "assistant" + ? { + ...item, + agent: event.data.agent, + model: event.data.model, + retry: undefined, + error: undefined, + finish: undefined, + snapshot: event.data.snapshot ? { ...item.snapshot, start: event.data.snapshot } : item.snapshot, + time: { ...item.time, completed: undefined }, + } + : item, + ), + current && current.id !== existing.id ? [current.id, existing.id] : [existing.id], + ) + return result( + [ + ...completed, + { + id: event.data.assistantMessageID, + type: "assistant", + metadata: event.metadata, + agent: event.data.agent, + model: event.data.model, + content: [], + snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, + time: { created: event.created }, + }, + ], + current ? [current.id, event.data.assistantMessageID] : [event.data.assistantMessageID], + ) + } + case "session.step.ended": + return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ + ...item, + finish: event.data.finish, + cost: event.data.cost, + tokens: event.data.tokens, + snapshot: + event.data.snapshot || event.data.files + ? { ...item.snapshot, end: event.data.snapshot, files: event.data.files } + : item.snapshot, + time: { ...item.time, completed: event.created }, + })) + case "session.step.failed": + return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ + ...item, + finish: "error", + error: event.data.error, + retry: undefined, + cost: event.data.cost ?? item.cost, + tokens: event.data.tokens ?? item.tokens, + snapshot: + event.data.snapshot || event.data.files + ? { ...item.snapshot, end: event.data.snapshot, files: event.data.files } + : item.snapshot, + time: { ...item.time, completed: event.created }, + })) + case "session.text.started": + return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ + ...item, + content: insertOrdinal(item.content, "text", event.data.ordinal, { type: "text", text: "" }), + })) + case "session.text.delta": + return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({ + ...item, + text: item.text + event.data.delta, + })) + case "session.text.ended": + return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({ + ...item, + text: event.data.text, + })) + case "session.reasoning.started": + return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ + ...item, + content: insertOrdinal(item.content, "reasoning", event.data.ordinal, { + type: "reasoning", + text: "", + state: event.data.state, + time: { created: event.created }, + }), + })) + case "session.reasoning.delta": + return updateContent( + source, + event.data.assistantMessageID, + sessionID, + "reasoning", + event.data.ordinal, + (item) => ({ + ...item, + text: item.text + event.data.delta, + }), + ) + case "session.reasoning.ended": + return updateContent( + source, + event.data.assistantMessageID, + sessionID, + "reasoning", + event.data.ordinal, + (item) => ({ + ...item, + text: event.data.text, + state: event.data.state ?? item.state, + time: { created: item.time?.created ?? event.created, completed: event.created }, + }), + ) + case "session.tool.input.started": + return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ + ...item, + content: item.content.some((content) => content.type === "tool" && content.id === event.data.callID) + ? item.content + : [ + ...item.content, + { + type: "tool", + id: event.data.callID, + name: event.data.name, + state: { status: "streaming", input: "" }, + time: { created: event.created }, + }, + ], + })) + case "session.tool.input.delta": + return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => + tool.state.status === "streaming" + ? { ...tool, state: { ...tool.state, input: tool.state.input + event.data.delta } } + : tool, + ) + case "session.tool.input.ended": + return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => + tool.state.status === "streaming" ? { ...tool, state: { ...tool.state, input: event.data.text } } : tool, + ) + case "session.tool.called": + return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => ({ + ...tool, + executed: event.data.executed, + providerState: event.data.state, + // structured: {}, content: [] + state: { status: "running", input: event.data.input, metadata: {} }, + time: { ...tool.time, ran: event.created }, + })) + case "session.tool.progress": + return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => + tool.state.status === "running" + ? { + ...tool, + // state: { ...tool.state, structured: event.data.structured, content: event.data.content }, + state: { ...tool.state, metadata: event.data.metadata }, + } + : tool, + ) + case "session.tool.success": + return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => { + if (tool.state.status !== "running") return tool + return { + ...tool, + executed: event.data.executed || tool.executed === true, + providerResultState: event.data.resultState, + state: { + status: "completed", + input: tool.state.input, + // structured: event.data.structured, + metadata: event.data.metadata, + content: event.data.content, + // result: event.data.result, + }, + time: { ...tool.time, completed: event.created }, + } + }) + case "session.tool.failed": + return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => { + if (tool.state.status !== "streaming" && tool.state.status !== "running") return tool + return { + ...tool, + executed: event.data.executed || tool.executed === true, + providerResultState: event.data.resultState, + state: { + status: "error", + input: typeof tool.state.input === "string" ? {} : tool.state.input, + // structured: tool.state.status === "running" ? tool.state.structured : {}, + metadata: event.data.metadata ?? (tool.state.status === "running" ? tool.state.metadata : {}), + content: event.data.content, + error: event.data.error, + // result: event.data.result, + }, + time: { ...tool.time, completed: event.created }, + } + }) + case "session.retry.scheduled": + return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ + ...item, + retry: { attempt: event.data.attempt, at: event.data.at, error: event.data.error }, + })) + case "session.execution.succeeded": + case "session.execution.failed": + case "session.execution.interrupted": { + const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed) + if (!current?.retry) return result([...source]) + return updateAssistant(source, current.id, sessionID, (item) => ({ ...item, retry: undefined })) + } + case "session.compaction.started": + return append({ + id: event.data.inputID ?? messageID(event.id), + type: "compaction", + status: "running", + metadata: event.metadata, + reason: event.data.reason, + summary: "", + recent: event.data.recent, + time: { created: event.created }, + }) + case "session.compaction.delta": + return updateMessage>( + source, + (item): item is Extract => + item.type === "compaction" && item.status === "running", + (item) => ({ + ...item, + summary: item.summary + event.data.text, + }), + sessionID, + ) + case "session.compaction.ended": { + const current = source.findLast( + (item): item is Extract => + item.type === "compaction" && item.status === "running", + ) + if (!current) + return append({ + id: messageID(event.id), + type: "compaction", + status: "completed", + metadata: event.metadata, + reason: event.data.reason, + summary: event.data.text, + recent: event.data.recent, + time: { created: event.created }, + }) + return result( + update(source, current.id, () => ({ + ...current, + status: "completed", + reason: event.data.reason, + summary: event.data.text, + recent: event.data.recent, + })), + [current.id], + ) + } + case "session.compaction.failed": { + const current = source.findLast( + (item): item is Extract => + item.type === "compaction" && item.status === "running", + ) + const failed: Extract = { + id: current?.id ?? event.data.inputID ?? messageID(event.id), + type: "compaction", + status: "failed", + metadata: current?.metadata ?? event.metadata, + reason: event.data.reason, + error: event.data.error, + time: current?.time ?? { created: event.created }, + } + if (!current) return append(failed) + return result( + update(source, current.id, () => failed), + [failed.id], + ) + } + default: + return + } + } + + return { + reduce, + clear(sessionID: string) { + for (const id of pending.keys()) { + if (id.startsWith(`${sessionID}:`)) pending.delete(id) + } + }, + } +} + +function key(sessionID: string, inputID: string) { + return `${sessionID}:${inputID}` +} + +function messageID(eventID: string) { + return eventID.replace(/^evt_/, "msg_") +} + +function update( + source: readonly SessionMessageInfo[], + id: string, + apply: (item: SessionMessageInfo) => SessionMessageInfo, +) { + return source.map((item) => (item.id === id ? apply(item) : item)) +} + +function updateMessage( + source: readonly SessionMessageInfo[], + matches: (item: SessionMessageInfo) => item is T, + apply: (item: T) => T, + sessionID: string, +): V2SessionReduction { + const current = source.findLast(matches) + if (!current) return { sessionID, messages: [...source], touched: [] } + return { + sessionID, + messages: update(source, current.id, (item) => (matches(item) ? apply(item) : item)), + touched: [current.id], + } +} + +function updateAssistant( + source: readonly SessionMessageInfo[], + id: string, + sessionID: string, + apply: (item: Assistant) => Assistant, +): V2SessionReduction { + return { + sessionID, + messages: update(source, id, (item) => (item.type === "assistant" ? apply(item) : item)), + touched: source.some((item) => item.id === id && item.type === "assistant") ? [id] : [], + } +} + +function updateContent( + source: readonly SessionMessageInfo[], + messageID: string, + sessionID: string, + type: T, + ordinal: number, + apply: ( + item: Extract, + ) => Extract, +) { + return updateAssistant(source, messageID, sessionID, (assistant) => { + let index = -1 + return { + ...assistant, + content: assistant.content.map((item) => { + if (item.type !== type || ++index !== ordinal) return item + return apply(item as Extract) + }), + } + }) +} + +function updateTool( + source: readonly SessionMessageInfo[], + messageID: string, + callID: string, + sessionID: string, + apply: ( + item: Extract, + ) => Extract, +) { + return updateAssistant(source, messageID, sessionID, (assistant) => ({ + ...assistant, + content: assistant.content.map((item) => (item.type === "tool" && item.id === callID ? apply(item) : item)), + })) +} + +function insertOrdinal( + source: Assistant["content"], + type: T, + ordinal: number, + item: Extract, +) { + const matches = source.filter((content) => content.type === type) + if (matches[ordinal]) return source + return [...source, item] +} diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index 38f7ff71e5..e9595e1376 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from "bun:test" import type { retry } from "@opencode-ai/core/util/retry" +import type { OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise" import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client" import { createServerSession } from "./server-session" +import type { ServerApi } from "@/utils/server" + +type MessageApi = ServerApi["message"] const session = (id: string, parentID?: string): Session => ({ id, @@ -158,6 +162,57 @@ function setup(sessions: Record) { } describe("server session", () => { + test("projects V2 session events into current and legacy message state", () => { + const ctx = setup({ child: session("child") }) + ctx.store.remember(session("child")) + ctx.store.set("session_message", "child", [ + { + id: "msg_1_user", + type: "user", + text: "hello", + time: { created: 1 }, + }, + ]) + const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent) + + apply({ + id: "evt_step", + created: 2, + type: "session.step.started", + durable: { aggregateID: "child", seq: 1, version: 1 }, + location: { directory: "/repo" }, + data: { + sessionID: "child", + assistantMessageID: "msg_2_assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + apply({ + id: "evt_text_start", + created: 3, + type: "session.text.started", + durable: { aggregateID: "child", seq: 2, version: 1 }, + location: { directory: "/repo" }, + data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0 }, + }) + apply({ + id: "evt_text_delta", + created: 4, + type: "session.text.delta", + location: { directory: "/repo" }, + data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0, delta: "world" }, + }) + + expect(ctx.store.data.session_message.child?.at(-1)).toMatchObject({ + id: "msg_2_assistant", + type: "assistant", + content: [{ type: "text", text: "world" }], + }) + expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"]) + expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }]) + }) + test("resolves lineage by session ID without directory", async () => { const ctx = setup({ child: session("child", "root"), root: session("root") }) @@ -178,6 +233,116 @@ describe("server session", () => { expect(ctx.store.data.message.root).toEqual([]) }) + test("loads current session content through the current message API", async () => { + const requests: unknown[] = [] + const user = { id: "msg_z_user", type: "user", text: "hello", time: { created: 1 } } + const assistant = { + id: "msg_a_assistant", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "hi" }], + time: { created: 2, completed: 3 }, + } + const client = { + session: { + messages: () => { + throw new Error("legacy message endpoint called") + }, + }, + } as unknown as OpencodeClient + const messageApi = { + list: async (input: unknown) => { + requests.push(input) + return { data: [assistant, user], cursor: { previous: null, next: null } } + }, + } as unknown as MessageApi + const store = createServerSession(client, {} as SessionApi, messageApi) + store.remember(session("root")) + + await store.sync("root") + + expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }]) + expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) + }) + + test("extends a current page to include the user for split assistant turns", async () => { + const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const + const assistant = (id: string, created: number) => ({ + id, + type: "assistant" as const, + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text" as const, text: id }], + time: { created, completed: created }, + }) + const assistants = [ + assistant("msg_2_assistant", 2), + assistant("msg_3_assistant", 3), + assistant("msg_4_assistant", 4), + ] + const pages = [ + { data: assistants.slice(1).toReversed(), cursor: { previous: null, next: "older" } }, + { data: [assistants[0], user], cursor: { previous: null, next: null } }, + ] + const requests: unknown[] = [] + const messageApi = { + list: async (input: unknown) => { + requests.push(input) + return pages.shift()! + }, + } as unknown as MessageApi + const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi) + store.remember(session("root")) + + await store.sync("root") + + expect(requests).toEqual([ + { sessionID: "root", limit: 20, order: "desc" }, + { sessionID: "root", limit: 20, cursor: "older" }, + ]) + expect(store.data.message.root.map((message) => message.id)).toEqual([ + user.id, + ...assistants.map((item) => item.id), + ]) + expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"]) + }) + + test("indexes V1 messages for the current timeline projection", async () => { + const user = userMessage("message-1", { sessionID: "root" }) + const assistant = assistantMessage("message-2", user.id, { sessionID: "root" }) + const client = messageClient( + response([ + { info: user, parts: [textPart(user.id, { sessionID: "root" })] }, + { info: assistant, parts: [textPart(assistant.id, { sessionID: "root" })] }, + ]), + ) + const messageApi = { + list: () => { + throw new Error("current message endpoint called") + }, + } as unknown as MessageApi + const store = createServerSession(client, {} as SessionApi, messageApi, { + protocol: Promise.resolve("v1"), + }) + store.remember(session("root")) + + await store.sync("root") + + expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) + expect(store.data.session_message.root).toMatchObject([ + { id: user.id, type: "user", text: "text" }, + { id: assistant.id, type: "assistant" }, + ]) + + const next = userMessage("message-3", { sessionID: "root" }) + store.apply({ type: "message.updated", properties: { info: next } }) + expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id, next.id]) + + store.apply({ type: "message.removed", properties: { sessionID: "root", messageID: next.id } }) + expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) + }) + test("backfills an assistant-only initial page through its user root", async () => { const user = userMessage("message-1") const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] @@ -200,6 +365,49 @@ describe("server session", () => { expect(store.history.more("child")).toBe(true) }) + test("keeps assistant history when its deleted parent cannot be backfilled", async () => { + const missing = Promise.withResolvers() + const assistant = assistantMessage("message-2", "message-missing") + const client = rootMessageClient([response([{ info: assistant, parts: [] }], "older")], [missing.promise]) + const store = createServerSession(client) + const loading = store.sync("child") + await client.rootRequested(1) + + missing.reject(new Error("Message not found: message-missing", { cause: { status: 404 } })) + await loading + + expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }]) + expect(store.data.message.child).toEqual([assistant]) + expect(store.history.more("child")).toBe(true) + }) + + test("drops a cached parent when a forced refresh confirms it was deleted", async () => { + const missing = Promise.withResolvers() + const parent = userMessage("message-1") + const part = textPart(parent.id) + const assistant = assistantMessage("message-2", parent.id) + const client = rootMessageClient( + [ + response([ + { info: parent, parts: [part] }, + { info: assistant, parts: [] }, + ]), + response([{ info: assistant, parts: [] }], "older"), + ], + [missing.promise], + ) + const store = createServerSession(client) + await store.sync("child") + const loading = store.sync("child", { force: true }) + await client.rootRequested(1) + + missing.reject(new Error(`Message not found: ${parent.id}`, { cause: { status: 404 } })) + await loading + + expect(store.data.message.child).toEqual([assistant]) + expect(store.data.part[parent.id]).toBeUndefined() + }) + test("does not let an optimistic user suppress initial root backfill", async () => { const user = userMessage("message-1") const part = textPart(user.id) diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index a440aab38f..69bec61ba2 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -1,5 +1,6 @@ import { Binary } from "@opencode-ai/core/util/binary" import { retry } from "@opencode-ai/core/util/retry" +import type { OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise" import type { Message, OpencodeClient, @@ -8,15 +9,21 @@ import type { QuestionRequest, Session, SessionStatus, - SnapshotFileDiff, Todo, } from "@opencode-ai/sdk/v2/client" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { batch } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" -import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs" +import { message as cleanMessage } from "@/utils/diffs" import { sessionNotFoundError } from "@/utils/server-errors" import { rootSession } from "@/utils/session-route" +import { normalizeSessionInfo } from "@/utils/session" +import { normalizeSessionMessages } from "@/utils/session-message" import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache" +import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer" +import type { ServerApi } from "@/utils/server" + +type MessageApi = ServerApi["message"] const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id) @@ -26,6 +33,17 @@ const historyMessagePageSize = 200 const sessionInfoLimit = 2_048 const emptyIDs: ReadonlySet = new Set() +function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) { + const boundary = source.find( + (message) => + message.type === "user" || + message.type === "shell" || + message.type === "assistant" || + (message.type === "synthetic" && message.description?.trim()), + ) + return boundary?.type === "assistant" +} + type OptimisticItem = { message: Message parts: Part[] @@ -36,10 +54,37 @@ type OptimisticItem = { type MessagePage = { session: Message[] part: { id: string; part: Part[] }[] + source?: SessionMessageInfo[] + sourceMode?: "latest" | "older" + projectSource?: boolean cursor?: string complete: boolean } +function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] { + return items + .slice() + .sort((a, b) => cmp(a.info.id, b.info.id)) + .map((item) => { + if (item.info.role === "user") { + return { + id: item.info.id, + type: "user" as const, + text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"), + time: item.info.time, + } + } + return { + id: item.info.id, + type: "assistant" as const, + agent: item.info.agent ?? item.info.mode, + model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant }, + content: [], + time: item.info.time, + } + }) +} + // Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries. type MessageLoadState = { touchedMessages: Set @@ -52,6 +97,7 @@ type MessageLoadState = { optimisticParts: Map> orphanParents: Set clearedMessageParts: Set + touchedSource: Set } type MessageLoadBaseline = Pick< @@ -110,6 +156,7 @@ function reconcileFetched( options: { touched?: ReadonlySet retained?: ReadonlySet + removed?: ReadonlySet preserveUnfetched?: boolean | ((item: T) => boolean) } = {}, ) { @@ -132,18 +179,29 @@ function reconcileFetched( if (item) result.set(id, item) if (!item) result.delete(id) } + for (const id of options.removed ?? emptyIDs) result.delete(id) return [...result.values()].sort((a, b) => cmp(a.id, b.id)) } -export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) { +type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> } + +export function createServerSession( + client: OpencodeClient, + sessionApiOrOptions?: SessionApi | ServerSessionOptions, + messageApi?: MessageApi, + currentOptions?: ServerSessionOptions, +) { + const sessionApi = messageApi ? (sessionApiOrOptions as SessionApi) : undefined + const options = messageApi ? currentOptions : (sessionApiOrOptions as ServerSessionOptions | undefined) const [data, setData] = createStore({ info: {} as Record, session_status: {} as Record, - session_diff: {} as Record, + session_diff: {} as Record, todo: {} as Record, permission: {} as Record, question: {} as Record, message: {} as Record, + session_message: {} as Record, part: {} as Record, part_text_accum_delta: {} as Record, session_working(id: string) { @@ -152,9 +210,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: }) const requests = new Map>() const inflight = new Map>() - const inflightDiff = new Map>() const inflightTodo = new Map>() const optimistic = new Map>() + const v2 = createV2SessionReducer() const messageLoads = new Map() const pendingParts = new Map>>() const orphanParts = new Map>() @@ -189,6 +247,16 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: at: {} as Record, }) + const indexLegacyMessage = (message: Message) => { + const current = data.session_message[message.sessionID] ?? [] + if (current.some((item) => item.id === message.id)) return + setData( + "session_message", + message.sessionID, + reconcile([...current, ...legacyMessageSource([{ info: message, parts: [] }])]), + ) + } + const remember = (session: Session) => { setData("info", session.id, reconcile(session)) infoSeen.delete(session.id) @@ -198,7 +266,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: ...pinned.keys(), ...requests.keys(), ...inflight.keys(), - ...inflightDiff.keys(), ...inflightTodo.keys(), ...messageLoads.keys(), ...optimistic.keys(), @@ -240,27 +307,31 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: const pending = requests.get(sessionID) if (pending) return pending const active = generation(sessionID) - const request = client.session.get({ sessionID }).then((result) => { - if (!result.data) throw sessionNotFoundError(sessionID) - if (generations.get(sessionID) !== active) return result.data - return remember(result.data) + const request = sessionApi + ? sessionApi.get({ sessionID }).then(normalizeSessionInfo) + : client.session.get({ sessionID }).then((result) => { + if (!result.data) throw sessionNotFoundError(sessionID) + return result.data + }) + const resolved = request.then((result) => { + if (generations.get(sessionID) !== active) return result + return remember(result) }) - requests.set(sessionID, request) + requests.set(sessionID, resolved) const cleanup = () => { - if (requests.get(sessionID) === request) requests.delete(sessionID) + if (requests.get(sessionID) === resolved) requests.delete(sessionID) if ( generations.get(sessionID) === active && !data.info[sessionID] && !requests.has(sessionID) && !messageLoads.has(sessionID) && !inflight.has(sessionID) && - !inflightDiff.has(sessionID) && !inflightTodo.has(sessionID) ) generations.delete(sessionID) } - void request.then(cleanup, cleanup) - return request + void resolved.then(cleanup, cleanup) + return resolved } const peekLineage = (sessionID: string) => { @@ -417,9 +488,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: clearOptimistic(sessionID) requests.delete(sessionID) inflight.delete(sessionID) - inflightDiff.delete(sessionID) inflightTodo.delete(sessionID) messageLoads.delete(sessionID) + v2.clear(sessionID) pendingParts.delete(sessionID) orphanParts.delete(sessionID) removedMessages.delete(sessionID) @@ -447,7 +518,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: ...pinned.keys(), ...requests.keys(), ...inflight.keys(), - ...inflightDiff.keys(), ...inflightTodo.keys(), ...messageLoads.keys(), ...optimistic.keys(), @@ -468,6 +538,34 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: ) const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => { + if (messageApi && (await options?.protocol) !== "v1") { + const request = (cursor?: string) => + (options?.retry ?? retry)(() => { + onAttempt?.() + return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" }) + }) + const first = await request(before) + const pages = [first] + while (pages.at(-1)?.cursor.next && needsOlderTurnRoot(pages.flatMap((page) => page.data).toReversed())) { + const response = await request(pages.at(-1)!.cursor.next ?? undefined) + pages.push(response) + if (!response.data.length) break + } + const response = pages.at(-1)! + const source = pages.flatMap((page) => page.data).toReversed() + const normalized = normalizeSessionMessages(sessionID, source) + return { + session: normalized.messages.sort((a, b) => cmp(a.id, b.id)), + part: [...normalized.parts.entries()] + .map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) })) + .sort((a, b) => cmp(a.id, b.id)), + source, + sourceMode: before ? ("older" as const) : ("latest" as const), + projectSource: true, + cursor: response.cursor.next ?? undefined, + complete: response.data.length === 0, + } + } const response = await (options?.retry ?? retry)(() => { onAttempt?.() return client.session.messages({ sessionID, limit, before }) @@ -479,12 +577,24 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: id: item.info.id, part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)), })), + source: legacyMessageSource(items), + sourceMode: before ? ("older" as const) : ("latest" as const), cursor: response.response.headers.get("x-next-cursor") ?? undefined, complete: !response.response.headers.get("x-next-cursor"), } } const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => { + if (sessionApi && (await options?.protocol) !== "v1") { + const response = await (options?.retry ?? retry)(() => { + onAttempt?.() + return sessionApi.message({ sessionID, messageID }) + }) + const normalized = normalizeSessionMessages(sessionID, [response]) + const message = normalized.messages[0] + if (!message) throw new Error(`Message not found: ${messageID}`) + return { message, parts: normalized.parts.get(messageID) ?? [] } + } const response = await (options?.retry ?? retry)(() => { onAttempt?.() return client.session.message({ sessionID, messageID }) @@ -569,7 +679,31 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: preserveUnfetched: boolean | ((message: Message) => boolean), cleanupOrphans: boolean, ) => { - const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])]) + const source = page.source + ? (() => { + const incoming = new Map(page.source.map((message) => [message.id, message])) + const existing = data.session_message[sessionID] ?? [] + const current = existing.filter((message) => !incoming.has(message.id)) + const live = new Map(existing.map((message) => [message.id, message])) + return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map( + (message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message), + ) + })() + : undefined + const projected = + page.projectSource && source + ? (() => { + const normalized = normalizeSessionMessages(sessionID, source) + return { + ...page, + session: normalized.messages.sort((a, b) => cmp(a.id, b.id)), + part: [...normalized.parts.entries()] + .map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) })) + .sort((a, b) => cmp(a.id, b.id)), + } + })() + : page + const merged = mergeOptimisticPage(projected, [...(optimistic.get(sessionID)?.values() ?? [])]) merged.observed.forEach((item) => { if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts) }) @@ -577,9 +711,11 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], { touched: touchedMessages, retained: load?.retainedMessages, + removed: load?.removedMessages, preserveUnfetched, }) batch(() => { + if (source) setData("session_message", sessionID, reconcile(source)) const messageIDs = replaceMessages(sessionID, messages) replaceParts(sessionID, merged.part, messageIDs, load) const orphans = orphanParts.get(sessionID) @@ -610,6 +746,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: optimisticParts: new Map(), orphanParents: new Set(), clearedMessageParts: new Set(), + touchedSource: new Set(), } messageLoads.set(sessionID, load) setMeta("loading", sessionID, true) @@ -645,7 +782,15 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: if (generations.get(sessionID) !== active) break const parent = await fetchMessage(sessionID, parentID, () => resetMessageLoad(sessionID, load, messageLoadBaseline(load, parentID)), - ) + ).catch((error) => { + const cause = error instanceof Error && typeof error.cause === "object" ? error.cause : undefined + if (cause && "status" in cause && cause.status === 404) { + load.removedMessages.add(parentID) + return + } + throw error + }) + if (!parent) continue if (parent.message.role !== "user") throw new Error(`Assistant parent is not a user message: ${parentID}`) parents.push(parent) } @@ -736,6 +881,109 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: return properties.part.sessionID } + const projectV2 = (reduction: V2SessionReduction) => { + reduction.touched.forEach((messageID) => messageLoads.get(reduction.sessionID)?.touchedSource.add(messageID)) + setData("session_message", reduction.sessionID, reconcile(reduction.messages)) + if (reduction.touched.length === 0) return + + const touched = new Set(reduction.touched) + let parentID: string | undefined + for (const message of reduction.messages) { + if (message.type === "user" || (message.type === "synthetic" && message.description?.trim())) + parentID = message.id + if (message.type === "shell") { + if (touched.has(message.id)) touched.add(`${message.id}:assistant`) + parentID = undefined + } + if (message.type === "assistant" && touched.has(message.id) && parentID) touched.add(parentID) + if (message.type === "compaction" && touched.has(message.id) && parentID) touched.add(parentID) + } + + const normalized = normalizeSessionMessages(reduction.sessionID, reduction.messages) + batch(() => { + for (const message of normalized.messages) { + if (!touched.has(message.id)) continue + apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } }) + } + for (const messageID of touched) { + const next = normalized.parts.get(messageID) ?? [] + const nextIDs = new Set(next.map((part) => part.id)) + for (const part of next) { + apply({ type: "message.part.updated", properties: { sessionID: reduction.sessionID, part } }) + } + for (const part of data.part[messageID] ?? []) { + if (nextIDs.has(part.id)) continue + apply({ + type: "message.part.removed", + properties: { sessionID: reduction.sessionID, messageID, partID: part.id }, + }) + } + } + }) + } + + const hydrateV2Message = (sessionID: string, messageID: string) => { + if (!sessionApi) return + void sessionApi + .message({ sessionID, messageID }) + .then((message) => { + const current = data.session_message[sessionID] ?? [] + const messages = [...current.filter((item) => item.id !== message.id), message].sort((a, b) => cmp(a.id, b.id)) + projectV2({ sessionID, messages, touched: [message.id] }) + }) + .catch(() => {}) + } + + const applyV2 = (event: OpenCodeEvent) => { + if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return + const sessionID = event.data.sessionID + const reduction = v2.reduce(data.session_message[sessionID] ?? [], event) + if (reduction) { + projectV2(reduction) + if (reduction.missing) hydrateV2Message(sessionID, reduction.missing) + } + + const info = data.info[sessionID] + if (event.type === "session.renamed" && info) + remember({ ...info, title: event.data.title, time: { ...info.time, updated: event.created } }) + if (event.type === "session.moved" && info) + remember({ + ...info, + projectID: event.data.projectID ?? info.projectID, + workspaceID: event.data.location.workspaceID, + directory: event.data.location.directory, + path: event.data.subpath, + time: { ...info.time, updated: event.created }, + }) + if (event.type === "session.usage.updated" && info) + remember({ ...info, cost: event.data.cost, tokens: event.data.tokens }) + // if (event.type === "session.archived") { + // if (info) remember({ ...info, time: { ...info.time, archived: event.created, updated: event.created } }) + // evict([sessionID]) + // } + if (event.type === "session.execution.started") setData("session_status", sessionID, { type: "busy" }) + if ( + event.type === "session.execution.succeeded" || + event.type === "session.execution.failed" || + event.type === "session.execution.interrupted" + ) + setData("session_status", sessionID, { type: "idle" }) + if (event.type === "session.retry.scheduled") + setData("session_status", sessionID, { + type: "retry", + attempt: event.data.attempt, + message: event.data.error.message, + next: event.data.at, + }) + if (event.type === "session.forked") void resolve(sessionID, { force: true }).catch(() => {}) + if ( + event.type === "session.revert.staged" || + event.type === "session.revert.cleared" || + event.type === "session.revert.committed" + ) + void resolve(sessionID, { force: true }).catch(() => {}) + } + const apply = (event: { type: string; properties?: unknown }) => { const eventID = eventSessionID(event) if (eventID) { @@ -759,7 +1007,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: return } case "session.deleted": { - const sessionID = (event.properties as { info: Session }).info.id + const properties = event.properties as { sessionID?: string; info?: Session } + const sessionID = properties.info?.id ?? properties.sessionID + if (!sessionID) return infoSeen.delete(sessionID) setData( "info", @@ -768,11 +1018,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: evict([sessionID]) return } - case "session.diff": { - const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] } - setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" })) - return - } case "todo.updated": { const props = event.properties as { sessionID: string; todos: Todo[] } setData("todo", props.sessionID, reconcile(props.todos, { key: "id" })) @@ -785,6 +1030,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: } case "message.updated": { const info = cleanMessage((event.properties as { info: Message }).info) + indexLegacyMessage(info) const load = messageLoads.get(info.sessionID) load?.touchedMessages.add(info.id) load?.removedMessages.delete(info.id) @@ -817,6 +1063,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: } case "message.removed": { const props = event.properties as { sessionID: string; messageID: string } + setData("session_message", props.sessionID, (messages) => + messages?.filter((message) => message.id !== props.messageID), + ) const load = messageLoads.get(props.sessionID) load?.touchedMessages.add(props.messageID) load?.removedMessages.add(props.messageID) @@ -1129,23 +1378,16 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: setData(produce((draft) => deleteMessageParts(draft, input.messageID))) }, }, - diff(sessionID: string, options?: { force?: boolean }) { + async todo(sessionID: string, request?: { force?: boolean }) { touch(sessionID) - if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve() - return runInflight(inflightDiff, sessionID, () => { - const active = generation(sessionID) - return retry(() => client.session.diff({ sessionID })).then((result) => { - if (generations.get(sessionID) !== active) return - setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" })) - }) - }) - }, - todo(sessionID: string, options?: { force?: boolean }) { - touch(sessionID) - if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve() + if (data.todo[sessionID] !== undefined && !request?.force) return + if ((await options?.protocol) === "v2") { + setData("todo", sessionID, []) + return + } return runInflight(inflightTodo, sessionID, () => { const active = generation(sessionID) - return retry(() => client.session.todo({ sessionID })).then((result) => { + return (options?.retry ?? retry)(() => client.session.todo({ sessionID })).then((result) => { if (generations.get(sessionID) !== active) return setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" })) }) @@ -1179,6 +1421,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: if (count && count > 1) pinned.set(sessionID, count - 1) }, apply, + applyV2, } } diff --git a/packages/app/src/context/server-sync.test.ts b/packages/app/src/context/server-sync.test.ts index 93e9c41755..7821a09e20 100644 --- a/packages/app/src/context/server-sync.test.ts +++ b/packages/app/src/context/server-sync.test.ts @@ -1,6 +1,106 @@ import { describe, expect, test } from "bun:test" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" +import type { + McpListInput, + McpResourceCatalogInput, + SessionApi, + SessionInfo, + SessionListInput, +} from "@opencode-ai/client/promise" +import { QueryClient } from "@tanstack/solid-query" import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction" -import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" +import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load" +import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync" +import { ServerScope } from "@/utils/server-scope" +import { createServerSession } from "./server-session" +import type { ServerApi } from "@/utils/server" + +type McpApi = ServerApi["mcp"] + +describe("MCP queries", () => { + test("loads current servers for the requested location", async () => { + const calls: unknown[] = [] + const queryClient = new QueryClient() + const result = await queryClient.fetchQuery( + loadMcpQuery(ServerScope.local, "/project", { + list: async (input: McpListInput = {}) => { + calls.push(input) + return { + location: { directory: "/project", project: { id: "project", directory: "/project" } }, + data: [ + { name: "docs", status: { status: "connected" } }, + { name: "search", status: { status: "pending" } }, + ], + } + }, + } as unknown as McpApi), + ) + + expect(calls).toEqual([{ location: { directory: "/project" } }]) + expect(result).toEqual({ docs: { status: "connected" }, search: { status: "pending" } }) + }) + + test("loads and keys the current resource catalog", async () => { + const calls: unknown[] = [] + const queryClient = new QueryClient() + const result = await queryClient.fetchQuery( + loadMcpResourcesQuery(ServerScope.local, "/project", { + resource: { + catalog: async (input: McpResourceCatalogInput = {}) => { + calls.push(input) + return { + location: { directory: "/project", project: { id: "project", directory: "/project" } }, + data: { + resources: [{ server: "docs", name: "Guide", uri: "docs://guide" }], + templates: [], + }, + } + }, + }, + } as unknown as McpApi), + ) + + expect(calls).toEqual([{ location: { directory: "/project" } }]) + expect(result).toEqual({ "docs:docs://guide": { server: "docs", name: "Guide", uri: "docs://guide" } }) + }) +}) + +describe("active session query", () => { + test("loads active sessions immediately and once per server cache", async () => { + let calls = 0 + const queryClient = new QueryClient() + const options = loadActiveSessionsQuery(ServerScope.local, { + active: async () => { + calls++ + return { ses_running: { type: "running" } } + }, + }) + + expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } }) + expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } }) + expect(calls).toBe(1) + expect(options.enabled).toBe(true) + expect([...options.queryKey]).toEqual([ServerScope.local, "activeSessions"]) + }) + + test("does not overwrite statuses already written by events", () => { + const session = createServerSession({} as OpencodeClient) + session.set("session_status", "ses_retry", { type: "retry", attempt: 2, message: "retrying", next: 10 }) + + seedActiveSessionStatuses(session, { + ses_running: { type: "running" }, + ses_retry: { type: "running" }, + }) + + expect(session.data.session_status.ses_running).toEqual({ type: "busy" }) + expect(session.data.session_status.ses_retry).toEqual({ + type: "retry", + attempt: 2, + message: "retrying", + next: 10, + }) + }) +}) describe("pickDirectoriesToEvict", () => { test("keeps pinned stores and evicts idle stores", () => { @@ -23,46 +123,57 @@ describe("pickDirectoriesToEvict", () => { }) }) -describe("loadRootSessionsWithFallback", () => { - test("uses limited roots query when supported", async () => { - const calls: Array<{ directory: string; roots: true; limit?: number }> = [] +describe("loadRootSessions", () => { + test("loads and normalizes a limited page of root sessions", async () => { + const calls: SessionListInput[] = [] - const result = await loadRootSessionsWithFallback({ + const result = await loadRootSessions({ + api: { + list: async (query = {}) => { + calls.push(query) + return { data: [sessionInfo("session-1")], cursor: {} } + }, + } satisfies Pick, directory: "dir", limit: 10, - list: async (query) => { - calls.push(query) - return { data: [] } - }, }) - expect(result.data).toEqual([]) + expect(result.data).toEqual([ + expect.objectContaining({ id: "session-1", directory: "dir", slug: "session-1", version: "" }), + ]) expect(result.limited).toBe(true) - expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }]) + expect(calls).toEqual([{ directory: "dir", parentID: null, limit: 10, order: "desc" }]) }) - test("falls back to full roots query on limited-query failure", async () => { - const calls: Array<{ directory: string; roots: true; limit?: number }> = [] - - const result = await loadRootSessionsWithFallback({ - directory: "dir", - limit: 25, - list: async (query) => { - calls.push(query) - if (query.limit) throw new Error("unsupported") - return { data: [] } - }, - }) - - expect(result.data).toEqual([]) - expect(result.limited).toBe(false) - expect(calls).toEqual([ - { directory: "dir", roots: true, limit: 25 }, - { directory: "dir", roots: true }, - ]) + test("propagates list failures", () => { + expect( + loadRootSessions({ + api: { + list: async () => { + throw new Error("failed") + }, + } satisfies Pick, + directory: "dir", + limit: 25, + }), + ).rejects.toThrow("failed") }) }) +function sessionInfo(id: string) { + 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 }, + title: id, + location: { directory: "dir" }, + } as SessionInfo +} + describe("estimateRootSessionTotal", () => { test("keeps exact total for full fetches", () => { expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42) diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 05806fba54..13a0b74bc6 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -1,10 +1,10 @@ import type { Config, - McpResource, OpencodeClient, Path, Project, ProviderAuthResponse, + SessionStatus, } from "@opencode-ai/sdk/v2/client" import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" @@ -18,6 +18,7 @@ import { bootstrapGlobal, clearProviderRev, loadAgentsQuery, + loadCommands, loadGlobalConfigQuery, loadPathQuery, loadProjectsQuery, @@ -26,12 +27,13 @@ import { } from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" -import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" +import { estimateRootSessionTotal, loadRootSessions, loadRootSessionsV1 } from "./global-sync/session-load" import { trimSessions } from "./global-sync/session-trim" import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { formatServerError } from "@/utils/server-errors" import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" +import type { SolidQueryOptions } from "@tanstack/solid-query" import { createRefreshQueue } from "./global-sync/queue" import { directoryKey } from "./global-sync/utils" import { PathKey } from "@/utils/path-key" @@ -45,8 +47,18 @@ import { retry } from "@opencode-ai/core/util/retry" import type { ServerScope } from "@/utils/server-scope" import { createHomeSessionIndexCache } from "./global-sync/home-session-index" import { persisted } from "@/utils/persist" +import type { ServerApi } from "@/utils/server" +import type { + McpListInput, + McpListOutput, + McpResource, + McpResourceCatalogInput, + McpResourceCatalogOutput, + McpServer, + SessionActiveOutput, +} from "@opencode-ai/client/promise" import { toggleMcp } from "./global-sync/mcp" -import { createServerSession } from "./server-session" +import { createServerSession, type ServerSession } from "./server-session" type GlobalStore = { ready: boolean @@ -59,16 +71,76 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } -export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => - queryOptions({ +type McpListApi = { + readonly list: (input?: McpListInput) => Promise +} + +type McpResourceApi = { + readonly resource: { + readonly catalog: (input?: McpResourceCatalogInput) => Promise + } +} + +type ApiQueryOptions = SolidQueryOptions & { + initialData?: undefined + queryKey: K +} + +type SessionActiveApi = { + readonly active: () => Promise +} + +export const loadMcpQuery = ( + scope: ServerScope, + directory: string, + api: McpListApi, + legacy?: OpencodeClient, + protocol?: Promise<"v1" | "v2">, +): ApiQueryOptions, readonly [ServerScope, string, "mcp"]> => + queryOptions< + Record, + Error, + Record, + readonly [ServerScope, string, "mcp"] + >({ queryKey: [scope, directory, "mcp"] as const, - queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}), + queryFn: async () => { + if ((await protocol) === "v1" && legacy) return (await legacy.mcp.status()).data ?? {} + return api + .list({ location: { directory } }) + .then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status]))) + }, }) -export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => - queryOptions>({ +export const loadMcpResourcesQuery = ( + scope: ServerScope, + directory: string, + api: McpResourceApi, + legacy?: OpencodeClient, + protocol?: Promise<"v1" | "v2">, +): ApiQueryOptions, readonly [ServerScope, string, "mcpResources"]> => + queryOptions< + Record, + Error, + Record, + readonly [ServerScope, string, "mcpResources"] + >({ queryKey: [scope, directory, "mcpResources"] as const, - queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}), + queryFn: async () => { + if ((await protocol) === "v1" && legacy) { + return Object.fromEntries( + Object.entries((await legacy.experimental.resource.list()).data ?? {}).map(([key, resource]) => [ + key, + { ...resource, server: resource.client }, + ]), + ) + } + return api.resource + .catalog({ location: { directory } }) + .then((result) => + Object.fromEntries(result.data.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])), + ) + }, placeholderData: {}, }) @@ -78,22 +150,52 @@ export const loadLspQuery = (scope: ServerScope, directory: string, sdk: Opencod queryFn: () => sdk.lsp.status().then((r) => r.data ?? []), }) +export const loadActiveSessionsQuery = ( + scope: ServerScope, + api: SessionActiveApi, +): ApiQueryOptions => + queryOptions({ + queryKey: [scope, "activeSessions"] as const, + queryFn: () => api.active(), + enabled: true, + staleTime: Number.POSITIVE_INFINITY, + gcTime: Number.POSITIVE_INFINITY, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }) + +export function seedActiveSessionStatuses( + session: Pick, + active: SessionActiveOutput | Record, +) { + for (const sessionID of Object.keys(active)) { + if (session.data.session_status[sessionID] !== undefined) continue + const status = active[sessionID] + session.set("session_status", sessionID, status?.type === "running" ? { type: "busy" } : status) + } +} + function makeQueryOptionsApi( scope: ServerScope, serverSDK: () => OpencodeClient, + serverAPI: ServerApi, sdkFor: (dir: PathKey) => OpencodeClient, + protocol: Promise<"v1" | "v2">, ) { return { - globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()), - projects: () => loadProjectsQuery(scope, serverSDK()), + globalConfig: () => loadGlobalConfigQuery(scope, serverSDK(), protocol), + projects: () => loadProjectsQuery(scope, serverAPI.project), providers: (directory: PathKey | null) => - loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)), + loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol), path: (directory: PathKey | null) => - loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)), - agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)), - references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)), - mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)), - mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)), + loadPathQuery(scope, directory, directory ? sdkFor(directory) : serverSDK(), protocol), + agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent, sdkFor(directory), protocol), + references: (directory: PathKey) => + loadReferencesQuery(scope, directory, serverAPI.reference, sdkFor(directory), protocol), + mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol), + mcpResources: (directory: PathKey) => + loadMcpResourcesQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol), lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)), sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }), } @@ -122,11 +224,44 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { return sdk } - const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor) + const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, { + protocol: serverSDK.protocol, + }) + const queryOptionsApi = makeQueryOptionsApi( + serverSDK.scope, + () => serverSDK.client, + serverSDK.api, + sdkFor, + serverSDK.protocol, + ) const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)], })) + const activeSessionsQuery = useQuery(() => + loadActiveSessionsQuery(serverSDK.scope, { + active: async () => { + if ((await serverSDK.protocol) === "v1") { + const statuses = (await serverSDK.client.session.status()).data ?? {} + seedActiveSessionStatuses(session, statuses) + for (const sessionID of Object.keys(statuses)) { + void session.resolve(sessionID).catch(() => undefined) + } + return Object.fromEntries( + Object.entries(statuses).flatMap(([sessionID, status]) => + status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]], + ), + ) + } + const active = await serverSDK.api.session.active() + seedActiveSessionStatuses(session, active) + for (const sessionID of Object.keys(active)) { + void session.resolve(sessionID).catch(() => undefined) + } + return active + }, + }), + ) const [globalStore, setGlobalStore] = createStore({ get ready() { @@ -155,6 +290,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const queryClient = useQueryClient() const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server)) + const refreshProviders = () => + queryClient.refetchQueries({ + predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers", + }) let bootedAt = 0 let bootingRoot = false @@ -183,6 +322,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { queryFn: async () => { await bootstrapGlobal({ serverSDK: serverSDK.client, + serverAPI: serverSDK.api, + protocol: serverSDK.protocol, scope: serverSDK.scope, requestFailedTitle: language.t("common.requestFailed"), translate: language.t, @@ -212,8 +353,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { bootstrapInstance, }) - const session = createServerSession(serverSDK.client) - const children = createChildStoreManager({ owner, scope: serverSDK.scope, @@ -224,17 +363,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { void bootstrapInstance(directory) }, onMcp: (directory, setStore) => { - void retry(() => - sdkFor(directory) - .command.list() - .then((x) => setStore("command", x.data ?? [])), - ).catch((err) => { - showToast({ - variant: "error", - title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }), - description: formatServerError(err, language.t), + void loadCommands(directory, serverSDK.api.command, sdkFor(directory), serverSDK.protocol) + .then((commands) => setStore("command", commands)) + .catch((err) => { + showToast({ + variant: "error", + title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }), + description: formatServerError(err, language.t), + }) }) - }) }, onDispose: (directory) => { const key = directoryKey(directory) @@ -279,11 +416,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { .fetchQuery({ ...queryOptionsApi.sessions(key), queryFn: () => - loadRootSessionsWithFallback({ - directory, - limit, - list: (query) => serverSDK.client.session.list(query), - }) + serverSDK.protocol + .then((protocol) => + protocol === "v1" + ? loadRootSessionsV1({ client: sdkFor(directory), directory, limit }) + : loadRootSessions({ api: serverSDK.api.session, directory, limit }), + ) .then((x) => { const nonArchived = (x.data ?? []) .filter((s) => !!s?.id) @@ -353,6 +491,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { provider: globalStore.provider, }, sdk, + api: serverSDK.api, store: child[0], setStore: child[1], vcsCache: cache, @@ -360,6 +499,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { translate: language.t, queryClient, session, + protocol: serverSDK.protocol, }) }) @@ -371,19 +511,41 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { return promise } + const indexSession = (info: Parameters[0]) => { + const key = directoryKey(info.directory) + const existing = children.children[key] + if (!existing) return + applyDirectoryEvent({ + event: { type: "session.created", properties: { info } }, + directory: key, + store: existing[0], + setStore: existing[1], + push: queue.push, + retainedLimit: sessionMeta.get(key)?.limit, + sessionContent: false, + permission: session.data.permission, + loadLsp() {}, + }) + } + const unsub = serverSDK.event.listen((e) => { const directory = e.name const key = directoryKey(directory) const event = e.details + const eventType: string = event.type const recent = bootingRoot || Date.now() - bootedAt < 1500 + if (event.current) session.applyV2(event.current) session.apply(event) if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") { homeSessions.apply(event) } homeSessions.refresh(event.type) + if (eventType === "integration.connection.updated") void refreshProviders() if (directory === "global") { + if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching) + void activeSessionsQuery.refetch() applyGlobalEvent({ event, project: globalStore.project, @@ -393,7 +555,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { }, setGlobalProject: setProjects, }) - if (event.type === "server.connected" || event.type === "global.disposed") { + if ( + eventType === "config.updated" || + eventType === "catalog.updated" || + eventType === "agent.updated" || + eventType === "project.directories.updated" + ) + bootstrap.refetch() + if (eventType === "server.connected" || eventType === "global.disposed") { if (recent) return for (const directory of Object.keys(children.children)) { if (!children.active(directory)) continue @@ -403,9 +572,30 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { return } + if (event.current?.type === "session.moved") { + const info = session.get(event.current.data.sessionID) + if (info) indexSession(info) + } + if (event.current?.type === "session.forked") + void session + .resolve(event.current.data.sessionID, { force: true }) + .then(indexSession) + .catch(() => {}) + const existing = children.children[key] if (!existing) return children.mark(key) + if ( + event.current?.type === "session.moved" || + // event.current?.type === "session.archived" || + event.current?.type === "session.forked" || + eventType === "command.updated" || + eventType === "config.updated" || + eventType === "agent.updated" + ) + queue.push(key) + if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key)) + if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key)) const [store, setStore] = existing applyDirectoryEvent({ event, @@ -493,6 +683,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { peek: children.peek, disableMcp: children.disableMcp, queryOptions: queryOptionsApi, + refreshProviders, // bootstrap, updateConfig: updateConfigMutation.mutateAsync, project: projectApi, @@ -502,14 +693,23 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { toggle: async (directory: string, name: string) => { const key = directoryKey(directory) const sdk = sdkFor(key) - const status = children.child(key, { bootstrap: false })[0].mcp[name].status + const status = children.child(key, { bootstrap: false })[0].mcp[name]?.status + if (!status) return await toggleMcp({ status, connect: async () => { - await sdk.mcp.connect({ name }) + if ((await serverSDK.protocol) === "v1") { + await sdk.mcp.connect({ name }) + return + } + await serverSDK.api.mcp.connect({ server: name, location: { directory: key } }) }, disconnect: async () => { - await sdk.mcp.disconnect({ name }) + if ((await serverSDK.protocol) === "v1") { + await sdk.mcp.disconnect({ name }) + return + } + await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } }) }, authenticate: async () => { await sdk.mcp.auth.authenticate({ name }) diff --git a/packages/app/src/context/settings.test.ts b/packages/app/src/context/settings.test.ts index ba0161a6cd..51b35eacbb 100644 --- a/packages/app/src/context/settings.test.ts +++ b/packages/app/src/context/settings.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test" import { + hasExistingWebState, + initialAgentVisibility, isAppUpgrade, layoutTransitionState, maximumSunsetTimeout, @@ -10,6 +12,22 @@ import { shouldEnableNewLayout, } from "./settings" +describe("agent visibility", () => { + test("shows the picker for existing profiles and hides it for first-time installs", () => { + expect(initialAgentVisibility(undefined, true)).toBe(true) + expect(initialAgentVisibility(undefined, false)).toBe(false) + }) + + test("shows the picker when updating from a recent release", () => { + expect(initialAgentVisibility(undefined, false, "1.18.8")).toBe(true) + }) + + test("preserves the preference after initialization", () => { + expect(initialAgentVisibility(true, true, "1.18.8")).toBeUndefined() + expect(initialAgentVisibility(true, false)).toBeUndefined() + }) +}) + describe("layout transition", () => { test("blank profiles default to the new layout", () => { expect(newLayoutDesignsDefault).toBe(true) @@ -23,6 +41,12 @@ describe("layout transition", () => { expect(layoutTransitionState(true, true, false, false)).toEqual({ available: true, notice: false }) }) + test("classifies web profiles from existing settings or a recorded version", () => { + expect(hasExistingWebState("{}", undefined)).toBe(true) + expect(hasExistingWebState(null, "1.17.19")).toBe(true) + expect(hasExistingWebState(null, undefined)).toBe(false) + }) + test("preserves explicit and default layout preferences", () => { expect(resolveNewLayoutDesigns(false, false, true)).toBe(false) expect(resolveNewLayoutDesigns(false, undefined, false)).toBe(false) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index c2b5680418..1a118b6544 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -1,5 +1,5 @@ import { createStore, reconcile } from "solid-js/store" -import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { persisted } from "@/utils/persist" import { usePlatform } from "@/context/platform" @@ -36,6 +36,7 @@ export interface Settings { mobileTitlebarPosition: "top" | "bottom" newLayoutDesigns?: boolean layoutTransitionEligible?: boolean + agentVisibilityInitialized?: boolean newInterfaceNoticeDismissed?: boolean shouldDisplayTabsToast?: boolean } @@ -89,6 +90,15 @@ export function shouldDisplayTabsToast( return isAppUpgrade(previous, current) || (!previous && existingInstall) } +export function hasExistingWebState(settings: Promise | string | null, previousVersion: string | undefined) { + return settings !== null || previousVersion !== undefined +} + +export function initialAgentVisibility(initialized: boolean | undefined, existing: boolean, previousVersion?: string) { + if (initialized === true) return + return existing || previousVersion !== undefined +} + export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) { if (!current) return false const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff) @@ -220,7 +230,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont gate: false, init: () => { const platform = usePlatform() - const [store, setStore, _, ready] = persisted("settings.v3", createStore(defaultSettings)) + const [store, setStore, settingsInit, ready] = persisted("settings.v3", createStore(defaultSettings)) const [launch, setLaunch, , launchReady] = persisted( "app-version.v1", createStore<{ version?: string }>({ version: undefined }), @@ -267,6 +277,14 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont ) }) const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference()) + const initializeAgentVisibility = (existing: boolean) => { + const initial = initialAgentVisibility(store.general?.agentVisibilityInitialized, existing, launchState.previous) + if (initial === undefined) return + batch(() => { + setStore("general", "showCustomAgents", initial) + setStore("general", "agentVisibilityInitialized", true) + }) + } if (sunset && !oldInterfaceRetired()) { const timeout = { current: undefined as ReturnType | undefined } @@ -293,6 +311,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setLaunch("version", platform.version) }) + createEffect(() => { + if (!ready() || !launchState.classified || platform.platform !== "web") return + const existing = hasExistingWebState(settingsInit, launchState.previous) + if (!layoutTransitionClassified()) setStore("general", "layoutTransitionEligible", existing) + initializeAgentVisibility(existing) + }) + createEffect(() => { if (!ready() || !launchState.classified || launchState.migrationApplied) return if (layoutUpgrade() && store.general?.newLayoutDesigns !== true) { @@ -416,6 +441,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont if (typeof current === "boolean") return setStore("general", "layoutTransitionEligible", eligible) }, + initializeAgentVisibility, layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available), newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice), dismissNewInterfaceNotice() { diff --git a/packages/app/src/context/tab-migration.ts b/packages/app/src/context/tab-migration.ts new file mode 100644 index 0000000000..04c1cb6804 --- /dev/null +++ b/packages/app/src/context/tab-migration.ts @@ -0,0 +1,23 @@ +import type { ServerConnection } from "./server" +import type { Tab } from "./tabs" + +export function migrateTabs(value: unknown, fallback: ServerConnection.Key): Tab[] { + if (!Array.isArray(value)) return [] + return value.flatMap((tab) => { + if (!tab || typeof tab !== "object") return [] + if ("server" in tab && typeof tab.server !== "string") return [] + const server = ("server" in tab ? tab.server : fallback) as ServerConnection.Key + if (tab.type === "session" && typeof tab.sessionId === "string") { + return [{ type: tab.type, server, sessionId: tab.sessionId }] + } + if ( + tab.type === "draft" && + typeof tab.draftID === "string" && + typeof tab.directory === "string" && + (tab.worktree === undefined || typeof tab.worktree === "string") + ) { + return [{ type: tab.type, server, draftID: tab.draftID, directory: tab.directory, worktree: tab.worktree }] + } + return [] + }) +} diff --git a/packages/app/src/context/tabs.test.ts b/packages/app/src/context/tabs.test.ts index b56ea2dfcf..abd62a4dae 100644 --- a/packages/app/src/context/tabs.test.ts +++ b/packages/app/src/context/tabs.test.ts @@ -3,6 +3,7 @@ import { createRoot, getOwner, onCleanup } from "solid-js" import { createTabMemory } from "./tab-memory" import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs" import type { SessionTab, Tab } from "./tabs" +import { migrateTabs } from "./tab-migration" import type { ServerConnection } from "./server" const server = "local\nhttp://localhost:4096" as ServerConnection.Key @@ -11,6 +12,23 @@ function sessionTab(sessionId: string): SessionTab { return { type: "session", server, sessionId } } +describe("tab migration", () => { + test("drops null and malformed persisted tabs", () => { + expect( + migrateTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"], server), + ).toEqual([sessionTab("a")]) + }) + + test("adds the fallback server to valid legacy tabs", () => { + expect(migrateTabs([{ type: "session", sessionId: "a", dirBase64: "legacy" }], server)).toEqual([sessionTab("a")]) + }) + + test("replaces invalid top-level persisted data", () => { + expect(migrateTabs(null, server)).toEqual([]) + expect(migrateTabs({}, server)).toEqual([]) + }) +}) + describe("tab memory", () => { test("keeps state until its tab is removed", () => { createRoot((dispose) => { diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 75191d54cf..b6608e8681 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -12,6 +12,7 @@ import { sessionHref } from "@/utils/session-route" import { createTabMemory } from "./tab-memory" import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs" import { createDraftPromptSession, type PromptModel } from "./prompt-state" +import { migrateTabs } from "./tab-migration" export type SessionTab = { type: "session" @@ -59,13 +60,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const [store, setStore, _, ready] = persisted( { ...Persist.window("tabs"), - migrate: (value: unknown) => { - if (!Array.isArray(value)) return value - return value.map((tab) => { - if (!tab || typeof tab !== "object" || "server" in tab) return tab - return { ...tab, server: fallback } - }) - }, + migrate: (value: unknown) => migrateTabs(value, fallback), }, createStore([]), ) @@ -106,7 +101,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ } const removeDraftPersisted = (draftID: string) => { - for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform) + for (const key of draftPersistedKeys()) { + const target = Persist.draft(draftID, key) + removePersisted(key === "prompt" ? Persist.prompt(target) : target, platform) + } } const removeInfo = (key: string) => { diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index d2d616248d..df575b71f7 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -149,6 +149,7 @@ function createWorkspaceTerminalSession( scope: ServerScopeValue, legacySessionID?: string, ) { + const location = { directory: sdk.directory } const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : [] const [store, setStore, _, ready] = persisted( @@ -240,47 +241,63 @@ function createWorkspaceTerminalSession( }) onCleanup(unsub) - const update = (client: DirectorySDK["client"], pty: Partial & { id: string }) => { + const update = (pty: Partial & { id: string }) => { const index = store.all.findIndex((x) => x.id === pty.id) const previous = index >= 0 ? store.all[index] : undefined if (index >= 0) { setStore("all", index, (item) => ({ ...item, ...pty })) } - client.pty - .update({ - ptyID: pty.id, - title: pty.title, - size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, - }) - .catch((error: unknown) => { - if (previous) { - const currentIndex = store.all.findIndex((item) => item.id === pty.id) - if (currentIndex >= 0) setStore("all", currentIndex, previous) - } - console.error("Failed to update terminal", error) - }) + const doUpdate = async () => { + if ((await sdk.protocol) === "v1") { + await sdk.client.pty.update({ + ptyID: pty.id, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + }) + } else { + await sdk.api.pty.update({ + ptyID: pty.id, + location, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + }) + } + } + doUpdate().catch((error: unknown) => { + if (previous) { + const currentIndex = store.all.findIndex((item) => item.id === pty.id) + if (currentIndex >= 0) setStore("all", currentIndex, previous) + } + console.error("Failed to update terminal", error) + }) } - const clone = async (client: DirectorySDK["client"], id: string) => { + const clone = async (id: string) => { const index = store.all.findIndex((x) => x.id === id) const pty = store.all[index] if (!pty) return - const next = await client.pty - .create({ - title: pty.title, - }) - .catch((error: unknown) => { - console.error("Failed to clone terminal", error) - return undefined - }) - if (!next?.data) return + const data = await (async () => { + if ((await sdk.protocol) === "v1") { + return (await sdk.client.pty.create({ title: pty.title })).data + } + return ( + await sdk.api.pty.create({ + location, + title: pty.title, + }) + ).data + })().catch((error: unknown) => { + console.error("Failed to clone terminal", error) + return undefined + }) + if (!data?.id) return const active = store.active === pty.id batch(() => { setStore("all", index, { - id: next.data.id, - title: next.data.title ?? pty.title, + id: data.id, + title: data.title ?? pty.title, titleNumber: pty.titleNumber, buffer: undefined, cursor: undefined, @@ -289,7 +306,7 @@ function createWorkspaceTerminalSession( cols: undefined, }) if (active) { - setStore("active", next.data.id) + setStore("active", data.id) } }) } @@ -308,17 +325,22 @@ function createWorkspaceTerminalSession( const nextNumber = pickNextTerminalNumber() const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined - sdk.client.pty - .create({ title: defaultTitle(nextNumber) }) - .then((pty: { data?: { id?: string; title?: string } }) => { - const id = pty.data?.id + const doCreate = async () => { + if ((await sdk.protocol) === "v1") { + return (await sdk.client.pty.create({ title: defaultTitle(nextNumber) })).data + } + return (await sdk.api.pty.create({ location, title: defaultTitle(nextNumber) })).data + } + doCreate() + .then((data) => { + const id = data?.id if (!id) { if (focusRequest !== undefined) cancelFocus(focusRequest) return } const newTerminal = { id, - title: pty.data?.title ?? defaultTitle(nextNumber), + title: data?.title ?? defaultTitle(nextNumber), titleNumber: nextNumber, } batch(() => { @@ -335,7 +357,7 @@ function createWorkspaceTerminalSession( }) }, update(pty: Partial & { id: string }) { - update(sdk.client, pty) + update(pty) }, trim(id: string) { const index = store.all.findIndex((x) => x.id === id) @@ -350,10 +372,9 @@ function createWorkspaceTerminalSession( }) }, async clone(id: string) { - await clone(sdk.client, id) + await clone(id) }, bind() { - const client = sdk.client return { trim(id: string) { const index = store.all.findIndex((x) => x.id === id) @@ -361,10 +382,10 @@ function createWorkspaceTerminalSession( setStore("all", index, (pty) => trimTerminal(pty)) }, update(pty: Partial & { id: string }) { - update(client, pty) + update(pty) }, async clone(id: string) { - await clone(client, id) + await clone(id) }, } }, @@ -412,7 +433,11 @@ function createWorkspaceTerminalSession( }) } - await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => { + const removePromise = + (await sdk.protocol) === "v1" + ? sdk.client.pty.remove({ ptyID: id }) + : sdk.api.pty.remove({ ptyID: id, location }) + await removePromise.catch((error: unknown) => { console.error("Failed to close terminal", error) }) }, diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index c41c1bb4b6..9b71d6567e 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -4,9 +4,9 @@ import * as Sentry from "@sentry/solid" import { render } from "solid-js/web" import { AppBaseProviders, AppInterface } from "@/app" import { type Platform, PlatformProvider } from "@/context/platform" +import { createBrowserDraftStore } from "@/utils/draft-store" import { dict as en } from "@/i18n/en" import { dict as zh } from "@/i18n/zh" -import { handleNotificationClick } from "@/utils/notification-click" import { authFromToken } from "@/utils/server" import pkg from "../package.json" import { ServerConnection } from "./context/server" @@ -54,7 +54,7 @@ const setStorage = (key: string, value: string | null) => { const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY) const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url) -const notify: Platform["notify"] = async (title, description, href) => { +const notify: Platform["notify"] = async (title, description, onClick) => { if (!("Notification" in window)) return const permission = @@ -73,21 +73,17 @@ const notify: Platform["notify"] = async (title, description, href) => { }) notification.onclick = () => { - handleNotificationClick(href) + window.focus() + onClick?.() notification.close() } } -const openLink: Platform["openLink"] = (url) => { - window.open(url, "_blank") -} - -const back: Platform["back"] = () => { - window.history.back() -} - -const forward: Platform["forward"] = () => { - window.history.forward() +const openExternal: Platform["openExternal"] = (value) => { + if (!URL.canParse(value)) return + const url = new URL(value) + if (url.protocol !== "http:" && url.protocol !== "https:" && url.protocol !== "mailto:") return + window.open(url.href, "_blank", "noopener,noreferrer") } const restart: Platform["restart"] = async () => { @@ -121,10 +117,9 @@ const clearAuthToken = () => { const platform: Platform = { platform: "web", + draftStore: createBrowserDraftStore(), version: pkg.version, - openLink, - back, - forward, + openExternal, restart, notify, getDefaultServer: async () => { diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index 982c26ca97..60ad01c867 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -2,7 +2,7 @@ import { useServerSync } from "@/context/server-sync" import { decode64 } from "@/utils/base64" import { useParams } from "@solidjs/router" import { Iterable, pipe } from "effect" -import type { Accessor } from "solid-js" +import { createEffect, createMemo, type Accessor } from "solid-js" import { selectProviderCatalog } from "./provider-catalog" export const popularProviders = [ @@ -17,14 +17,14 @@ export const popularProviders = [ ] const popularProviderSet = new Set(popularProviders) -export function useProviders(directory?: Accessor) { +export function useProviders(directory: Accessor) { const serverSync = useServerSync() const params = useParams() const dir = () => (directory ? directory() : decode64(params.dir)) const providers = () => { const value = dir() const projectStore = value ? serverSync().child(value)[0] : undefined - if (directory) + if (value) return selectProviderCatalog({ explicit: true, directory: value, @@ -37,6 +37,7 @@ export function useProviders(directory?: Accessor) { global: serverSync().data.provider, }) } + return { all: () => providers().all, default: () => providers().default, @@ -58,7 +59,7 @@ export function useProviders(directory?: Accessor) { }, paid: () => { const connected = new Set(providers().connected) - return [ + const paid = [ ...Iterable.filter( providers().all, ([id]) => @@ -66,6 +67,7 @@ export function useProviders(directory?: Accessor) { (id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)), ), ] + return paid }, } } diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index e45186b018..89fa394637 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -86,6 +86,7 @@ export const dict = { "command.session.unshare": "إلغاء مشاركة الجلسة", "command.session.unshare.description": "إيقاف مشاركة هذه الجلسة", "palette.search.placeholder": "البحث في الملفات والأوامر والجلسات", + "palette.search.placeholder.home": "البحث في الأوامر والجلسات", "palette.empty": "لا توجد نتائج", "palette.group.commands": "الأوامر", "palette.group.files": "الملفات", @@ -276,6 +277,7 @@ export const dict = { "prompt.action.send": "إرسال", "prompt.action.stop": "توقف", "prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم", + "prompt.toast.attachmentDuplicate.title": "تم تحميل هذا الملف بالفعل", "prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.", "prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً", "prompt.toast.modelAgentRequired.description": "اختر وكيلاً ونموذجاً قبل إرسال الموجه.", @@ -612,6 +614,7 @@ export const dict = { "session.new.workspace.triggerLocal": "محلي", "session.new.workspace.local": "المستودع المحلي", "session.new.workspace.existing": "مساحة عمل…", + "session.new.git.none": "لا يوجد Git", "session.new.lastModified": "آخر تعديل", "session.header.search.placeholder": "بحث {{project}}", "session.header.searchFiles": "بحث عن الملفات", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 0e709e83c9..f6e5d8df9d 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -86,6 +86,7 @@ export const dict = { "command.session.unshare": "Parar de compartilhar sessão", "command.session.unshare.description": "Parar de compartilhar esta sessão", "palette.search.placeholder": "Buscar arquivos, comandos e sessões", + "palette.search.placeholder.home": "Buscar comandos e sessões", "palette.empty": "Nenhum resultado encontrado", "palette.group.commands": "Comandos", "palette.group.files": "Arquivos", @@ -276,6 +277,7 @@ export const dict = { "prompt.action.send": "Enviar", "prompt.action.stop": "Parar", "prompt.toast.pasteUnsupported.title": "Anexo não suportado", + "prompt.toast.attachmentDuplicate.title": "Este arquivo já foi enviado", "prompt.toast.pasteUnsupported.description": "Apenas imagens, PDFs ou arquivos de texto podem ser anexados aqui.", "prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo", "prompt.toast.modelAgentRequired.description": "Escolha um agente e modelo antes de enviar um prompt.", @@ -618,6 +620,7 @@ export const dict = { "session.new.workspace.triggerLocal": "Local", "session.new.workspace.local": "Repositório local", "session.new.workspace.existing": "Espaço de trabalho…", + "session.new.git.none": "Sem Git", "session.new.lastModified": "Última modificação", "session.header.search.placeholder": "Buscar {{project}}", "session.header.searchFiles": "Buscar arquivos", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 988adda0da..3343df7a2b 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -93,6 +93,7 @@ export const dict = { "command.session.unshare.description": "Zaustavi dijeljenje ove sesije", "palette.search.placeholder": "Pretraži datoteke, komande i sesije", + "palette.search.placeholder.home": "Pretraži komande i sesije", "palette.empty": "Nema rezultata", "palette.group.commands": "Komande", "palette.group.files": "Datoteke", @@ -297,6 +298,7 @@ export const dict = { "prompt.action.stop": "Zaustavi", "prompt.toast.pasteUnsupported.title": "Nepodržan prilog", + "prompt.toast.attachmentDuplicate.title": "Ova datoteka je već učitana", "prompt.toast.pasteUnsupported.description": "Ovdje se mogu priložiti samo slike, PDF-ovi ili tekstualne datoteke.", "prompt.toast.modelAgentRequired.title": "Odaberi agenta i model", "prompt.toast.modelAgentRequired.description": "Odaberi agenta i model prije slanja upita.", @@ -674,6 +676,7 @@ export const dict = { "session.new.workspace.triggerLocal": "Lokalno", "session.new.workspace.local": "Lokalni repozitorij", "session.new.workspace.existing": "Radni prostor…", + "session.new.git.none": "Nema Gita", "session.new.lastModified": "Posljednja izmjena", "session.header.search.placeholder": "Pretraži {{project}}", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 6966a0b026..52bfb642c3 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -93,6 +93,7 @@ export const dict = { "command.session.unshare.description": "Stop med at dele denne session", "palette.search.placeholder": "Søg i filer, kommandoer og sessioner", + "palette.search.placeholder.home": "Søg i kommandoer og sessioner", "palette.empty": "Ingen resultater fundet", "palette.group.commands": "Kommandoer", "palette.group.files": "Filer", @@ -295,6 +296,7 @@ export const dict = { "prompt.action.stop": "Stop", "prompt.toast.pasteUnsupported.title": "Ikke understøttet vedhæftning", + "prompt.toast.attachmentDuplicate.title": "Denne fil er allerede uploadet", "prompt.toast.pasteUnsupported.description": "Kun billeder, PDF'er eller tekstfiler kan vedhæftes her.", "prompt.toast.modelAgentRequired.title": "Vælg en agent og model", "prompt.toast.modelAgentRequired.description": "Vælg en agent og model før du sender en forespørgsel.", @@ -669,6 +671,7 @@ export const dict = { "session.new.workspace.triggerLocal": "Lokal", "session.new.workspace.local": "Lokalt repository", "session.new.workspace.existing": "Arbejdsområde…", + "session.new.git.none": "Ingen Git", "session.new.lastModified": "Sidst ændret", "session.header.search.placeholder": "Søg {{project}}", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 182243464e..8619546079 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -90,6 +90,7 @@ export const dict = { "command.session.unshare": "Teilen der Sitzung aufheben", "command.session.unshare.description": "Teilen dieser Sitzung beenden", "palette.search.placeholder": "Dateien, Befehle und Sitzungen durchsuchen", + "palette.search.placeholder.home": "Befehle und Sitzungen durchsuchen", "palette.empty": "Keine Ergebnisse gefunden", "palette.group.commands": "Befehle", "palette.group.files": "Dateien", @@ -281,6 +282,7 @@ export const dict = { "prompt.action.send": "Senden", "prompt.action.stop": "Stopp", "prompt.toast.pasteUnsupported.title": "Nicht unterstützter Anhang", + "prompt.toast.attachmentDuplicate.title": "Diese Datei wurde bereits hochgeladen", "prompt.toast.pasteUnsupported.description": "Hier können nur Bilder, PDFs oder Textdateien angehängt werden.", "prompt.toast.modelAgentRequired.title": "Wählen Sie einen Agenten und ein Modell", "prompt.toast.modelAgentRequired.description": @@ -627,6 +629,7 @@ export const dict = { "session.new.workspace.triggerLocal": "Lokal", "session.new.workspace.local": "Lokales Repository", "session.new.workspace.existing": "Arbeitsbereich…", + "session.new.git.none": "Kein Git", "session.new.lastModified": "Zuletzt geändert", "session.header.search.placeholder": "{{project}} durchsuchen", "session.header.searchFiles": "Dateien suchen", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 3716b5bf8e..76623f4468 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -93,6 +93,7 @@ export const dict = { "command.session.unshare.description": "Stop sharing this session", "palette.search.placeholder": "Search files, commands, and sessions", + "palette.search.placeholder.home": "Search commands and sessions", "palette.empty": "No results found", "palette.group.commands": "Commands", "palette.group.files": "Files", @@ -101,6 +102,7 @@ export const dict = { "dialog.provider.empty": "No providers found", "dialog.provider.group.popular": "Popular", "dialog.provider.group.other": "Other", + "dialog.provider.custom.label": "Custom OpenAI-compatible provider", "dialog.provider.tag.recommended": "Recommended", "dialog.provider.opencode.note": "Curated models including Claude, GPT, Gemini and more", "dialog.provider.opencode.tagline": "Reliable optimized models", @@ -121,6 +123,7 @@ export const dict = { "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.viewAll": "Show more providers", @@ -298,6 +301,7 @@ export const dict = { "prompt.toast.pasteUnsupported.title": "Unsupported attachment", "prompt.toast.pasteUnsupported.description": "Only images, PDFs, or text files can be attached here.", + "prompt.toast.attachmentDuplicate.title": "This file has already been uploaded", "prompt.toast.modelAgentRequired.title": "Select an agent and model", "prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.", "prompt.toast.worktreeCreateFailed.title": "Failed to create worktree", @@ -697,6 +701,7 @@ export const dict = { "session.new.workspace.triggerLocal": "Local", "session.new.workspace.local": "Local repository", "session.new.workspace.existing": "Workspace…", + "session.new.git.none": "No Git", "session.new.lastModified": "Last modified", "session.header.search.placeholder": "Search {{project}}", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 49a3b61614..043c997c04 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -93,6 +93,7 @@ export const dict = { "command.session.unshare.description": "Dejar de compartir esta sesión", "palette.search.placeholder": "Buscar archivos, comandos y sesiones", + "palette.search.placeholder.home": "Buscar comandos y sesiones", "palette.empty": "No se encontraron resultados", "palette.group.commands": "Comandos", "palette.group.files": "Archivos", @@ -296,6 +297,7 @@ export const dict = { "prompt.action.stop": "Detener", "prompt.toast.pasteUnsupported.title": "Adjunto no compatible", + "prompt.toast.attachmentDuplicate.title": "Este archivo ya se ha subido", "prompt.toast.pasteUnsupported.description": "Solo se pueden adjuntar imágenes, PDFs o archivos de texto aquí.", "prompt.toast.modelAgentRequired.title": "Selecciona un agente y modelo", "prompt.toast.modelAgentRequired.description": "Elige un agente y modelo antes de enviar un prompt.", @@ -675,6 +677,7 @@ export const dict = { "session.new.workspace.triggerLocal": "Local", "session.new.workspace.local": "Repositorio local", "session.new.workspace.existing": "Espacio de trabajo…", + "session.new.git.none": "Sin Git", "session.new.lastModified": "Última modificación", "session.header.search.placeholder": "Buscar {{project}}", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 7a793a4535..fc5c5bbb34 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -86,6 +86,7 @@ export const dict = { "command.session.unshare": "Ne plus partager la session", "command.session.unshare.description": "Arrêter de partager cette session", "palette.search.placeholder": "Rechercher des fichiers, des commandes et des sessions", + "palette.search.placeholder.home": "Rechercher des commandes et des sessions", "palette.empty": "Aucun résultat trouvé", "palette.group.commands": "Commandes", "palette.group.files": "Fichiers", @@ -276,6 +277,7 @@ export const dict = { "prompt.action.send": "Envoyer", "prompt.action.stop": "Arrêter", "prompt.toast.pasteUnsupported.title": "Pièce jointe non prise en charge", + "prompt.toast.attachmentDuplicate.title": "Ce fichier a déjà été téléversé", "prompt.toast.pasteUnsupported.description": "Seules les images, les PDF ou les fichiers texte peuvent être joints ici.", "prompt.toast.modelAgentRequired.title": "Sélectionnez un agent et un modèle", @@ -623,6 +625,7 @@ export const dict = { "session.new.workspace.triggerLocal": "Local", "session.new.workspace.local": "Dépôt local", "session.new.workspace.existing": "Espace de travail…", + "session.new.git.none": "Pas de Git", "session.new.lastModified": "Dernière modification", "session.header.search.placeholder": "Rechercher {{project}}", "session.header.searchFiles": "Rechercher des fichiers", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 12961f5522..56c63e0e70 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -86,6 +86,7 @@ export const dict = { "command.session.unshare": "セッションの共有を停止", "command.session.unshare.description": "このセッションの共有を停止", "palette.search.placeholder": "ファイル、コマンド、セッションを検索", + "palette.search.placeholder.home": "コマンドとセッションを検索", "palette.empty": "結果が見つかりません", "palette.group.commands": "コマンド", "palette.group.files": "ファイル", @@ -275,6 +276,7 @@ export const dict = { "prompt.action.send": "送信", "prompt.action.stop": "停止", "prompt.toast.pasteUnsupported.title": "サポートされていない添付ファイル", + "prompt.toast.attachmentDuplicate.title": "このファイルはすでにアップロードされています", "prompt.toast.pasteUnsupported.description": "画像、PDF、またはテキストファイルのみ添付できます。", "prompt.toast.modelAgentRequired.title": "エージェントとモデルを選択", "prompt.toast.modelAgentRequired.description": "プロンプトを送信する前にエージェントとモデルを選択してください。", @@ -614,6 +616,7 @@ export const dict = { "session.new.workspace.triggerLocal": "ローカル", "session.new.workspace.local": "ローカルリポジトリ", "session.new.workspace.existing": "ワークスペース…", + "session.new.git.none": "Git なし", "session.new.lastModified": "最終更新", "session.header.search.placeholder": "{{project}}を検索", "session.header.searchFiles": "ファイルを検索", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 1bf0e80809..02d9a5e0e1 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -82,6 +82,7 @@ export const dict = { "command.session.unshare": "세션 공유 중지", "command.session.unshare.description": "이 세션 공유 중지", "palette.search.placeholder": "파일, 명령어 및 세션 검색", + "palette.search.placeholder.home": "명령어 및 세션 검색", "palette.empty": "결과 없음", "palette.group.commands": "명령어", "palette.group.files": "파일", @@ -263,6 +264,7 @@ export const dict = { "prompt.action.send": "전송", "prompt.action.stop": "중지", "prompt.toast.pasteUnsupported.title": "지원되지 않는 첨부 파일", + "prompt.toast.attachmentDuplicate.title": "이 파일은 이미 업로드되었습니다", "prompt.toast.pasteUnsupported.description": "이미지, PDF 또는 텍스트 파일만 첨부할 수 있습니다.", "prompt.toast.modelAgentRequired.title": "에이전트 및 모델 선택", "prompt.toast.modelAgentRequired.description": "프롬프트를 보내기 전에 에이전트와 모델을 선택하세요.", @@ -975,6 +977,7 @@ export const dict = { "session.new.workspace.triggerLocal": "로컬", "session.new.workspace.local": "로컬 저장소", "session.new.workspace.existing": "작업 공간…", + "session.new.git.none": "Git 없음", "sidebar.empty.title": "열린 프로젝트 없음", "sidebar.empty.description": "프로젝트를 열어 시작하세요", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 68fc9e6a24..959e4c68cb 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -92,6 +92,7 @@ export const dict = { "command.session.unshare.description": "Slutt å dele denne sesjonen", "palette.search.placeholder": "Søk i filer, kommandoer og sesjoner", + "palette.search.placeholder.home": "Søk i kommandoer og sesjoner", "palette.empty": "Ingen resultater funnet", "palette.group.commands": "Kommandoer", "palette.group.files": "Filer", @@ -287,6 +288,7 @@ export const dict = { "prompt.action.stop": "Stopp", "prompt.toast.pasteUnsupported.title": "Ikke støttet vedlegg", + "prompt.toast.attachmentDuplicate.title": "Denne filen er allerede lastet opp", "prompt.toast.pasteUnsupported.description": "Kun bilder, PDF-er eller tekstfiler kan legges ved her.", "prompt.toast.modelAgentRequired.title": "Velg en agent og modell", "prompt.toast.modelAgentRequired.description": "Velg en agent og modell før du sender en forespørsel.", @@ -1069,6 +1071,7 @@ export const dict = { "session.new.workspace.triggerLocal": "Lokalt", "session.new.workspace.local": "Lokalt depot", "session.new.workspace.existing": "Arbeidsområde…", + "session.new.git.none": "Ingen Git", "sidebar.empty.title": "Ingen åpne prosjekter", "sidebar.empty.description": "Åpne et prosjekt for å komme i gang", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index f9392bef70..7517f16b81 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -86,6 +86,7 @@ export const dict = { "command.session.unshare": "Przestań udostępniać sesję", "command.session.unshare.description": "Zatrzymaj udostępnianie tej sesji", "palette.search.placeholder": "Szukaj plików, poleceń i sesji", + "palette.search.placeholder.home": "Szukaj poleceń i sesji", "palette.empty": "Brak wyników", "palette.group.commands": "Polecenia", "palette.group.files": "Pliki", @@ -277,6 +278,7 @@ export const dict = { "prompt.action.send": "Wyślij", "prompt.action.stop": "Zatrzymaj", "prompt.toast.pasteUnsupported.title": "Nieobsługiwany załącznik", + "prompt.toast.attachmentDuplicate.title": "Ten plik został już przesłany", "prompt.toast.pasteUnsupported.description": "Można tutaj załączać tylko obrazy, pliki PDF lub pliki tekstowe.", "prompt.toast.modelAgentRequired.title": "Wybierz agenta i model", "prompt.toast.modelAgentRequired.description": "Wybierz agenta i model przed wysłaniem zapytania.", @@ -618,6 +620,7 @@ export const dict = { "session.new.workspace.triggerLocal": "Lokalnie", "session.new.workspace.local": "Lokalne repozytorium", "session.new.workspace.existing": "Przestrzeń robocza…", + "session.new.git.none": "Brak Git", "session.new.lastModified": "Ostatnio zmodyfikowano", "session.header.search.placeholder": "Szukaj {{project}}", "session.header.searchFiles": "Szukaj plików", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 865213259a..17d6ba58a9 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -93,6 +93,7 @@ export const dict = { "command.session.unshare.description": "Прекратить публикацию сессии", "palette.search.placeholder": "Поиск файлов, команд и сессий", + "palette.search.placeholder.home": "Поиск команд и сессий", "palette.empty": "Ничего не найдено", "palette.group.commands": "Команды", "palette.group.files": "Файлы", @@ -296,6 +297,7 @@ export const dict = { "prompt.action.stop": "Остановить", "prompt.toast.pasteUnsupported.title": "Неподдерживаемое вложение", + "prompt.toast.attachmentDuplicate.title": "Этот файл уже загружен", "prompt.toast.pasteUnsupported.description": "Здесь можно прикрепить только изображения, PDF или текстовые файлы.", "prompt.toast.modelAgentRequired.title": "Выберите агента и модель", "prompt.toast.modelAgentRequired.description": "Выберите агента и модель перед отправкой запроса.", @@ -672,6 +674,7 @@ export const dict = { "session.new.workspace.triggerLocal": "Локально", "session.new.workspace.local": "Локальный репозиторий", "session.new.workspace.existing": "Рабочее пространство…", + "session.new.git.none": "Нет Git", "session.new.lastModified": "Последнее изменение", "session.header.search.placeholder": "Поиск {{project}}", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 4c91e7cc73..2c0b7d081c 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -93,6 +93,7 @@ export const dict = { "command.session.unshare.description": "หยุดการแชร์เซสชันนี้", "palette.search.placeholder": "ค้นหาไฟล์ คำสั่ง และเซสชัน", + "palette.search.placeholder.home": "ค้นหาคำสั่งและเซสชัน", "palette.empty": "ไม่พบผลลัพธ์", "palette.group.commands": "คำสั่ง", "palette.group.files": "ไฟล์", @@ -296,6 +297,7 @@ export const dict = { "prompt.action.stop": "หยุด", "prompt.toast.pasteUnsupported.title": "ไฟล์แนบที่ไม่รองรับ", + "prompt.toast.attachmentDuplicate.title": "ไฟล์นี้ถูกอัปโหลดแล้ว", "prompt.toast.pasteUnsupported.description": "แนบได้เฉพาะรูปภาพ, PDF หรือไฟล์ข้อความเท่านั้น", "prompt.toast.modelAgentRequired.title": "เลือกเอเจนต์และโมเดล", "prompt.toast.modelAgentRequired.description": "เลือกเอเจนต์และโมเดลก่อนส่งพร้อมท์", @@ -668,6 +670,7 @@ export const dict = { "session.new.workspace.triggerLocal": "ในเครื่อง", "session.new.workspace.local": "รีโพซิทอรีในเครื่อง", "session.new.workspace.existing": "พื้นที่ทำงาน…", + "session.new.git.none": "ไม่มี Git", "session.new.lastModified": "แก้ไขล่าสุด", "session.header.search.placeholder": "ค้นหา {{project}}", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 02a9b6a10e..7c123c69aa 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -97,6 +97,7 @@ export const dict = { "command.session.unshare.description": "Bu oturumun paylaşımını durdur", "palette.search.placeholder": "Dosya, komut ve oturum ara", + "palette.search.placeholder.home": "Komut ve oturum ara", "palette.empty": "Sonuç bulunamadı", "palette.group.commands": "Komutlar", "palette.group.files": "Dosyalar", @@ -301,6 +302,7 @@ export const dict = { "prompt.action.stop": "Durdur", "prompt.toast.pasteUnsupported.title": "Desteklenmeyen ek", + "prompt.toast.attachmentDuplicate.title": "Bu dosya zaten yüklendi", "prompt.toast.pasteUnsupported.description": "Buraya yalnızca resimler, PDF'ler veya metin dosyaları eklenebilir.", "prompt.toast.modelAgentRequired.title": "Bir ajan ve model seçin", "prompt.toast.modelAgentRequired.description": "Komut göndermeden önce bir ajan ve model seçin.", @@ -678,6 +680,7 @@ export const dict = { "session.new.workspace.triggerLocal": "Yerel", "session.new.workspace.local": "Yerel depo", "session.new.workspace.existing": "Çalışma alanı…", + "session.new.git.none": "Git yok", "session.new.lastModified": "Son değişiklik", "session.header.search.placeholder": "{{project}} ara", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index c71b81fba8..d8c9ae8e43 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -93,6 +93,7 @@ export const dict = { "command.session.unshare.description": "Припинити поширення цієї сесії", "palette.search.placeholder": "Пошук файлів, команд і сесій", + "palette.search.placeholder.home": "Пошук команд і сесій", "palette.empty": "Результатів не знайдено", "palette.group.commands": "Команди", "palette.group.files": "Файли", @@ -298,6 +299,7 @@ export const dict = { "prompt.action.stop": "Зупинити", "prompt.toast.pasteUnsupported.title": "Непідтримуване вкладення", + "prompt.toast.attachmentDuplicate.title": "Цей файл уже завантажено", "prompt.toast.pasteUnsupported.description": "Сюди можна прикріплювати лише зображення, PDF або текстові файли.", "prompt.toast.modelAgentRequired.title": "Виберіть агента та модель", "prompt.toast.modelAgentRequired.description": "Виберіть агента та модель перед надсиланням запиту.", @@ -702,6 +704,7 @@ export const dict = { "session.new.workspace.triggerLocal": "Локально", "session.new.workspace.local": "Локальний репозиторій", "session.new.workspace.existing": "Робоча область…", + "session.new.git.none": "Немає Git", "session.new.lastModified": "Востаннє змінено", "session.header.search.placeholder": "Пошук {{project}}", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 271c082c12..724bcc1175 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -120,6 +120,7 @@ export const dict = { "command.session.unshare.description": "停止分享此会话", "palette.search.placeholder": "搜索文件、命令和会话", + "palette.search.placeholder.home": "搜索命令和会话", "palette.empty": "未找到结果", "palette.group.commands": "命令", "palette.group.files": "文件", @@ -315,6 +316,7 @@ export const dict = { "prompt.action.send": "发送", "prompt.action.stop": "停止", "prompt.toast.pasteUnsupported.title": "不支持的附件", + "prompt.toast.attachmentDuplicate.title": "此文件已上传", "prompt.toast.pasteUnsupported.description": "此处仅能附加图片、PDF 或文本文件。", "prompt.toast.modelAgentRequired.title": "请选择智能体和模型", "prompt.toast.modelAgentRequired.description": "发送提示前请先选择智能体和模型。", @@ -665,6 +667,7 @@ export const dict = { "session.new.workspace.triggerLocal": "本地", "session.new.workspace.local": "本地仓库", "session.new.workspace.existing": "工作区…", + "session.new.git.none": "无 Git", "session.new.lastModified": "最后修改", "session.header.search.placeholder": "搜索 {{project}}", "session.header.searchFiles": "搜索文件", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 136ff16ad6..6f66822165 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -97,6 +97,7 @@ export const dict = { "command.session.unshare.description": "停止分享此工作階段", "palette.search.placeholder": "搜尋檔案、命令和工作階段", + "palette.search.placeholder.home": "搜尋命令和工作階段", "palette.empty": "找不到結果", "palette.group.commands": "命令", "palette.group.files": "檔案", @@ -296,6 +297,7 @@ export const dict = { "prompt.action.stop": "停止", "prompt.toast.pasteUnsupported.title": "不支援的附件", + "prompt.toast.attachmentDuplicate.title": "此檔案已上傳", "prompt.toast.pasteUnsupported.description": "此處僅能附加圖片、PDF 或文字檔案。", "prompt.toast.modelAgentRequired.title": "請選擇代理程式和模型", "prompt.toast.modelAgentRequired.description": "傳送提示前請先選擇代理程式和模型。", @@ -662,6 +664,7 @@ export const dict = { "session.new.workspace.triggerLocal": "本機", "session.new.workspace.local": "本機儲存庫", "session.new.workspace.existing": "工作區…", + "session.new.git.none": "無 Git", "session.new.lastModified": "最後修改", "session.header.search.placeholder": "搜尋 {{project}}", diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index d18e05fc9c..8d36086d9f 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -27,4 +27,4 @@ export { type WslServersState, } from "./wsl/types" export { ServerConnection } from "./context/server" -export { handleNotificationClick } from "./utils/notification-click" +export { createDraftStore, type DraftStore } from "./utils/draft-store" diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 77c120e57b..a0b3576396 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -63,6 +63,7 @@ export function DirectoryDataProvider( navigate(href(sessionID))} onSessionHref={href} > diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index 86a997b39d..2fc6095412 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -354,7 +354,7 @@ export const ErrorPage: Component = (props) => {
- ) -} - -function HomeProjectColumn(props: { - projects: LocalProject[] - recentlyClosed: LocalProject[] - homedir: string - selected: HomeProjectSelection - focusServer: (server: ServerConnection.Any) => void - selectProject: (server: ServerConnection.Any, directory: string) => void - openNewSession: (server: ServerConnection.Any, directory: string) => void - openRecentProject: (server: ServerConnection.Any, directory: string) => void - chooseProject: (server: ServerConnection.Any) => void - editProject: (server: ServerConnection.Any, project: LocalProject) => void - closeProject: (server: ServerConnection.Any, directory: string) => void - clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void - unseenCount: (server: ServerConnection.Any, project: LocalProject) => number - openSettings: () => void - openHelp: () => void - language: ReturnType -}) { - const global = useGlobal() - const dialog = useDialog() - const controller = useServerManagementController({ navigateOnAdd: false }) - const [_state, setState, _, ready] = persisted( - Persist.global("home.servers", ["home.servers.v1"]), - createStore({ collapsed: {} as Record }), - ) - const [state] = createResource( - () => ready.promise ?? Promise.resolve(), - (p) => p.then(() => _state), - { initialValue: _state }, - ) - - return ( - - ) -} - -function HomeUtilityNav(props: { - class?: string - openSettings: () => void - openHelp: () => void - language: ReturnType -}) { - return ( -
- - -
- ) -} - -function HomeServerRow(props: { - server: ServerConnection.Any - selected: boolean - collapsed: boolean - health: ServerHealth | undefined - controller: ReturnType - focusServer: (server: ServerConnection.Any) => void - chooseProject: (server: ServerConnection.Any) => void - openEdit: (server: ServerConnection.Http) => void - toggleCollapsed: () => void - language: ReturnType -}) { - const global = useGlobal() - const [state, setState] = createStore({ menuOpen: false }) - const healthy = () => !!props.health?.healthy - const canToggle = () => healthy() && global.ensureServerCtx(props.server).projects.list().length > 0 - return ( -
- -
- setState("menuOpen", open)} - /> - - } - aria-label={props.language.t("home.project.add")} - disabled={props.health?.healthy === false} - onClick={() => props.chooseProject(props.server)} - /> - -
-
- ) -} - -function HomeProjectList(props: { - server: ServerConnection.Any - projects: LocalProject[] - selected: HomeProjectSelection - selectProject: (server: ServerConnection.Any, directory: string) => void - openNewSession: (server: ServerConnection.Any, directory: string) => void - editProject: (server: ServerConnection.Any, project: LocalProject) => void - closeProject: (server: ServerConnection.Any, directory: string) => void - clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void - unseenCount: (server: ServerConnection.Any, project: LocalProject) => number - language: ReturnType -}) { - return ( -
- - {(project) => ( - - )} - -
- ) -} - -function HomeProjectEmpty(props: { - server: ServerConnection.Any - recentlyClosed: LocalProject[] - homedir: string - chooseProject: (server: ServerConnection.Any) => void - openRecentProject: (server: ServerConnection.Any, directory: string) => void - language: ReturnType -}) { - const global = useGlobal() - const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false - return ( -
- - 0}> -
-
{props.language.t("home.recentlyClosed")}
-
- - {(project) => ( - - )} - -
-
- ) -} - -function HomeRecentlyClosedRow(props: { - project: LocalProject - server: ServerConnection.Any - homedir: string - openRecentProject: (server: ServerConnection.Any, directory: string) => void - language: ReturnType -}) { - const global = useGlobal() - const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false - const path = () => { - const home = props.homedir - const worktree = props.project.worktree - if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}` - return worktree - } - return ( - - - - ) -} - -function HomeProjectRow(props: { - project: LocalProject - server: ServerConnection.Any - selected: boolean - unseenCount: number - selectProject: (server: ServerConnection.Any, directory: string) => void - openNewSession: (server: ServerConnection.Any, directory: string) => void - editProject: (server: ServerConnection.Any, project: LocalProject) => void - closeProject: (server: ServerConnection.Any, directory: string) => void - clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void - language: ReturnType -}) { - const global = useGlobal() - const platform = usePlatform() - const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false - const [state, setState] = createStore({ menuOpen: false }) - const canRevealInFileManager = () => - platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server) - const fileManagerActionLabel = () => - props.language.t( - fileManagerApp(platform.platform === "desktop" ? (platform.os ?? "unknown") : "unknown").actionLabel, - ) - const revealInFileManager = () => { - if (!platform.openPath) return - platform.openPath(props.project.worktree).catch((err: unknown) => - showToast({ - title: props.language.t("common.requestFailed"), - description: errorMessage(err, props.language.t("common.requestFailed")), - }), - ) - } - return ( -
- -
- setState("menuOpen", open)} - > - } - aria-label={props.language.t("common.moreOptions")} - /> - - - props.openNewSession(props.server, props.project.worktree)}> - {props.language.t("command.session.new")} - - props.editProject(props.server, props.project)}> - {props.language.t("dialog.project.edit.title")} - - - {fileManagerActionLabel()} - - props.clearNotifications(props.server, props.project)} - > - {props.language.t("sidebar.project.clearNotifications")} - - - props.closeProject(props.server, props.project.worktree)}> - {props.language.t("common.close")} - - - - - } - aria-label={props.language.t("command.session.new")} - onClick={() => props.openNewSession(props.server, props.project.worktree)} - /> -
-
- ) -} - -function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) { - const name = createMemo(() => displayName(props.project)) - return ( - - ) -} - -function HomeSessionLeading(props: { - project: LocalProject - session: Session - server: ServerConnection.Key - revealProjectOnHover: boolean -}) { - const tabs = useTabs() - const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session)) - return ( -
- - - -
- ) -} - -function HomeSessionSearch(props: { - value: string - placeholder: string - open: boolean - loading: boolean - results: HomeSessionRecord[] - showProjectName: boolean - server: ServerConnection.Key - noResultsLabel: string - bindFocus: (focus: () => void) => void - onInput: (value: string) => void - onFocus: () => void - onClose: () => void - onSelect: (session: Session, options?: OpenSessionOptions) => void -}) { - const language = useLanguage() - const [store, setStore] = createStore({ active: "" }) - let root: HTMLDivElement | undefined - let input: HTMLInputElement | undefined - let listRef: HTMLDivElement | undefined - - const focusInput = () => { - input?.focus() - props.onFocus() - } - - onMount(() => { - props.bindFocus(focusInput) - }) - - const syncActive = (results: HomeSessionRecord[]) => { - if (results.length === 0) { - setStore("active", "") - return - } - if (!results.some((record) => homeSessionSearchKey(record) === store.active)) { - setStore("active", homeSessionSearchKey(results[0])) - } - } - - createEffect(() => syncActive(props.results)) - - createEffect( - on( - () => props.value, - () => syncActive(props.results), - ), - ) - - const scrollActiveIntoView = () => { - const key = store.active - if (!key || !listRef) return - const element = listRef.querySelector(`[data-key="${key}"]`) - element?.scrollIntoView({ block: "nearest" }) - } - - const moveActive = (delta: number) => { - const results = props.results - if (results.length === 0) return - const index = results.findIndex((record) => homeSessionSearchKey(record) === store.active) - const start = index === -1 ? 0 : index - const next = (start + delta + results.length) % results.length - setStore("active", homeSessionSearchKey(results[next])) - scrollActiveIntoView() - } - - const selectActive = () => { - const record = props.results.find((item) => homeSessionSearchKey(item) === store.active) - if (!record) return - props.onSelect(record.session) - } - - onCleanup( - makeEventListener(document, "pointerdown", (event) => { - if (!props.open) return - const target = event.target - if (!(target instanceof Node)) return - if (root?.contains(target)) return - props.onClose() - }), - ) - - return ( -
-
- -
-
-
- - -
- } - > - 0} - fallback={ -

- {props.noResultsLabel} -

- } - > -
-

- {language.t("home.sessions.search.sessions")} -

- (listRef = el)}> -
- - {(record) => ( - setStore("active", homeSessionSearchKey(record))} - onSelect={(session, options) => props.onSelect(session, options)} - /> - )} - -
-
-
-
- -
-
-
- - -
-
- ) -} - -function HomeSessionSearchResultRow(props: { - record: HomeSessionRecord - showProjectName: boolean - server: ServerConnection.Key - selected: boolean - onHighlight: () => void - onSelect: (session: Session, options?: OpenSessionOptions) => void -}) { - const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) - const showProjectName = () => props.showProjectName && props.record.projectName - - const key = () => homeSessionSearchKey(props.record) - - return ( - - ) -} - -function HomeSessionGroupHeader(props: { - title: string - titleOpacity: number - ref: ComponentProps<"div">["ref"] - elevated?: boolean -}) { + const home = createHomeController() + const projects = createHomeProjectsController(home) + const sessions = createHomeSessionsController(home) + const search = createHomeSessionSearchController(home, sessions) + const scroll = createHomeScrollController(sessions.data.groups) return (
- -
- ) -} - -function HomeSessionRow(props: { - record: HomeSessionRecord - showProjectName: boolean - server: ServerConnection.Key - openSession: (session: Session, options?: OpenSessionOptions) => void - archiveSession: (session: Session) => Promise -}) { - const language = useLanguage() - const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) - const showProjectName = () => props.showProjectName && props.record.projectName - - return ( -
- - -
- - } - aria-label={language.t("common.archive")} - onClick={(event) => { - event.preventDefault() - event.stopPropagation() - void props.archiveSession(props.record.session) - }} - /> - -
-
-
- ) -} - -function HomeSessionsEmpty(props: { onNewSession?: () => void }) { - const language = useLanguage() - return ( -
-
- {language.t("home.sessions.empty")} -
-

- {language.t("home.sessions.empty.description")} -

- - {(onNewSession) => ( - - {language.t("command.session.new")} - - )} - -
- ) -} - -function HomeSessionSkeleton(props: { label: string }) { - return ( -
-
- -
- - ) -} - -function groupSessions(records: HomeSessionRecord[], language: ReturnType): HomeSessionGroup[] { - const now = DateTime.local() - const yesterday = now.minus({ days: 1 }) - const todaySessions = records.filter((record) => - DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"), - ) - const yesterdaySessions = records.filter((record) => - DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"), - ) - const olderSessions = records.filter((record) => { - const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created) - return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day") - }) - const olderTitle = - todaySessions.length === 0 && yesterdaySessions.length === 0 - ? language.t("sidebar.project.recentSessions") - : language.t("home.sessions.group.older") - - return [ - { id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions }, - { id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions }, - { id: "older" as const, title: olderTitle, sessions: olderSessions }, - ].filter((group) => group.sessions.length > 0) -} - -export function LegacyHome() { - const sync = useServerSync() - const platform = usePlatform() - const pickDirectory = useDirectoryPicker() - const dialog = useDialog() - const navigate = useNavigate() - const global = useGlobal() - const server = useServer() - const language = useLanguage() - const homedir = createMemo(() => sync().data.path.home) - const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false) - const recent = createMemo(() => { - return sync() - .data.project.slice() - .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) - .slice(0, 5) - }) - - const serverDotClass = createMemo(() => { - const healthy = global.servers.health[server.key]?.healthy - if (healthy === true) return "bg-icon-success-base" - if (healthy === false) return "bg-icon-critical-base" - return "bg-border-weak-base" - }) - - function openProject(server: ServerConnection.Any, directory: string) { - const serverCtx = global.ensureServerCtx(server) - serverCtx.projects.open(directory) - serverCtx.projects.touch(directory) - navigate(`/${base64Encode(directory)}`) - } - - function chooseProject() { - if (serverUnreachable()) return - const s = server.current - if (!s) return - - const resolve = (result: string | string[] | null) => { - if (Array.isArray(result)) { - for (const directory of result) { - openProject(s, directory) - } - } else if (result) { - openProject(s, result) - } - } - - pickDirectory({ - server: s, - title: language.t("command.project.open"), - multiple: true, - onSelect: resolve, - }) - } - - return ( -
- - - - 0}> -
-
-
{language.t("home.recentProjects")}
- -
-
    - - {(project) => ( - - )} - -
-
-
- -
-
{language.t("common.loading")}
- -
-
- -
- -
-
{language.t("home.empty.title")}
-
{language.t("home.empty.description")}
-
- -
-
-
+ class={` + mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 + lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6 + `} + > + + + +
+
) } diff --git a/packages/app/src/pages/home/home-controller.ts b/packages/app/src/pages/home/home-controller.ts new file mode 100644 index 0000000000..5e7e50cfc4 --- /dev/null +++ b/packages/app/src/pages/home/home-controller.ts @@ -0,0 +1,108 @@ +import { useGlobal } from "@/context/global" +import { type HomeProjectSelection, useLayout } from "@/context/layout" +import { ServerConnection, useServer } from "@/context/server" +import { useServerSync } from "@/context/server-sync" +import { useTabs } from "@/context/tabs" +import { toggleHomeProjectSelection } from "@/pages/layout/helpers" +import { createEffect, createMemo } from "solid-js" + +export function createHomeController() { + const sync = useServerSync() + const layout = useLayout() + const server = useServer() + const global = useGlobal() + const tabs = useTabs() + const selection = layout.home.selection + const focusedServer = createMemo( + () => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current, + ) + const focusedServerCtx = createMemo(() => { + const conn = focusedServer() + if (!conn) return undefined + return global.ensureServerCtx(conn) + }) + const focusedSync = () => focusedServerCtx()?.sync ?? sync() + const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list()) + const recentlyClosed = createMemo( + () => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(), + ) + const homedir = createMemo(() => focusedSync().data.path.home ?? "") + const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory)) + const newSessionProject = createMemo( + () => + selectedProject() ?? + projects().find((project) => project.worktree === focusedServerCtx()?.projects.last()) ?? + projects()[0], + ) + + createEffect(() => { + const list = global.servers.list() + if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return + const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0] + if (conn) setSelection({ server: ServerConnection.key(conn) }) + }) + + function setSelection(next: HomeProjectSelection) { + layout.home.setSelection(next) + } + + function openProjectNewSession(conn: ServerConnection.Any, directory: string) { + const ctx = global.ensureServerCtx(conn) + ctx.projects.open(directory) + ctx.projects.touch(directory) + void tabs.newDraft({ server: ServerConnection.key(conn), directory }) + } + + return { + selection: { + value: selection, + set: setSelection, + focusServer: (conn: ServerConnection.Any) => setSelection({ server: ServerConnection.key(conn) }), + }, + server: { + list: global.servers.list, + health: (conn: ServerConnection.Any) => global.servers.health[ServerConnection.key(conn)], + context: (conn: ServerConnection.Any) => global.ensureServerCtx(conn), + focused: focusedServer, + focusedContext: focusedServerCtx, + focusedSync, + }, + project: { + list: projects, + recentlyClosed, + homedir, + selected: selectedProject, + newSession: newSessionProject, + forServer: (conn: ServerConnection.Any) => global.ensureServerCtx(conn).projects.list(), + select: (conn: ServerConnection.Any, directory: string) => { + const key = ServerConnection.key(conn) + if (global.servers.health[key]?.healthy === false) return + if ( + !global + .ensureServerCtx(conn) + .projects.list() + .some((project) => project.worktree === directory) + ) + return + setSelection(toggleHomeProjectSelection(selection(), key, directory)) + }, + add: (conn: ServerConnection.Any, directories: string[]) => { + const directory = directories[0] + if (!directory) return + const ctx = global.ensureServerCtx(conn) + directories.forEach((item) => ctx.projects.open(item)) + ctx.projects.touch(directory) + setSelection({ server: ServerConnection.key(conn), directory }) + }, + openNewSession: () => { + const conn = focusedServer() + const project = newSessionProject() + if (!conn || !project) return + openProjectNewSession(conn, project.worktree) + }, + openProjectNewSession, + }, + } +} + +export type HomeController = ReturnType diff --git a/packages/app/src/pages/home/home-projects-controller.tsx b/packages/app/src/pages/home/home-projects-controller.tsx new file mode 100644 index 0000000000..18c58bd290 --- /dev/null +++ b/packages/app/src/pages/home/home-projects-controller.tsx @@ -0,0 +1,128 @@ +import { useDirectoryPicker } from "@/components/directory-picker" +import { useServerManagementController } from "@/components/dialog-select-server" +import { useSettingsCommand } from "@/components/settings-dialog" +import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2" +import { type LocalProject } from "@/context/layout" +import { useLanguage } from "@/context/language" +import { useNotification } from "@/context/notification" +import { usePlatform } from "@/context/platform" +import { ServerConnection } from "@/context/server" +import { closeHomeProject, errorMessage, homeProjectDirectories } from "@/pages/layout/helpers" +import { Persist, persisted } from "@/utils/persist" +import { showToast } from "@/utils/toast" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createResource } from "solid-js" +import { createStore } from "solid-js/store" +import type { HomeController } from "./home-controller" + +export function createHomeProjectsController(home: HomeController) { + const platform = usePlatform() + const pickDirectory = useDirectoryPicker() + const dialog = useDialog() + const language = useLanguage() + const notification = useNotification() + const openSettings = useSettingsCommand() + const serverManagement = useServerManagementController({ navigateOnAdd: false }) + const [_state, setState, _, ready] = persisted( + Persist.global("home.servers", ["home.servers.v1"]), + createStore({ collapsed: {} as Record }), + ) + const [state] = createResource( + () => ready.promise ?? Promise.resolve(), + (promise) => promise.then(() => _state), + { initialValue: _state }, + ) + function directories(project: LocalProject) { + return [project.worktree, ...(project.sandboxes ?? [])] + } + + function canRevealProject(conn: ServerConnection.Any) { + return platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(conn) + } + + return { + copy: { + language, + }, + selection: { + value: home.selection.value, + }, + server: { + list: home.server.list, + health: home.server.health, + projects: home.project.forServer, + collapsed: (conn: ServerConnection.Any) => state().collapsed[ServerConnection.key(conn)] ?? false, + toggleCollapsed: (conn: ServerConnection.Any) => { + const key = ServerConnection.key(conn) + setState("collapsed", key, !state().collapsed[key]) + }, + canDefault: serverManagement.canDefault, + defaultKey: serverManagement.defaultKey, + setDefault: (conn: ServerConnection.Any | undefined) => + serverManagement.setDefault(conn ? ServerConnection.key(conn) : null), + remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)), + edit: (conn: ServerConnection.Http) => dialog.show(() => ), + focus: home.selection.focusServer, + }, + project: { + list: home.project.list, + recentlyClosed: home.project.recentlyClosed, + homedir: home.project.homedir, + select: home.project.select, + add: home.project.add, + openNewSession: home.project.openProjectNewSession, + edit: (conn: ServerConnection.Any, project: LocalProject) => { + void import("@/components/dialog-edit-project-v2").then(({ DialogEditProjectV2 }) => { + void dialog.show(() => ) + }) + }, + unseenCount: (conn: ServerConnection.Any, project: LocalProject) => { + const state = notification.ensureServerState(ServerConnection.key(conn)) + return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0) + }, + clearNotifications: (conn: ServerConnection.Any, project: LocalProject) => { + const state = notification.ensureServerState(ServerConnection.key(conn)) + directories(project) + .filter((directory) => state.project.unseenCount(directory) > 0) + .forEach((directory) => state.project.markViewed(directory)) + }, + choose: (conn: ServerConnection.Any) => { + if (home.server.health(conn)?.healthy === false) return + pickDirectory({ + server: conn, + title: language.t("command.project.open"), + multiple: true, + onSelect: (result) => home.project.add(conn, homeProjectDirectories(result)), + }) + }, + close: (conn: ServerConnection.Any, directory: string) => { + const next = closeHomeProject( + home.selection.value(), + ServerConnection.key(conn), + home.server.context(conn).projects, + directory, + ) + if (next) home.selection.set(next) + }, + move: (conn: ServerConnection.Any, worktree: string, index: number) => { + home.server.context(conn).projects.move(worktree, index) + }, + canReveal: canRevealProject, + reveal: (conn: ServerConnection.Any, project: LocalProject) => { + if (!platform.openPath || !canRevealProject(conn)) return + platform.openPath(project.worktree).catch((cause: unknown) => + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(cause, language.t("common.requestFailed")), + }), + ) + }, + }, + utility: { + settings: openSettings, + help: () => platform.openExternal("https://opencode.ai/desktop-feedback"), + }, + } +} + +export type HomeProjectsController = ReturnType diff --git a/packages/app/src/pages/home/home-projects-view.tsx b/packages/app/src/pages/home/home-projects-view.tsx new file mode 100644 index 0000000000..d80d4a942f --- /dev/null +++ b/packages/app/src/pages/home/home-projects-view.tsx @@ -0,0 +1,615 @@ +import { type Accessor, createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js" +import { createStore } from "solid-js/store" +import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" +import { isSortable, useSortable } from "@dnd-kit/solid/sortable" +import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom" +import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers" +import { RestrictToElement } from "@dnd-kit/dom/modifiers" +import { ScrollView } from "@opencode-ai/ui/scroll-view" +import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { getProjectAvatarVariant, type HomeProjectSelection, type LocalProject } from "@/context/layout" +import { ServerConnection } from "@/context/server" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" +import { ServerRowMenuView, serverMenuLabels } from "@/components/server/server-row-menu" +import { ServerHealthIndicator } from "@/components/server/server-row" +import { type ServerHealth } from "@/utils/server-health" +import { fileManagerApp } from "@/utils/file-manager" + +const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap" + +const serverContextMenuID = (server: ServerConnection.Any) => `server:${ServerConnection.key(server)}` +const projectContextMenuID = (server: ServerConnection.Any, directory: string) => + `project:${ServerConnection.key(server)}:${directory}` + +export type HomeProjectsViewProps = { + language: ReturnType + servers: Accessor + projects: Accessor + recentlyClosed: Accessor + selection: Accessor + homedir: Accessor + serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined + projectsForServer: (server: ServerConnection.Any) => LocalProject[] + collapsed: (server: ServerConnection.Any) => boolean + canDefaultServer: Accessor + defaultServerKey: Accessor + canRevealProject: (server: ServerConnection.Any) => boolean + unseenCount: (server: ServerConnection.Any, project: LocalProject) => number + onWheel: (event: WheelEvent) => void + onChooseProject: (server: ServerConnection.Any) => void + onFocusServer: (server: ServerConnection.Any) => void + onToggleCollapsed: (server: ServerConnection.Any) => void + onEditServer: (server: ServerConnection.Http) => void + onSetDefaultServer: (server: ServerConnection.Any | undefined) => void + onRemoveServer: (server: ServerConnection.Any) => void + onMoveProject: (server: ServerConnection.Any, worktree: string, index: number) => void + onSelectProject: (server: ServerConnection.Any, directory: string) => void + onAddProjects: (server: ServerConnection.Any, directories: string[]) => void + onOpenProjectNewSession: (server: ServerConnection.Any, directory: string) => void + onEditProject: (server: ServerConnection.Any, project: LocalProject) => void + onRevealProject: (server: ServerConnection.Any, project: LocalProject) => void + onClearNotifications: (server: ServerConnection.Any, project: LocalProject) => void + onCloseProject: (server: ServerConnection.Any, directory: string) => void + onOpenSettings: () => void + onOpenHelp: () => void +} + +export function HomeProjectsView(props: HomeProjectsViewProps) { + const [contextMenu, setContextMenu] = createStore({ open: undefined as string | undefined }) + const contextMenuProps = { + contextMenuOpen: (id: string) => contextMenu.open === id, + onSetContextMenuOpen: (id: string, open: boolean) => setContextMenu("open", open ? id : undefined), + } + return ( + + ) +} + +export function HomeUtilityNav(props: { + class?: string + onOpenSettings: () => void + onOpenHelp: () => void + language: ReturnType +}) { + return ( +
+ + + {props.language.t("sidebar.settings")} + + + + {props.language.t("sidebar.help")} + +
+ ) +} + +function HomeServerRow(props: { + language: HomeProjectsViewProps["language"] + projectsForServer: HomeProjectsViewProps["projectsForServer"] + contextMenuOpen: HomeProjectsContextMenuProps["contextMenuOpen"] + canDefaultServer: HomeProjectsViewProps["canDefaultServer"] + defaultServerKey: HomeProjectsViewProps["defaultServerKey"] + onFocusServer: HomeProjectsViewProps["onFocusServer"] + onToggleCollapsed: HomeProjectsViewProps["onToggleCollapsed"] + onEditServer: HomeProjectsViewProps["onEditServer"] + onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"] + onRemoveServer: HomeProjectsViewProps["onRemoveServer"] + onSetContextMenuOpen: HomeProjectsContextMenuProps["onSetContextMenuOpen"] + onChooseProject: HomeProjectsViewProps["onChooseProject"] + server: ServerConnection.Any + selected: boolean + collapsed: boolean + health: ServerHealth | undefined +}) { + const healthy = () => !!props.health?.healthy + const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0 + const contextMenuID = () => serverContextMenuID(props.server) + onCleanup(() => { + const id = contextMenuID() + if (props.contextMenuOpen(id)) props.onSetContextMenuOpen(id, false) + }) + return ( +
+ props.onFocusServer(props.server)} + > + { + event.preventDefault() + event.stopPropagation() + if (!canToggle()) return + props.onToggleCollapsed(props.server) + }} + onPointerDown={(event) => event.preventDefault()} + > + + +
+ +
+ + {props.server.displayName ?? new URL(props.server.http.url).host} + + {(label) => ( + + {label()} + + )} + + +
+
+ props.onSetDefaultServer(props.server)} + onRemoveDefault={() => props.onSetDefaultServer(undefined)} + onRemove={() => props.onRemoveServer(props.server)} + open={props.contextMenuOpen(contextMenuID())} + onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)} + /> + + } + aria-label={props.language.t("home.project.add")} + disabled={props.health?.healthy === false} + onClick={() => props.onChooseProject(props.server)} + /> + +
+
+ ) +} + +type HomeProjectsContextMenuProps = { + contextMenuOpen: (id: string) => boolean + onSetContextMenuOpen: (id: string, open: boolean) => void +} + +type HomeProjectListProps = HomeProjectsViewProps & + HomeProjectsContextMenuProps & { + server: ServerConnection.Any + items: LocalProject[] + } + +function HomeProjectList(props: HomeProjectListProps) { + let listRef!: HTMLDivElement + + return ( + [ + ...defaults.filter((sensor) => sensor !== PointerSensor), + PointerSensor.configure({ + activationConstraints: (event) => + event.pointerType === "touch" + ? [new PointerActivationConstraints.Delay({ value: 250, tolerance: 5 })] + : [new PointerActivationConstraints.Distance({ value: 4 })], + preventActivation: (event) => event.target instanceof Element && !!event.target.closest("[data-action]"), + }), + ]} + modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]} + plugins={(defaults) => [ + ...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback), + AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }), + Feedback.configure({ dropAnimation: null }), + ]} + onDragEnd={(event) => { + const source = event.operation.source + if (event.canceled || !isSortable(source)) return + if (source.initialIndex !== source.index) props.onMoveProject(props.server, source.id.toString(), source.index) + if (props.selection().server !== ServerConnection.key(props.server)) + props.onSelectProject(props.server, source.id.toString()) + }} + > +
+ {/* Keyed on worktree strings: the enriched project objects are + recreated on every store or sync update, so iterating them directly + remounts all rows — killing any in-flight drag activation (the + row's sortable unregisters on unmount) and discarding animations. + String keys keep row elements alive and move them on reorder. */} + project.worktree)}> + {(worktree, index) => } + +
+
+ ) +} + +function HomeProjectSlot( + props: HomeProjectListProps & { + worktree: string + index: () => number + }, +) { + const initial = props.items.find((item) => item.worktree === props.worktree) + if (!initial) return + const project = createMemo( + (previous) => props.items.find((item) => item.worktree === props.worktree) ?? previous, + initial, + ) + + return ( + + ) +} + +function HomeProjectEmpty( + props: HomeProjectsViewProps & { + server: ServerConnection.Any + items: LocalProject[] + }, +) { + const unreachable = () => props.serverHealth(props.server)?.healthy === false + return ( +
+ props.onChooseProject(props.server)} + > + + {props.language.t("home.project.add")} + + 0}> +
+
{props.language.t("home.recentlyClosed")}
+
+ + {(project) => } + +
+
+ ) +} + +function HomeRecentlyClosedRow( + props: HomeProjectsViewProps & { + project: LocalProject + server: ServerConnection.Any + }, +) { + const unreachable = () => props.serverHealth(props.server)?.healthy === false + const path = () => { + const home = props.homedir() + const worktree = props.project.worktree + if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}` + return worktree + } + return ( + + props.onAddProjects(props.server, [props.project.worktree])} + > + + {displayName(props.project)} + + + ) +} + +function HomeProjectRow( + props: HomeProjectsViewProps & + HomeProjectsContextMenuProps & { + project: LocalProject + server: ServerConnection.Any + index: () => number + serverSelected: boolean + selected: boolean + unseen: number + }, +) { + const platform = usePlatform() + const serverUnreachable = () => props.serverHealth(props.server)?.healthy === false + const sortable = useSortable({ + get id() { + return props.project.worktree + }, + get index() { + return props.index() + }, + }) + let pointerDownSelected: boolean | undefined + const contextMenuID = () => projectContextMenuID(props.server, props.project.worktree) + onCleanup(() => { + const id = contextMenuID() + if (props.contextMenuOpen(id)) props.onSetContextMenuOpen(id, false) + }) + return ( +
+ { + // Same-server mouse selection happens on pointerdown (like tabs), + // but only ever selects; selectProject toggles, and deselecting here + // would fire on every drag before the threshold is met. Cross-server + // selection waits for click so reordering a remote server's projects + // does not focus that server and load its session index. Touch is + // excluded so flick-scrolling the list cannot select rows. + pointerDownSelected = undefined + if (event.button !== 0 || event.pointerType === "touch") return + if (!props.serverSelected) return + pointerDownSelected = props.selected + if (!props.selected) props.onSelectProject(props.server, props.project.worktree) + }} + onClick={(event) => { + // The drag sensor calls preventDefault on post-drag clicks; never + // toggle selection as part of a reorder. + if (event.defaultPrevented) return + // Keyboard activation and touch taps keep the original toggle. + if (event.detail === 0 || pointerDownSelected === undefined) { + props.onSelectProject(props.server, props.project.worktree) + return + } + // Mouse: pointerdown already selected unselected rows; a plain click + // on an already-selected row toggles it off. + if (pointerDownSelected) props.onSelectProject(props.server, props.project.worktree) + pointerDownSelected = undefined + }} + > + + {displayName(props.project)} + +
+ props.onSetContextMenuOpen(contextMenuID(), open)} + > + } + aria-label={props.language.t("common.moreOptions")} + /> + + + props.onOpenProjectNewSession(props.server, props.project.worktree)}> + {props.language.t("command.session.new")} + + props.onEditProject(props.server, props.project)}> + {props.language.t("dialog.project.edit.title")} + + + props.onRevealProject(props.server, props.project)}> + {props.language.t( + fileManagerApp(platform.platform === "desktop" ? (platform.os ?? "unknown") : "unknown") + .actionLabel, + )} + + + props.onClearNotifications(props.server, props.project)} + > + {props.language.t("sidebar.project.clearNotifications")} + + + props.onCloseProject(props.server, props.project.worktree)}> + {props.language.t("common.close")} + + + + + } + aria-label={props.language.t("command.session.new")} + onClick={() => props.onOpenProjectNewSession(props.server, props.project.worktree)} + /> +
+
+ ) +} + +function HomeProjectNavButton(props: JSX.ButtonHTMLAttributes) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + ) +} + +function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) { + const name = createMemo(() => displayName(props.project)) + return ( + + ) +} diff --git a/packages/app/src/pages/home/home-projects.tsx b/packages/app/src/pages/home/home-projects.tsx new file mode 100644 index 0000000000..ff2abf7c16 --- /dev/null +++ b/packages/app/src/pages/home/home-projects.tsx @@ -0,0 +1,40 @@ +import type { HomeProjectsController } from "./home-projects-controller" +import { HomeProjectsView } from "./home-projects-view" +import type { HomeScrollController } from "./home-scroll-controller" + +export function HomeProjects(props: { projects: HomeProjectsController; scroll: HomeScrollController }) { + return ( + + ) +} diff --git a/packages/app/src/pages/home/home-scroll-controller.ts b/packages/app/src/pages/home/home-scroll-controller.ts new file mode 100644 index 0000000000..96b0cf46d3 --- /dev/null +++ b/packages/app/src/pages/home/home-scroll-controller.ts @@ -0,0 +1,145 @@ +import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js" +import { createStore } from "solid-js/store" +import type { HomeSessionGroup } from "./home-sessions-controller" + +const HOME_SESSION_HEADER_STICKY_TOP = 12 +const HOME_SESSION_HEADER_TEXT_HEIGHT = 16 +const HOME_SESSION_HEADER_FADE_DISTANCE = 16 + +export function createHomeScrollController(groups: Accessor) { + const [thumbTrack, setThumbTrack] = createSignal() + const [hoverTarget, setHoverTarget] = createSignal() + const [state, setState] = createStore({ + titleOpacity: {} as Partial>, + }) + const headerRefs = new Map() + const headerOffsets = new Map() + let viewport: HTMLDivElement | undefined + let content: HTMLDivElement | undefined + let positionFrame: number | undefined + let resizeObserver: ResizeObserver | undefined + let stickyTop = HOME_SESSION_HEADER_STICKY_TOP + + createEffect(() => { + const items = groups() + const ids = new Set(items.map((group) => group.id)) + headerRefs.forEach((_, id) => { + if (!ids.has(id)) headerRefs.delete(id) + }) + headerOffsets.forEach((_, id) => { + if (!ids.has(id)) headerOffsets.delete(id) + }) + if (items.length === 0) { + content = undefined + bindResizeObserver() + } + queuePositionUpdate() + }) + + onCleanup(() => { + if (positionFrame !== undefined) cancelAnimationFrame(positionFrame) + resizeObserver?.disconnect() + }) + + function queuePositionUpdate() { + if (typeof requestAnimationFrame === "undefined") { + updatePositionCache() + return + } + if (positionFrame !== undefined) return + positionFrame = requestAnimationFrame(() => { + positionFrame = undefined + updatePositionCache() + }) + } + + function updatePositionCache() { + if (!viewport) return + const header = groups() + .map((group) => headerRefs.get(group.id)) + .find((element) => element !== undefined) + if (header && typeof getComputedStyle === "function") { + const top = Number.parseFloat(getComputedStyle(header).top) + if (Number.isFinite(top)) stickyTop = top + } + groups().forEach((group) => { + const element = headerRefs.get(group.id) + if (element) headerOffsets.set(group.id, element.offsetTop) + }) + update(viewport.scrollTop) + } + + function update(scrollTop: number) { + const items = groups() + items.forEach((group, index) => { + const nextOffset = items + .slice(index + 1) + .map((item) => headerOffsets.get(item.id)) + .find((offset) => offset !== undefined) + const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT + const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop + const opacity = + nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE)) + setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000) + }) + } + + function bindResizeObserver() { + resizeObserver?.disconnect() + if (typeof ResizeObserver === "undefined") return + resizeObserver = new ResizeObserver(queuePositionUpdate) + if (viewport) resizeObserver.observe(viewport) + if (content) resizeObserver.observe(content) + } + + function containWheel(event: WheelEvent) { + if (!viewport) return + if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return + if (!(event.target instanceof Element)) return + const scrollable = event.target.closest("[data-scrollable]") + if ( + scrollable !== viewport && + scrollable && + (event.deltaY < 0 + ? scrollable.scrollTop > 0 + : scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight) + ) + return + event.preventDefault() + } + + return { + viewport: { + thumbTrack, + hoverTarget, + setThumbTrack, + setHoverTarget, + setViewport: (element: HTMLDivElement) => { + viewport = element + bindResizeObserver() + queuePositionUpdate() + }, + update, + containWheel, + containOuterWheel: (event: WheelEvent) => { + if (!viewport) return + if (event.target instanceof Node && viewport.contains(event.target)) return + containWheel(event) + }, + }, + header: { + setContent: (element: HTMLDivElement) => { + content = element + bindResizeObserver() + queuePositionUpdate() + }, + setHeader: (id: HomeSessionGroup["id"], element: HTMLDivElement) => { + headerRefs.set(id, element) + queuePositionUpdate() + }, + titleOpacity: (id: HomeSessionGroup["id"]) => state.titleOpacity[id] ?? 1, + }, + } +} + +export type HomeScrollController = ReturnType diff --git a/packages/app/src/pages/home/home-session-search-controller.ts b/packages/app/src/pages/home/home-session-search-controller.ts new file mode 100644 index 0000000000..5e55c4b746 --- /dev/null +++ b/packages/app/src/pages/home/home-session-search-controller.ts @@ -0,0 +1,114 @@ +import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" +import { serverName } from "@/context/server" +import { displayName } from "@/pages/layout/helpers" +import { makeEventListener } from "@solid-primitives/event-listener" +import { createMemo, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import type { HomeController } from "./home-controller" +import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./home-sessions-controller" + +type HomeSessionSearchSource = Pick + +export function createHomeSessionSearchController(home: HomeController, sessions: HomeSessionSearchSource) { + const command = useCommand() + const language = useLanguage() + const [state, setState] = createStore({ value: "", focused: false, highlighted: "" }) + let root: HTMLDivElement | undefined + let input: HTMLInputElement | undefined + let list: HTMLDivElement | undefined + const query = createMemo(() => state.value.trim()) + const results = createMemo(() => { + const value = query().toLowerCase() + if (!value) return [] + return sessions.data + .searchRecords() + .filter((record) => `${record.session.title} ${record.projectName}`.toLowerCase().includes(value)) + }) + const active = createMemo(() => { + const records = results() + if (records.some((record) => homeSessionSearchKey(record) === state.highlighted)) return state.highlighted + return records[0] ? homeSessionSearchKey(records[0]) : "" + }) + const open = createMemo(() => state.focused && query().length > 0) + const placeholder = createMemo(() => { + const project = home.project.selected() + if (project) return language.t("home.sessions.search.placeholder.scoped", { scope: displayName(project) }) + if (home.server.list().length > 1) { + const conn = home.server.focused() + if (conn) return language.t("home.sessions.search.placeholder.scoped", { scope: serverName(conn) }) + } + return language.t("home.sessions.search.placeholder") + }) + + onCleanup( + makeEventListener(document, "pointerdown", (event) => { + if (!open()) return + const target = event.target + if (!(target instanceof Node) || root?.contains(target)) return + close() + }), + ) + + command.register("home.search", () => [ + { + id: "home.sessions.search.focus", + title: placeholder(), + keybind: "mod+f", + hidden: true, + onSelect: focus, + }, + ]) + + function focus() { + input?.focus() + setState("focused", true) + } + + function close() { + setState({ value: "", focused: false }) + } + + function select(record: HomeSessionRecord, options?: { background?: boolean }) { + sessions.session.open(record.session, options) + if (!options?.background) close() + } + + return { + query: { + value: () => state.value, + placeholder, + open, + focus, + input: (value: string) => setState({ value, highlighted: "" }), + close, + }, + result: { + loading: sessions.data.loading, + list: results, + active, + noResultsLabel: () => language.t("home.sessions.search.noResults", { query: query() }), + highlight: (record: HomeSessionRecord) => setState("highlighted", homeSessionSearchKey(record)), + move: (delta: number) => { + const records = results() + if (records.length === 0) return + const index = records.findIndex((record) => homeSessionSearchKey(record) === active()) + const next = ((index === -1 ? 0 : index) + delta + records.length) % records.length + setState("highlighted", homeSessionSearchKey(records[next])) + list?.querySelector(`[data-key="${state.highlighted}"]`)?.scrollIntoView({ block: "nearest" }) + }, + select, + selectActive: () => { + const record = results().find((item) => homeSessionSearchKey(item) === active()) + if (record) select(record) + }, + }, + element: { + setRoot: (element: HTMLDivElement) => (root = element), + setInput: (element: HTMLInputElement) => (input = element), + setList: (element: HTMLDivElement) => (list = element), + }, + } +} + +export type HomeSessionSearchController = ReturnType diff --git a/packages/app/src/pages/home/home-sessions-controller.tsx b/packages/app/src/pages/home/home-sessions-controller.tsx new file mode 100644 index 0000000000..a12e9800e9 --- /dev/null +++ b/packages/app/src/pages/home/home-sessions-controller.tsx @@ -0,0 +1,320 @@ +import type { Session } from "@opencode-ai/sdk/v2/client" +import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useMarked } from "@opencode-ai/ui/context/marked" +import { useQuery } from "@tanstack/solid-query" +import { DateTime } from "luxon" +import { type Accessor, createEffect, createMemo, createRoot, type JSX, startTransition } from "solid-js" +import { produce } from "solid-js/store" +import { useCommand } from "@/context/command" +import { + loadHomeSessionIndex, + retainHomeSessions, + type HomeSessionEvents, +} from "@/context/global-sync/home-session-index" +import type { LocalProject } from "@/context/layout" +import { useLanguage } from "@/context/language" +import { ServerConnection } from "@/context/server" +import { sessionHasOpenTab, useTabs } from "@/context/tabs" +import { displayName, errorMessage, projectForSession } from "@/pages/layout/helpers" +import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" +import { pathKey } from "@/utils/path-key" +import { showToast } from "@/utils/toast" +import { Binary } from "@opencode-ai/core/util/binary" +import { archiveHomeSession } from "../home-session-archive" +import type { HomeController } from "./home-controller" + +const HOME_SESSION_LIMIT = 64 +export type HomeSessionRecord = { + session: Session + project: LocalProject + projectName: string +} + +export type HomeSessionGroup = { + id: "today" | "yesterday" | "older" + title: string + sessions: HomeSessionRecord[] +} + +export type OpenSessionOptions = { background?: boolean } + +export function createHomeSessionsController(home: HomeController) { + const tabs = useTabs() + const command = useCommand() + const dialog = useDialog() + const language = useLanguage() + const marked = useMarked() + const projectDirectories = createMemo(() => { + const project = home.project.selected() + if (!project) return home.project.list().flatMap(directories) + return directories(project) + }) + const projectByID = createMemo( + () => new Map(home.project.list().flatMap((project) => (project.id ? [[project.id, project] as const] : []))), + ) + const homeSessions = () => home.server.focusedSync().homeSessions + const sessionEventLoad = useQuery(() => ({ + queryKey: homeSessions().eventsKey, + queryFn: async (): Promise => ({ sequence: 0, entries: [] }), + initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents, + enabled: false, + })) + const sessionLoad = useQuery(() => ({ + queryKey: homeSessions().indexKey, + enabled: !!home.server.focusedContext(), + queryFn: async ({ signal }) => { + const ctx = home.server.focusedContext() + if (!ctx) return { sessions: [], eventSequence: 0 } + const cache = homeSessions() + const eventSequence = cache.eventSequence() + const index = await loadHomeSessionIndex( + (input, options) => ctx.sdk.client.v2.session.list(input, options), + eventSequence, + signal, + ) + cache.complete(eventSequence) + return index + }, + retry: false, + staleTime: 30_000, + refetchOnMount: true, + refetchOnReconnect: true, + })) + const indexedSessions = createMemo(() => + retainHomeSessions( + homeSessions().sessions(sessionLoad.data, sessionEventLoad.data), + HOME_SESSION_LIMIT, + Date.now(), + ), + ) + const allRecords = createMemo(() => + buildHomeSessionRecords({ + sessions: indexedSessions, + projectDirectories, + projects: home.project.list, + projectByID, + }), + ) + const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT)) + const groups = createMemo(() => groupSessions(records(), language)) + const prefetched = new Set() + + createEffect(() => { + const ctx = home.server.focusedContext() + const conn = home.server.focused() + if (!ctx || !conn) return + records() + .slice(0, 2) + .forEach((record) => { + const key = `${ServerConnection.key(conn)}\0${record.session.id}` + if (prefetched.has(key)) return + prefetched.add(key) + createRoot((dispose) => { + try { + void ctx.sync.session + .sync(record.session.id) + .then(() => + Promise.all( + (ctx.sync.session.data.message[record.session.id] ?? []).flatMap((message) => + (ctx.sync.session.data.part[message.id] ?? []).flatMap((part) => { + if (part.type !== "text" || !part.text) return [] + return preloadMarkdown(part.text, part.id, marked) + }), + ), + ), + ) + .catch(() => {}) + .finally(dispose) + } catch { + dispose() + } + }) + }) + }) + + command.register("home.palette", () => [ + { + id: "command.palette", + title: language.t("command.palette"), + hidden: true, + onSelect: async () => { + const conn = home.server.focused() + if (!conn) return + const ctx = home.server.focusedContext() + if (!ctx) return + const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2") + void dialog.show(() => ( + { + if (!entry.sessionID || !entry.directory || !entry.server) return + const sessionID = entry.sessionID + const server = entry.server + const directory = entry.project?.worktree ?? entry.directory + ctx.projects.open(directory) + ctx.projects.touch(directory) + void startTransition(() => { + const tab = tabs.addSessionTab({ server, sessionId: sessionID }) + tabs.select(tab) + }) + }} + /> + )) + }, + }, + ]) + + return { + copy: { + language, + }, + data: { + records, + groups, + loading: () => sessionLoad.isLoading, + searchRecords: allRecords, + }, + session: { + showProjectName: () => !home.project.selected(), + server: () => home.selection.value().server, + canCreate: () => !!home.project.newSession(), + create: home.project.openNewSession, + open: (session: Session, options?: OpenSessionOptions) => { + const directoryKey = pathKey(session.directory) + const project = + home.project + .list() + .find( + (item) => + pathKey(item.worktree) === directoryKey || + item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey), + ) ?? projectForSession(session, home.project.list(), projectByID()) + const conn = home.server.focused() + if (!conn) return + const directory = project?.worktree ?? session.directory + const ctx = home.server.focusedContext() + if (!ctx) return + ctx.projects.open(directory) + if (options?.background) { + tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id }) + return + } + ctx.projects.touch(directory) + void startTransition(() => { + const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id }) + tabs.select(tab) + }) + }, + archive: async (session: Session) => { + const conn = home.server.focused() + const ctx = home.server.focusedContext() + if (!conn || !ctx) return + const [, setStore] = ctx.sync.child(session.directory) + if ((await ctx.sdk.protocol) !== "v1") return + await archiveHomeSession({ + server: ServerConnection.key(conn), + session, + archive: (sessionID) => + ctx.sdk.client.session.update({ + sessionID, + directory: session.directory, + time: { archived: Date.now() }, + }), + remove: () => + setStore( + produce((draft) => { + const match = Binary.search(draft.session, session.id, (item) => item.id) + if (match.found) draft.session.splice(match.index, 1) + }), + ), + onError: (cause) => + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(cause, language.t("common.requestFailed")), + }), + }) + }, + }, + tab: { + isOpen: (record: HomeSessionRecord) => + sessionHasOpenTab(tabs.store, home.selection.value().server, record.session), + }, + } +} + +function directories(project: LocalProject) { + return [project.worktree, ...(project.sandboxes ?? [])] +} + +function buildHomeSessionRecords(input: { + sessions: () => Session[] + projectDirectories: () => string[] + projects: () => LocalProject[] + projectByID: () => Map +}) { + const directories = new Set(input.projectDirectories().map(pathKey)) + const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory))) + return [...new Map(sessions.map((session) => [session.id, session] as const)).values()] + .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) + .flatMap((session) => { + const directory = pathKey(session.directory) + const project = + input + .projects() + .find( + (item) => + pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory), + ) ?? projectForSession(session, input.projects(), input.projectByID()) + if (!project) return [] + return { session, project, projectName: displayName(project) } + }) +} + +export function homeSessionSearchKey(record: HomeSessionRecord) { + return `${pathKey(record.session.directory)}:${record.session.id}` +} + +function groupSessions(records: HomeSessionRecord[], language: ReturnType): HomeSessionGroup[] { + const now = DateTime.local() + const yesterday = now.minus({ days: 1 }) + const todaySessions = records.filter((record) => + DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"), + ) + const yesterdaySessions = records.filter((record) => + DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"), + ) + const olderSessions = records.filter((record) => { + const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created) + return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day") + }) + const olderTitle = + todaySessions.length === 0 && yesterdaySessions.length === 0 + ? language.t("sidebar.project.recentSessions") + : language.t("home.sessions.group.older") + return [ + { id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions }, + { id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions }, + { id: "older" as const, title: olderTitle, sessions: olderSessions }, + ].filter((group) => group.sessions.length > 0) +} + +export type HomeSessionsController = ReturnType + +export function HomeSessionStatusController(props: { + server: Accessor + record: HomeSessionRecord + isOpenTab: (record: HomeSessionRecord) => boolean + render: (state: { unread: Accessor; loading: Accessor; open: Accessor }) => JSX.Element +}) { + const avatar = useSessionTabAvatarState( + props.server, + () => props.record.session.directory, + () => props.record.session.id, + ) + return props.render({ + unread: avatar.unread, + loading: avatar.loading, + open: () => props.isOpenTab(props.record), + }) +} diff --git a/packages/app/src/pages/home/home-sessions-view.tsx b/packages/app/src/pages/home/home-sessions-view.tsx new file mode 100644 index 0000000000..72f2f37616 --- /dev/null +++ b/packages/app/src/pages/home/home-sessions-view.tsx @@ -0,0 +1,550 @@ +import type { Session } from "@opencode-ai/sdk/v2/client" +import { type Accessor, createMemo, For, Show, Suspense } from "solid-js" +import { Spinner } from "@opencode-ai/ui/spinner" +import { ScrollView } from "@opencode-ai/ui/scroll-view" +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 { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { useLanguage } from "@/context/language" +import { ServerConnection } from "@/context/server" +import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar" +import { sessionTitle } from "@/utils/session-title" +import { shouldOpenSessionInBackground } from "../home-session-open" +import { + HomeSessionStatusController, + homeSessionSearchKey, + type HomeSessionGroup, + type HomeSessionRecord, + type OpenSessionOptions, +} from "./home-sessions-controller" + +const SHOW_HOME_SESSION_ARCHIVE = false +const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]" +const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results" + +// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session +// tab in the background without navigating, matching browser conventions. +function isBackgroundOpen(event: MouseEvent) { + return shouldOpenSessionInBackground({ + button: event.button, + mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform), + meta: event.metaKey, + ctrl: event.ctrlKey, + shift: event.shiftKey, + alt: event.altKey, + }) +} + +export type HomeSessionsViewProps = { + language: ReturnType + groups: Accessor + showProjectName: Accessor + server: Accessor + canCreateSession: Accessor + searchValue: Accessor + searchPlaceholder: Accessor + searchOpen: Accessor + searchLoading: Accessor + searchResults: Accessor + searchActive: Accessor + searchNoResultsLabel: Accessor + titleOpacity: (id: HomeSessionGroup["id"]) => number + isOpenTab: (record: HomeSessionRecord) => boolean + onCreateSession: () => void + onOpenSession: (session: Session, options?: OpenSessionOptions) => void + onArchiveSession: (session: Session) => Promise + onSetHoverTarget: (element: HTMLElement) => void + onSetThumbTrack: (element: HTMLDivElement) => void + onSetContent: (element: HTMLDivElement) => void + onSetHeader: (id: HomeSessionGroup["id"], element: HTMLDivElement) => void + onWheel: (event: WheelEvent) => void + onSetSearchRoot: (element: HTMLDivElement) => void + onSetSearchInput: (element: HTMLInputElement) => void + onSetSearchList: (element: HTMLDivElement) => void + onSearchFocus: () => void + onSearchInput: (value: string) => void + onSearchClose: () => void + onSearchMove: (delta: number) => void + onSearchSelectActive: () => void + onSearchHighlight: (record: HomeSessionRecord) => void + onSearchSelect: (record: HomeSessionRecord, options?: OpenSessionOptions) => void +} + +export function HomeSessionsView(props: HomeSessionsViewProps) { + return ( +
+
+ + + 0 && props.canCreateSession()}> +
+ + {props.language.t("command.session.new")} + +
+
+
+
+ +
+ ) +} + +function HomeSessionLeadingController(props: { + server: HomeSessionsViewProps["server"] + isOpenTab: HomeSessionsViewProps["isOpenTab"] + record: HomeSessionRecord + revealProjectOnHover: boolean +}) { + return ( + ( + + )} + /> + ) +} + +function HomeSessionLeading(props: { + record: HomeSessionRecord + revealProjectOnHover: boolean + open: boolean + unread: boolean + loading: boolean +}) { + return ( +
+ + + +
+ ) +} + +function HomeSessionSearch(props: HomeSessionsViewProps) { + return ( +
+
+ +
+
+
+ + +
+ } + > + 0} + fallback={ +

+ {props.searchNoResultsLabel()} +

+ } + > +
+

+ {props.language.t("home.sessions.search.sessions")} +

+ +
+ + {(record) => ( + + )} + +
+
+
+
+ +
+
+
+ + +
+
+ ) +} + +function HomeSessionSearchResultRow( + props: HomeSessionsViewProps & { + record: HomeSessionRecord + selected: boolean + }, +) { + const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) + const showProjectName = () => props.showProjectName() && props.record.projectName + const key = () => homeSessionSearchKey(props.record) + + return ( + + ) +} + +function HomeSessionGroupHeader(props: { + title: string + titleOpacity: number + onSetRef: (element: HTMLDivElement) => void + elevated?: boolean +}) { + return ( +
+ +
+ ) +} + +function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) { + const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) + const showProjectName = () => props.showProjectName() && props.record.projectName + + return ( +
+ + +
+ + } + aria-label={props.language.t("common.archive")} + onClick={(event) => { + event.preventDefault() + event.stopPropagation() + void props.onArchiveSession(props.record.session) + }} + /> + +
+
+
+ ) +} + +function HomeSessionTitle(props: { title: string; showProjectName: boolean; search?: boolean }) { + return ( + + {props.title} + + ) +} + +function HomeSessionProjectName(props: { name: string; search?: boolean }) { + return ( + + {props.name} + + ) +} + +function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnType }) { + return ( +
+
+ {props.language.t("home.sessions.empty")} +
+

+ {props.language.t("home.sessions.empty.description")} +

+ + {(onNewSession) => ( + + {props.language.t("command.session.new")} + + )} + +
+ ) +} + +function HomeSessionSkeleton(props: { label: string }) { + return ( +
+
+ +
+ + ) +} diff --git a/packages/app/src/pages/home/home-sessions.tsx b/packages/app/src/pages/home/home-sessions.tsx new file mode 100644 index 0000000000..7bda4dd363 --- /dev/null +++ b/packages/app/src/pages/home/home-sessions.tsx @@ -0,0 +1,47 @@ +import type { HomeScrollController } from "./home-scroll-controller" +import type { HomeSessionSearchController } from "./home-session-search-controller" +import type { HomeSessionsController } from "./home-sessions-controller" +import { HomeSessionsView } from "./home-sessions-view" + +export function HomeSessions(props: { + sessions: HomeSessionsController + search: HomeSessionSearchController + scroll: HomeScrollController +}) { + return ( + + ) +} diff --git a/packages/app/src/pages/home/legacy-home.tsx b/packages/app/src/pages/home/legacy-home.tsx new file mode 100644 index 0000000000..0556f7cf60 --- /dev/null +++ b/packages/app/src/pages/home/legacy-home.tsx @@ -0,0 +1,142 @@ +import { DialogSelectServer } from "@/components/dialog-select-server" +import { useDirectoryPicker } from "@/components/directory-picker" +import { useGlobal } from "@/context/global" +import { useLanguage } from "@/context/language" +import { type ServerConnection, useServer } from "@/context/server" +import { useServerSync } from "@/context/server-sync" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Icon } from "@opencode-ai/ui/icon" +import { Logo } from "@opencode-ai/ui/logo" +import { useNavigate } from "@solidjs/router" +import { DateTime } from "luxon" +import { createMemo, For, Match, Switch } from "solid-js" + +export function LegacyHome() { + const sync = useServerSync() + const pickDirectory = useDirectoryPicker() + const dialog = useDialog() + const navigate = useNavigate() + const global = useGlobal() + const server = useServer() + const language = useLanguage() + const homedir = createMemo(() => sync().data.path.home) + const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false) + const recent = createMemo(() => { + return sync() + .data.project.slice() + .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) + .slice(0, 5) + }) + + const serverDotClass = createMemo(() => { + const healthy = global.servers.health[server.key]?.healthy + if (healthy === true) return "bg-icon-success-base" + if (healthy === false) return "bg-icon-critical-base" + return "bg-border-weak-base" + }) + + function openProject(conn: ServerConnection.Any, directory: string) { + const serverCtx = global.ensureServerCtx(conn) + serverCtx.projects.open(directory) + serverCtx.projects.touch(directory) + navigate(`/${base64Encode(directory)}`) + } + + function chooseProject() { + if (serverUnreachable()) return + const conn = server.current + if (!conn) return + + const resolve = (result: string | string[] | null) => { + if (Array.isArray(result)) { + result.forEach((directory) => openProject(conn, directory)) + return + } + if (result) openProject(conn, result) + } + + pickDirectory({ + server: conn, + title: language.t("command.project.open"), + multiple: true, + onSelect: resolve, + }) + } + + return ( +
+ + + + 0}> +
+
+
{language.t("home.recentProjects")}
+ +
+
    + + {(project) => ( + + )} + +
+
+
+ +
+
{language.t("common.loading")}
+ +
+
+ +
+ +
+
{language.t("home.empty.title")}
+
{language.t("home.empty.description")}
+
+ +
+
+
+
+ ) +} diff --git a/packages/app/src/pages/layout-new.tsx b/packages/app/src/pages/layout-new.tsx index fd0df68d27..6d76ca8401 100644 --- a/packages/app/src/pages/layout-new.tsx +++ b/packages/app/src/pages/layout-new.tsx @@ -1,16 +1,14 @@ import { createEffect, Suspense, type ParentProps } from "solid-js" -import { useNavigate } from "@solidjs/router" +import { createStore } from "solid-js/store" import { DebugBar } from "@/components/debug-bar" import { TabsInfoPopup } from "@/components/help-button" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" import { usePlatform } from "@/context/platform" -import { setNavigate } from "@/utils/notification-click" import { setV2Toast, ToastRegion } from "@/utils/toast" export default function NewLayout(props: ParentProps) { const platform = usePlatform() - const navigate = useNavigate() - setNavigate(navigate) + const [state, setState] = createStore({ debugTools: true }) createEffect(() => setV2Toast(true)) @@ -32,11 +30,18 @@ export default function NewLayout(props: ParentProps) { "padding-bottom": "env(safe-area-inset-bottom, 0px)", }} > - + setState("debugTools", (value) => !value) } + : undefined + } + />
{props.children}
- {import.meta.env.DEV && } + {import.meta.env.DEV && state.debugTools && }
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 545eee827a..963a542f88 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -33,9 +33,9 @@ import { createStore, produce, reconcile } from "solid-js/store" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" import { useProviders } from "@/hooks/use-providers" -import { toaster } from "@opencode-ai/ui/toast" -import { setV2Toast, showToast, ToastRegion } from "@/utils/toast" +import { dismissToast, setV2Toast, showToast, ToastRegion } from "@/utils/toast" import { useServerSDK } from "@/context/server-sdk" +import { normalizeProjectInfo } from "@/context/global-sync/utils" import { clearWorkspaceTerminals } from "@/context/terminal" import { pickSessionCacheEvictions } from "@/context/global-sync/session-cache" import { useNotification } from "@/context/notification" @@ -44,10 +44,10 @@ import { Binary } from "@opencode-ai/core/util/binary" import { retry } from "@opencode-ai/core/util/retry" import { playSoundById } from "@/utils/sound" import { createAim } from "@/utils/aim" -import { setNavigate } from "@/utils/notification-click" import { Worktree as WorktreeState } from "@/utils/worktree" import { setSessionHandoff } from "@/pages/session/handoff" import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope" +import { listAllSessions } from "@/utils/session" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" @@ -116,8 +116,7 @@ export default function LegacyLayout(props: ParentProps) { const notification = useNotification() const permission = usePermission() const navigate = useNavigate() - setNavigate(navigate) - const providers = useProviders() + const providers = useProviders(() => undefined) const dialog = useDialog() const command = useCommand() const theme = useTheme() @@ -156,6 +155,7 @@ export default function LegacyLayout(props: ParentProps) { sizing: false, peek: undefined as string | undefined, peeked: false, + debugTools: true, }) const updateVersion = () => { @@ -381,7 +381,7 @@ export default function LegacyLayout(props: ParentProps) { const dismissSessionAlert = (sessionKey: string) => { const toastId = toastBySession.get(sessionKey) if (toastId === undefined) return - toaster.dismiss(toastId) + dismissToast(toastId) toastBySession.delete(sessionKey) alertedAtBySession.delete(sessionKey) } @@ -446,13 +446,13 @@ export default function LegacyLayout(props: ParentProps) { void playSoundById(settings.sounds.permissions()) } if (settings.notifications.permissions()) { - void platform.notify(title, description, href) + void platform.notify(title, description, () => navigate(href)) } } if (e.details.type === "question.asked") { if (settings.notifications.agent()) { - void platform.notify(title, description, href) + void platform.notify(title, description, () => navigate(href)) } } @@ -869,14 +869,15 @@ export default function LegacyLayout(props: ParentProps) { } async function archiveSession(session: Session) { + if ((await serverSDK().protocol) !== "v1") return const [store, setStore] = serverSync().child(session.directory) const sessions = store.session ?? [] const index = sessions.findIndex((s) => s.id === session.id) const nextSession = sessions[index + 1] ?? sessions[index - 1] await serverSDK().client.session.update({ - directory: session.directory, sessionID: session.id, + directory: session.directory, time: { archived: Date.now() }, }) setStore( @@ -1184,9 +1185,12 @@ export default function LegacyLayout(props: ParentProps) { } const refreshDirs = async (target?: string) => { if (!target || target === root || canOpen(target)) return canOpen(target) - const listed = await serverSDK() - .client.worktree.list({ directory: root }) - .then((x) => x.data ?? []) + const listed = await Promise.resolve( + project?.id ?? serverSDK().api.project.current({ location: { directory: root } }), + ) + .then((value) => (typeof value === "string" ? value : value.id)) + .then((projectID) => serverSDK().api.project.directories({ projectID, location: { directory: root } })) + .then((items) => items.map((item) => item.directory).filter((item) => pathKey(item) !== pathKey(root))) .catch(() => [] as string[]) dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root]) return canOpen(target) @@ -1230,10 +1234,11 @@ export default function LegacyLayout(props: ParentProps) { await Promise.all( dirs.map(async (item) => ({ path: { directory: item }, - session: await serverSDK() - .client.session.list({ directory: item }) - .then((x) => x.data ?? []) - .catch(() => []), + session: await listAllSessions(serverSDK().api.session, { + directory: item, + parentID: null, + order: "desc", + }).catch(() => []), })), ), Date.now(), @@ -1293,7 +1298,16 @@ export default function LegacyLayout(props: ParentProps) { const name = next === getFilename(project.worktree) ? "" : next if (project.id && project.id !== "global") { - await serverSDK().client.project.update({ projectID: project.id, directory: project.worktree, name }) + const sdk = serverSDK() + if ((await sdk.protocol) !== "v1") return + const result = await sdk.client.project + .update({ projectID: project.id, directory: project.worktree, name }) + .then((response) => response.data) + if (!result) return + // const result = await serverSDK().api.project.update({ projectID: project.id, name }) + serverSync().set("project", (items) => + items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)), + ) return } @@ -1442,12 +1456,9 @@ export default function LegacyLayout(props: ParentProps) { title: language.t("workspace.resetting.title"), description: language.t("workspace.resetting.description"), }) - const dismiss = () => toaster.dismiss(progress) + const dismiss = () => dismissToast(progress) - const sessions: Session[] = await serverSDK() - .client.session.list({ directory }) - .then((x) => x.data ?? []) - .catch(() => []) + const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => []) clearWorkspaceTerminals( directory, @@ -1476,20 +1487,20 @@ export default function LegacyLayout(props: ParentProps) { return } - const archivedAt = Date.now() - await Promise.all( - sessions - .filter((session) => session.time.archived === undefined) - .map((session) => - serverSDK() - .client.session.update({ - sessionID: session.id, - directory: session.directory, - time: { archived: archivedAt }, - }) - .catch(() => undefined), - ), - ) + if ((await serverSDK().protocol) === "v1") + await Promise.all( + sessions + .filter((session) => session.time.archived === undefined) + .map((session) => + serverSDK() + .client.session.update({ + sessionID: session.id, + directory: session.directory, + time: { archived: Date.now() }, + }) + .catch(() => undefined), + ), + ) setBusy(directory, false) dismiss() @@ -1523,9 +1534,9 @@ export default function LegacyLayout(props: ParentProps) { onMount(() => { serverSDK() - .client.vcs.status({ directory: props.directory }) - .then((x) => { - const files = x.data ?? [] + .api.vcs.status({ location: { directory: props.directory } }) + .then((result) => { + const files = result.data const dirty = files.length > 0 setData({ status: "ready", dirty }) }) @@ -1581,19 +1592,19 @@ export default function LegacyLayout(props: ParentProps) { }) const refresh = async () => { - const sessions = await serverSDK() - .client.session.list({ directory: props.directory }) - .then((x) => x.data ?? []) - .catch(() => []) + const sessions = await listAllSessions(serverSDK().api.session, { + directory: props.directory, + order: "desc", + }).catch(() => []) const active = sessions.filter((session) => session.time.archived === undefined) setState({ sessions: active }) } onMount(() => { serverSDK() - .client.vcs.status({ directory: props.directory }) - .then((x) => { - const files = x.data ?? [] + .api.vcs.status({ location: { directory: props.directory } }) + .then((result) => { + const files = result.data const dirty = files.length > 0 setState({ status: "ready", dirty }) void refresh() @@ -2238,7 +2249,7 @@ export default function LegacyLayout(props: ParentProps) { settingsKeybind={() => command.keybind("settings.open")} onOpenSettings={openSettings} helpLabel={() => language.t("sidebar.help")} - onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} + onOpenHelp={() => platform.openExternal("https://opencode.ai/desktop-feedback")} renderPanel={() => mobile ? : } @@ -2248,7 +2259,14 @@ export default function LegacyLayout(props: ParentProps) { return (
{autoselecting() ?? ""} - + setState("debugTools", (value) => !value) } + : undefined + } + /> @@ -2393,7 +2411,7 @@ export default function LegacyLayout(props: ParentProps) {
- {import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && } + {import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && state.debugTools && }
@@ -2429,7 +2447,7 @@ function UpdateAvailableToast(props: { onCleanup(() => { if (toastId === undefined) return - toaster.dismiss(toastId) + dismissToast(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 8e5dc38d67..236f6bd405 100644 --- a/packages/app/src/pages/layout/project-avatar-state.ts +++ b/packages/app/src/pages/layout/project-avatar-state.ts @@ -13,7 +13,6 @@ 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() @@ -22,9 +21,10 @@ 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,9 +34,11 @@ export function useSessionTabAvatarState( return !!sessionQuestionRequest(store.session, serverSync.session.data.question, sessionId()) }) const needsAttention = createMemo(() => hasPermissions() || hasQuestions()) - const unread = createMemo( - () => needsAttention() || notification.ensureServerState(server()).session.unseenCount(sessionId()) > 0, - ) + const notificationState = createMemo(() => { + if (!connection()) return + return notification.ensureServerState(server()) + }) + const unread = createMemo(() => needsAttention() || (notificationState()?.session.unseenCount(sessionId()) ?? 0) > 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 3c776c8671..0902173643 100644 --- a/packages/app/src/pages/layout/session-tab-avatar.tsx +++ b/packages/app/src/pages/layout/session-tab-avatar.tsx @@ -19,16 +19,34 @@ 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 projectControls = createPromptProjectControls() - const projectController = createPromptProjectController({ - controls: projectControls, - onDone: () => inputRef?.focus(), - }) - - 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 workspace = createNewSessionWorkspaceController() + const draft = createNewSessionDraftController({ + worktree: workspace.selection.value, + resetWorktree: workspace.selection.reset, }) - 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() + const project = createPromptProjectController({ + controls: draft.project.controls, + onDone: draft.input.restoreFocus, + }) + useNewSessionCommands({ + restoreFocus: draft.input.restoreFocus, + project: { + empty: project.empty, + open: () => project.setOpen(true), + }, }) - 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 }) - }) - }) - - createEffect(() => { - if (!prompt.ready()) return - requestAnimationFrame(() => inputRef?.focus()) + if (!draft.prompt.ready()) return + draft.input.restoreFocus() }) const ready = Promise.resolve() - const [promptReady] = createResource( - () => prompt.ready.promise ?? ready, + const [suspendUntilPromptReady] = createResource( + () => draft.prompt.readyPromise() ?? ready, (promise) => promise.then(() => true), ) return (
- - {(mount) => ( - - - - - - - - )} - + {suspendUntilPromptReady()} +
-
-
- -
- - {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 new file mode 100644 index 0000000000..bf22834e48 --- /dev/null +++ b/packages/app/src/pages/new-session/new-session-draft-controller.ts @@ -0,0 +1,64 @@ +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 new file mode 100644 index 0000000000..5960e64335 --- /dev/null +++ b/packages/app/src/pages/new-session/new-session-view.tsx @@ -0,0 +1,162 @@ +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 new file mode 100644 index 0000000000..2a79ae77fa --- /dev/null +++ b/packages/app/src/pages/new-session/new-session-workspace-controller.test.ts @@ -0,0 +1,43 @@ +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 new file mode 100644 index 0000000000..f3fc9b2708 --- /dev/null +++ b/packages/app/src/pages/new-session/new-session-workspace-controller.ts @@ -0,0 +1,77 @@ +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 new file mode 100644 index 0000000000..a0d835f97f --- /dev/null +++ b/packages/app/src/pages/new-session/use-new-session-commands.tsx @@ -0,0 +1,44 @@ +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 cfa5498c2e..c6e3a5fcbb 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -6,6 +6,7 @@ import { batch, ErrorBoundary, onCleanup, + Suspense, Show, Match, Switch, @@ -41,6 +42,7 @@ 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" @@ -57,6 +59,7 @@ 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" @@ -362,6 +365,7 @@ 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() @@ -529,7 +533,6 @@ 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({ @@ -665,7 +668,8 @@ export default function Page() { const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") const wantsReview = createMemo(() => isDesktop() - ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") + ? desktopFileTreeOpen() || + (desktopReviewOpen() && (activeTab() === "review" || (newSessionDesign() && !!activeFileTab()))) : store.mobileTab === "changes", ) const vcsMode = createMemo(() => { @@ -686,8 +690,8 @@ export default function Page() { queryFn: mode ? () => sdk() - .client.vcs.diff({ mode }) - .then((result) => list(result.data)) + .api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode }) + .then((result) => result.data) .catch((error) => { console.debug("[session-review] failed to load vcs diff", { mode, error }) return [] @@ -734,8 +738,12 @@ export default function Page() { retry: 2, queryFn: () => sdk() - .client.vcs.diff({ mode, directory: scope, context }) - .then((result) => result.data ?? []), + .api.vcs.diff({ + location: { directory: scope }, + mode: mode === "git" ? "working" : mode, + context, + }) + .then((result) => result.data), }) .then((diffs) => diffs.find((diff) => diff.file === file)) @@ -942,10 +950,11 @@ export default function Page() { ) const stopVcs = sdk().event.listen((evt) => { - if (evt.details.type !== "file.watcher.updated") return + const details = evt.details as { type: string; properties?: unknown } + if (details.type !== "file.watcher.updated" && details.type !== "filesystem.changed") return const props = - typeof evt.details.properties === "object" && evt.details.properties - ? (evt.details.properties as Record) + typeof details.properties === "object" && details.properties + ? (details.properties as Record) : undefined const file = typeof props?.file === "string" ? props.file : undefined if (!file || file.startsWith(".git/")) return @@ -1135,6 +1144,14 @@ 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, @@ -1452,44 +1469,6 @@ 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 @@ -1745,7 +1724,7 @@ export default function Page() { setFollowup("failed", input.sessionID, undefined) const ok = await sendFollowupDraft({ - client: sdk().client, + api: sdk().api.session, sync: sync(), serverSync: serverSync(), draft: item, @@ -1841,13 +1820,13 @@ export default function Page() { const halt = (sessionID: string) => busy(sessionID) ? sdk() - .client.session.abort({ sessionID }) + .api.session.interrupt({ sessionID }) .catch(() => {}) : Promise.resolve() const revertMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; messageID: string }) => { - const client = sdk().client + const session = sdk().api.session const target = sync() const last = target.session.get(input.sessionID)?.revert const value = draft(input.messageID) @@ -1857,10 +1836,8 @@ export default function Page() { roll(input.sessionID, { messageID: input.messageID }, target) prompt.set(value) }, - request: () => halt(input.sessionID).then(() => client.session.revert(input)), - complete: (result) => { - if (result.data) merge(result.data, target) - }, + request: () => halt(input.sessionID).then(() => session.revert.stage(input)), + complete: () => undefined, rollback: () => roll(input.sessionID, last, target), fail, }) @@ -1872,7 +1849,7 @@ export default function Page() { const sessionID = params.id if (!sessionID) return - const client = sdk().client + const session = sdk().api.session const target = sync() const next = userMessages().find((item) => item.id > id) const last = target.session.get(sessionID)?.revert @@ -1889,11 +1866,9 @@ export default function Page() { }, request: () => !next - ? 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) - }, + ? halt(sessionID).then(() => session.revert.clear({ sessionID })) + : halt(sessionID).then(() => session.revert.stage({ sessionID, messageID: next.id }).then(() => undefined)), + complete: () => undefined, rollback: () => roll(sessionID, last, target), fail, }) @@ -2035,83 +2010,6 @@ 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) => (
- {(_) => 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 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 + }} + + } + /> + ) + }} +
{mobileTabs(true, true)} ) @@ -2287,49 +2298,53 @@ 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 3ead9599bb..a9b0070bc0 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" +import type { PromptInputControls } from "@/components/prompt-input/contracts" import type { PromptProjectControls } from "@/components/prompt-project-selector" import { useDirectoryPicker } from "@/components/directory-picker" import { useGlobal } from "@/context/global" @@ -12,7 +12,6 @@ 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" @@ -26,36 +25,39 @@ export function createPromptInputController(input: { }) { const layout = useLayout() const local = useLocal() - const providers = useProviders() - const settings = useSettings() - const sync = useSync() const sdk = useSDK() + const sync = useSync() + const providers = useProviders(() => sdk().directory) 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(() => ({ - 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: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading, - }, - session: { - id: input.sessionID(), - tabs: layout.tabs(input.sessionKey), - reviewPanel: view.reviewPanel, - }, - newLayoutDesigns: settings.general.newLayoutDesigns(), - })) + 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, + }, + } + }) } 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 45f5e4cb26..f54e0c9e4f 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() - .client.permission.respond({ sessionID: perm.sessionID, permissionID: perm.id, response }) + .api.permission.reply({ sessionID: perm.sessionID, requestID: perm.id, reply: 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 445a9f47a0..941424e247 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -223,7 +223,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } const replyMutation = useMutation(() => ({ - mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }), + mutationFn: (answers: QuestionAnswer[]) => + sdk().api.question.reply({ sessionID: props.request.sessionID, requestID: props.request.id, answers }), onMutate: () => { props.onSubmit() }, @@ -235,7 +236,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit })) const rejectMutation = useMutation(() => ({ - mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }), + mutationFn: () => sdk().api.question.reject({ sessionID: props.request.sessionID, 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 dd030870a1..ce3fd26b02 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,5 +1,6 @@ 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" @@ -78,12 +79,17 @@ 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 f68428fcda..d44b490262 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -8,8 +8,10 @@ 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" @@ -48,6 +50,7 @@ export function SessionTodoDock(props: { dockProgress: number }) { const language = useLanguage() + const settings = useSettings() const [store, setStore] = createStore({ height: 78, }) @@ -91,8 +94,13 @@ 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 61cfaf931b..41d1f984bd 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,7 +71,6 @@ 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 044ebda7ae..b67b810a4b 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -24,6 +24,12 @@ 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 @@ -207,8 +213,9 @@ export function FileTabContent(props: { tab: string }) { ) } -export function SessionFileView(props: { tab: string }) { +export function SessionFileView(props: SessionFileViewProps) { const settings = useSettings() + return ( }> @@ -548,10 +555,10 @@ function SessionFileViewV2(props: { tab: string }) { }) } - const buildPreview = (filePath: string, selection: FileSelection) => { + const buildPreview = (filePath: string, lines: SelectedLineRange) => { const source = filePath === path() ? contents() : file.get(filePath)?.content?.content if (!source) return undefined - return selectionPreview(source, selection) + return selectionPreview(source, selectionFromLines(lines)) } const addCommentToContext = (input: { @@ -562,7 +569,7 @@ function SessionFileViewV2(props: { tab: string }) { origin?: "review" | "file" }) => { const selection = selectionFromLines(input.selection) - const preview = input.preview ?? buildPreview(input.file, selection) + const preview = input.preview ?? buildPreview(input.file, input.selection) const saved = comments.add({ file: input.file, @@ -587,7 +594,7 @@ function SessionFileViewV2(props: { tab: string }) { comment: string }) => { comments.update(input.file, input.id, input.comment) - const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined + const preview = input.file === path() ? buildPreview(input.file, input.selection) : undefined prompt.context.updateComment(input.file, input.id, { comment: input.comment, ...(preview ? { preview } : {}), @@ -628,7 +635,7 @@ function SessionFileViewV2(props: { tab: string }) { mention: { items: file.searchFilesAndDirectories, }, - getSide: (range) => range.endSide ?? range.side ?? "additions", + getSide: selectionSide, state: { opened: () => note.openedComment, setOpened: (id) => setNote("openedComment", id), @@ -757,7 +764,7 @@ function SessionFileViewV2(props: { tab: string }) { commentsUi.onLineNumberSelectionEnd(range) }} search={search} - class="select-text [--opencode-diffs-bg:var(--v2-background-bg-base)]" + class="select-text" 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 3854bf0276..1b65af7121 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -1,6 +1,7 @@ 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, @@ -14,7 +15,7 @@ import type { LineComment } from "@/context/comments" export type DiffStyle = "unified" | "split" -type ReviewDiff = SnapshotFileDiff | VcsFileDiff +type ReviewDiff = FileDiffInfo | 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 184fa684ec..6f074fa203 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -24,6 +24,7 @@ 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" @@ -56,15 +57,17 @@ 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 RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff +type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff +type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff +const FILE_TREE_WIDTH_MIN = 240 -function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { +function renderDiff(value: ReviewDiff): value is RenderDiff { return typeof value.file === "string" } export function SessionSidePanel(props: { canReview: () => boolean - diffs: () => (SnapshotFileDiff | VcsFileDiff)[] + diffs: () => ReviewDiff[] diffsReady: () => boolean empty: () => string hasReview: () => boolean @@ -102,13 +105,14 @@ 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 `${layout.fileTree.width()}px` + return `${fileTreeWidth()}px` }) - const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) + const treeWidth = createMemo(() => (fileOpen() ? `${fileTreeWidth()}px` : "0px")) const diffs = createMemo(() => props.diffs().filter(renderDiff)) const diffFiles = createMemo(() => diffs().map((d) => d.file)) @@ -237,6 +241,7 @@ 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, }) @@ -550,7 +555,7 @@ export function SessionSidePanel(props: { > {(toggle) => ( -
+
{toggle()(activeTab() === SESSION_OPEN_FILE_TAB)}
)} @@ -570,9 +575,15 @@ export function SessionSidePanel(props: { + {language.t("common.closeTab")} + 0}> + + + + } placement="bottom" gutter={10} > @@ -583,7 +594,7 @@ export function SessionSidePanel(props: { onClick={() => tabs().close("context")} aria-label={language.t("common.closeTab")} /> - + } hideCloseButton onMiddleClick={() => tabs().close("context")} @@ -611,9 +622,15 @@ export function SessionSidePanel(props: { + {language.t("common.closeTab")} + 0}> + + + + } placement="bottom" gutter={10} > @@ -624,7 +641,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)} @@ -830,8 +847,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 2959e9f8a9..eca1541cea 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -283,6 +283,14 @@ 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 @@ -325,6 +333,7 @@ export function MessageTimeline(props: { const projection = createTimelineProjection({ messages: sessionMessages, userMessages: () => props.userMessages, + sessionMessages: projectedMessages, parts: getMsgParts, status: sessionStatus, showReasoningSummaries: settings.general.showReasoningSummaries, @@ -637,7 +646,7 @@ export function MessageTimeline(props: { const viewShare = () => { const url = shareUrl() if (!url) return - platform.openLink(url) + platform.openExternal(url) } const errorMessage = (err: unknown) => { @@ -665,7 +674,7 @@ export function MessageTimeline(props: { const titleMutation = useMutation(() => ({ mutationFn: (input: { id: string; title: string }) => - sdk().client.session.update({ sessionID: input.id, title: input.title }), + sdk().api.session.rename({ sessionID: input.id, title: input.title }), onSuccess: (_, input) => { sync().set( produce((draft) => { @@ -803,13 +812,14 @@ 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, time: { archived: Date.now() } }) + .client.session.update({ sessionID, directory: sdk().directory, time: { archived: Date.now() } }) .then(() => { sync().set( produce((draft) => { @@ -838,8 +848,8 @@ export function MessageTimeline(props: { const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) const result = await sdk() - .client.session.delete({ sessionID }) - .then((x) => x.data) + .api.session.remove({ sessionID }) + .then(() => true) .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 ea8ea4f132..e30c936d73 100644 --- a/packages/app/src/pages/session/timeline/projection.ts +++ b/packages/app/src/pages/session/timeline/projection.ts @@ -1,16 +1,15 @@ -import { Binary } from "@opencode-ai/core/util/binary" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2" -import { createMemo, mapArray, type Accessor } from "solid-js" +import { createMemo, 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 @@ -30,47 +29,20 @@ export function createTimelineProjection(input: { }) return result }) - 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 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(() => projection().activeMessageID) const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => - reuseTimelineRows( - previous, - messageRowMemos().flatMap((memo) => memo()), - ), + reuseTimelineRows(previous, projection().rows), ) 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 new file mode 100644 index 0000000000..a5952a5e66 --- /dev/null +++ b/packages/app/src/pages/session/timeline/rows-current.test.ts @@ -0,0 +1,207 @@ +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 72cd9a180c..879646e86a 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -1,7 +1,9 @@ import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" -import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" +import { AssistantMessage, Part, SessionStatus, 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" @@ -30,6 +32,71 @@ 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[], @@ -49,7 +116,8 @@ 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 error = assistantMessages.find((m) => m.error && m.error.name !== "MessageAbortedError")?.error + const latestError = assistantMessages.at(-1)?.error + const error = latestError?.name === "MessageAbortedError" ? undefined : latestError const assistantPartRefs = assistantMessages.flatMap((message, messageIndex) => getMessageParts(message.id) @@ -137,14 +205,7 @@ export namespace Timeline { if (isActive && status === "retry") rows.push(new TimelineRow.Retry({ userMessageID: userMessage.id })) - 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() + const diffs = uniqueSummaryDiffs(userMessage.summary?.diffs) if (diffs.length > 0 && (status === "idle" || !isActive)) { rows.push( new TimelineRow.DiffSummary({ @@ -169,10 +230,6 @@ 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 new file mode 100644 index 0000000000..9b66bf6771 --- /dev/null +++ b/packages/app/src/pages/session/timeline/summary-diffs.test.ts @@ -0,0 +1,42 @@ +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 new file mode 100644 index 0000000000..2df37af5a6 --- /dev/null +++ b/packages/app/src/pages/session/timeline/summary-diffs.ts @@ -0,0 +1,20 @@ +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-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 8ad6cb6072..12dd96a5e6 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -5,7 +5,6 @@ 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" @@ -19,6 +18,7 @@ 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,7 +40,6 @@ 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() @@ -48,6 +47,7 @@ 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 client = sdk().client + const session = sdk().api.session 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 client.session.abort({ sessionID }).catch(() => {}) + await session.interrupt({ sessionID }).catch(() => {}) } await runCommand({ owner, prompt: promptSession, - request: () => client.session.revert({ sessionID, messageID: message.id }), + request: () => session.revert.stage({ 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 client = sdk().client + const session = sdk().api.session const messages = userMessages() const promptSession = prompt.capture() @@ -346,7 +346,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { await runCommand({ owner, prompt: promptSession, - request: () => client.session.unrevert({ sessionID }), + request: () => session.revert.clear({ 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: () => client.session.revert({ sessionID, messageID: next.id }), + request: () => session.revert.stage({ sessionID, messageID: next.id }), updatePrompt: () => undefined, updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)), }) @@ -375,10 +375,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => { return } - await sdk().client.session.summarize({ + await sdk().api.session.compact({ sessionID, - modelID: model.id, - providerID: model.provider.id, + model: { providerID: model.provider.id, modelID: model.id }, }) } @@ -468,7 +467,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { id: "file.open", title: language.t("command.file.open"), description: language.t("palette.search.placeholder"), - keybind: "mod+k,mod+p", + keybind: "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 49cec334bc..d3adb1f2ff 100644 --- a/packages/app/src/pages/session/v2/review-diff-kinds.ts +++ b/packages/app/src/pages/session/v2/review-diff-kinds.ts @@ -1,14 +1,15 @@ 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 = (SnapshotFileDiff & { file: string }) | VcsFileDiff +export type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff export function normalizePath(p: string) { return normalizeFileTreeV2Path(p) } -export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { +export function filterRenderableDiff(value: FileDiffInfo | 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 4f0cf612e1..fcd6bbb79f 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -1,5 +1,6 @@ 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, @@ -30,7 +31,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 = SnapshotFileDiff | VcsFileDiff +type ReviewDiff = FileDiffInfo | 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 5fbca469b7..a3d25f4279 100644 --- a/packages/app/src/utils/diffs.test.ts +++ b/packages/app/src/utils/diffs.test.ts @@ -1,5 +1,6 @@ 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" @@ -9,7 +10,7 @@ const item = { additions: 1, deletions: 1, status: "modified", -} satisfies SnapshotFileDiff +} satisfies FileDiffInfo & SnapshotFileDiff describe("diffs", () => { test("keeps valid arrays", () => { diff --git a/packages/app/src/utils/diffs.ts b/packages/app/src/utils/diffs.ts index 0cb2504fbe..a8eec75a9a 100644 --- a/packages/app/src/utils/diffs.ts +++ b/packages/app/src/utils/diffs.ts @@ -1,7 +1,8 @@ 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 = SnapshotFileDiff | VcsFileDiff +type Diff = FileDiffInfo | 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 new file mode 100644 index 0000000000..cd0895f52c --- /dev/null +++ b/packages/app/src/utils/draft-store.ts @@ -0,0 +1,171 @@ +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 new file mode 100644 index 0000000000..0a3009eb71 --- /dev/null +++ b/packages/app/src/utils/menu-dismiss-controller.ts @@ -0,0 +1,30 @@ +/** 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 deleted file mode 100644 index fa81b0e025..0000000000 --- a/packages/app/src/utils/notification-click.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 316b278206..0000000000 --- a/packages/app/src/utils/notification-click.ts +++ /dev/null @@ -1,13 +0,0 @@ -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 a2daae4866..a7742c399e 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -15,6 +15,7 @@ type PersistedWithReady = [ ] type PersistTarget = { + draft?: boolean storage?: string scope?: "window" legacyStorageNames?: string[] @@ -295,6 +296,14 @@ 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 @@ -513,6 +522,9 @@ 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 { @@ -526,9 +538,12 @@ function resolveTarget(target: PersistTarget, platform: Platform): PersistTarget } export function removePersisted( - target: { storage?: string; legacyStorageNames?: string[]; key: string }, + target: { draft?: boolean; 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) { @@ -561,8 +576,17 @@ 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) @@ -577,7 +601,7 @@ export function persisted( const legacyStorageNames = config.legacyStorageNames ?? [] const storage = (() => { - if (!isDesktop) { + if (!isDesktop && !draft) { const current = currentStorage as SyncStorage const legacyStore = legacyStorage as SyncStorage const legacyStores = legacyStorageNames.map(localStorageWithPrefix) @@ -609,15 +633,26 @@ export function persisted( const current = currentStorage as AsyncStorage const legacyStore = legacyStorage as AsyncStorage | undefined - const legacyStores = legacyStorageNames - .map((name) => platform.storage?.(name) 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))), + ] .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 - return migrateLegacyAsync({ + const migrated = await migrateLegacyAsync({ current, legacyStore, stores: legacyStores, @@ -626,8 +661,15 @@ 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 1ecaf02c97..8b86d43b90 100644 --- a/packages/app/src/utils/prompt.test.ts +++ b/packages/app/src/utils/prompt.test.ts @@ -37,8 +37,18 @@ 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", dataUrl: "data:image/png;base64,AAA" }, - { type: "image", filename: "b.pdf", mime: "application/pdf", dataUrl: "data:application/pdf;base64,BBB" }, + { + 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) }), + }, ]) }) }) diff --git a/packages/app/src/utils/prompt.ts b/packages/app/src/utils/prompt.ts index 35aec0071a..67d32086bb 100644 --- a/packages/app/src/utils/prompt.ts +++ b/packages/app/src/utils/prompt.ts @@ -1,5 +1,6 @@ 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 = | { @@ -107,7 +108,7 @@ export function extractPromptFromParts(parts: Part[], opts?: { directory?: strin id: filePart.id, filename: filePart.filename ?? attachmentName, mime: filePart.mime, - dataUrl: filePart.url, + blob: createLegacyBlobReference(filePart.url), }) } } diff --git a/packages/app/src/utils/server-compat.test.ts b/packages/app/src/utils/server-compat.test.ts new file mode 100644 index 0000000000..52e5ec6e3b --- /dev/null +++ b/packages/app/src/utils/server-compat.test.ts @@ -0,0 +1,236 @@ +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 new file mode 100644 index 0000000000..1df1338b71 --- /dev/null +++ b/packages/app/src/utils/server-compat.ts @@ -0,0 +1,518 @@ +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 b1c8f2c7e2..69a8c7b3be 100644 --- a/packages/app/src/utils/server-health.test.ts +++ b/packages/app/src/utils/server-health.test.ts @@ -14,15 +14,45 @@ function abortFromInput(input: RequestInfo | URL, init?: RequestInit) { describe("checkServerHealth", () => { test("returns healthy response with version", async () => { - const fetch = (async () => - new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + 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" }), { 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 () => { @@ -142,7 +172,7 @@ describe("checkServerHealth", () => { retryDelayMs: 1, }) - expect(count).toBe(3) + expect(count).toBe(6) expect(result).toEqual({ healthy: false }) }) }) diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts index 1b684d9af7..1d7d9e4b2e 100644 --- a/packages/app/src/utils/server-health.ts +++ b/packages/app/src/utils/server-health.ts @@ -1,6 +1,7 @@ import { usePlatform } from "@/context/platform" import { ServerConnection } from "@/context/server" -import { createSdkForServer } from "./server" +import { authTokenFromCredentials, createSdkForServer } from "./server" +import { ClientError, OpenCode } from "@opencode-ai/client" import { Accessor, createEffect, onCleanup } from "solid-js" import { createStore, reconcile } from "solid-js/store" @@ -61,6 +62,7 @@ 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 @@ -82,15 +84,31 @@ export async function checkServerHealth( .then(() => attempt(count + 1)) .catch(() => ({ healthy: false })) } - const attempt = (count: number): Promise => - createSdkForServer({ - server, + const attempt = async (count: number): Promise => { + const current = await OpenCode.make({ + baseUrl: server.url, fetch, - signal, + headers: server.password + ? { + Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`, + } + : undefined, }) + .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 new file mode 100644 index 0000000000..2130a968c4 --- /dev/null +++ b/packages/app/src/utils/server-protocol.test.ts @@ -0,0 +1,40 @@ +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 new file mode 100644 index 0000000000..27b8dc208e --- /dev/null +++ b/packages/app/src/utils/server-protocol.ts @@ -0,0 +1,35 @@ +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 603784e4d4..1c8292ca9d 100644 --- a/packages/app/src/utils/server.ts +++ b/packages/app/src/utils/server.ts @@ -1,4 +1,5 @@ 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" @@ -39,3 +40,23 @@ 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 new file mode 100644 index 0000000000..a69c414e16 --- /dev/null +++ b/packages/app/src/utils/session-message.test.ts @@ -0,0 +1,214 @@ +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 new file mode 100644 index 0000000000..93d86a66bb --- /dev/null +++ b/packages/app/src/utils/session-message.ts @@ -0,0 +1,358 @@ +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 new file mode 100644 index 0000000000..b15c23b660 --- /dev/null +++ b/packages/app/src/utils/session.test.ts @@ -0,0 +1,94 @@ +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 new file mode 100644 index 0000000000..faf847967b --- /dev/null +++ b/packages/app/src/utils/session.ts @@ -0,0 +1,37 @@ +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 5fa1506b1e..aac854ca82 100644 --- a/packages/app/src/utils/terminal-websocket-url.test.ts +++ b/packages/app/src/utils/terminal-websocket-url.test.ts @@ -2,8 +2,28 @@ import { describe, expect, test } from "bun:test" import { terminalWebSocketURL } from "./terminal-websocket-url" describe("terminalWebSocketURL", () => { - test("uses query auth without embedding credentials in websocket URL", () => { + test("uses the current ticketed PTY route", () => { 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", @@ -16,11 +36,14 @@ 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", () => { + test("omits query auth for same-origin saved credentials for v1", () => { const url = terminalWebSocketURL({ + protocol: "v1", url: "https://app.example.test", id: "pty_test", directory: "/tmp/project", @@ -31,11 +54,14 @@ 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", () => { + test("uses query auth for same-origin credentials from auth_token for v1", () => { const url = terminalWebSocketURL({ + protocol: "v1", url: "https://app.example.test", id: "pty_test", directory: "/tmp/project", @@ -47,6 +73,8 @@ 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 06facdc7d2..a32b239cc9 100644 --- a/packages/app/src/utils/terminal-websocket-url.ts +++ b/packages/app/src/utils/terminal-websocket-url.ts @@ -1,6 +1,7 @@ import { authTokenFromCredentials } from "@/utils/server" export function terminalWebSocketURL(input: { + protocol?: "v1" | "v2" url: string id: string directory: string @@ -11,18 +12,24 @@ export function terminalWebSocketURL(input: { password?: string authToken?: boolean }) { - const next = new URL(`${input.url}/pty/${input.id}/connect`) - next.searchParams.set("directory", input.directory) + 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) + } 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 (input.password && (!input.sameOrigin || input.authToken)) + if (isV1 && 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 e444548508..6f23b63d1e 100644 --- a/packages/app/src/utils/toast.tsx +++ b/packages/app/src/utils/toast.tsx @@ -1,6 +1,12 @@ import { Icon, type IconProps } from "@opencode-ai/ui/icon" -import { Toast, showToast as showLegacyToast, type ToastOptions, type ToastVariant } from "@opencode-ai/ui/toast" -import { ToastV2, showToastV2 } from "@opencode-ai/ui/v2/toast-v2" +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" let v2 = false @@ -27,6 +33,13 @@ 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 new file mode 100644 index 0000000000..6a74834fd0 --- /dev/null +++ b/packages/app/test-browser/command-palette.test.ts @@ -0,0 +1,78 @@ +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 ca7686598e..49dad279cd 100644 --- a/packages/app/test-browser/prompt-attachments.test.ts +++ b/packages/app/test-browser/prompt-attachments.test.ts @@ -1,7 +1,10 @@ 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 () => { @@ -85,6 +88,88 @@ 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 a7b08078d7..f3d8f8ae4f 100644 --- a/packages/app/test-browser/prompt-persistence.test.ts +++ b/packages/app/test-browser/prompt-persistence.test.ts @@ -2,6 +2,7 @@ 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 @@ -30,7 +31,7 @@ beforeAll(async () => { }), })) mock.module("@/context/platform", () => ({ - usePlatform: () => ({ platform: "desktop", storage: () => storage }), + usePlatform: () => ({ platform: "desktop", storage: () => storage, draftStore: storage }), })) Prompt = await import("@/context/prompt") @@ -69,3 +70,50 @@ 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 61b5c6a14f..5777c9c392 100644 --- a/packages/app/test-browser/prompt-transient-state.test.ts +++ b/packages/app/test-browser/prompt-transient-state.test.ts @@ -18,7 +18,6 @@ test("resets transient prompt input state when the prompt session changes", () = draggingType: "image", mode: "shell", applyingHistory: true, - variantOpen: true, }) setIdentity("B") @@ -33,7 +32,6 @@ 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 new file mode 100644 index 0000000000..75f0b75543 --- /dev/null +++ b/packages/app/test-browser/settings-keybinds.test.ts @@ -0,0 +1,107 @@ +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 new file mode 100644 index 0000000000..5795fa5648 --- /dev/null +++ b/packages/app/test-browser/solid-router-cleanup.test.ts @@ -0,0 +1,64 @@ +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 104a505851..25ba5c000e 100644 --- a/packages/app/test-browser/toast-owner.test.ts +++ b/packages/app/test-browser/toast-owner.test.ts @@ -1,8 +1,46 @@ -import { describe, expect, test } from "bun:test" +import { beforeEach, 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 new file mode 100644 index 0000000000..bc1b664f82 Binary files /dev/null and b/packages/app/vendor/opencode-ai-client-1.17.13-v2.tgz differ diff --git a/packages/cli/package.json b/packages/cli/package.json index e59c972b25..be5d853de0 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.2", + "version": "1.18.11", "type": "module", "license": "MIT", "bin": { diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index 55869a6295..f42d8b07b0 100755 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -1,7 +1,6 @@ #!/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" @@ -51,10 +50,6 @@ 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, @@ -68,7 +63,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", parserWorker], + entrypoints: ["./src/index.ts"], tsconfig: "./tsconfig.json", plugins: [plugin], external: ["node-gyp"], @@ -94,10 +89,6 @@ 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 d98565e298..e162f2ea7e 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.dev" +const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.opencode.ai" 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 e6dd1b1ab5..1641518483 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.2", + "version": "1.18.11", "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 9f3123cef5..5023021661 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.2", + "version": "1.18.11", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/app/src/app.tsx b/packages/console/app/src/app.tsx index 99b1010767..1f1d0066ec 100644 --- a/packages/console/app/src/app.tsx +++ b/packages/console/app/src/app.tsx @@ -9,7 +9,6 @@ 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() @@ -34,7 +33,6 @@ export default function App() { {props.children} - diff --git a/packages/console/app/src/component/desktop-promo.tsx b/packages/console/app/src/component/desktop-promo.tsx index 165f4fc690..504088e3c7 100644 --- a/packages/console/app/src/component/desktop-promo.tsx +++ b/packages/console/app/src/component/desktop-promo.tsx @@ -7,12 +7,17 @@ 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" @@ -36,7 +41,14 @@ export function DesktopPromo() { -
diff --git a/packages/console/app/src/routes/go/index.css b/packages/console/app/src/routes/go/index.css index 45b61d217e..bcf7c896a4 100644 --- a/packages/console/app/src/routes/go/index.css +++ b/packages/console/app/src/routes/go/index.css @@ -327,6 +327,37 @@ 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; @@ -492,6 +523,12 @@ 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] { @@ -628,8 +665,8 @@ body { stroke: none; } - [data-bar][data-kind="free"] { - fill: var(--color-text-strong); + [data-bar][data-kind="promo"] { + fill: color-mix(in srgb, var(--bar-go) 50%, transparent); } [data-val] { @@ -652,11 +689,6 @@ 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); @@ -1028,28 +1060,47 @@ 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: 12px 16px; + padding: 8px 12px; + border-bottom: 1px solid var(--color-border-weak); 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 960099fe49..17c78f214d 100644 --- a/packages/console/app/src/routes/go/index.tsx +++ b/packages/console/app/src/routes/go/index.tsx @@ -23,20 +23,25 @@ const checkLoggedIn = query(async () => { }, "checkLoggedIn.get") const models = [ - { 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" }, -] + { 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 function LimitsGraph(props: { href: string }) { let root!: HTMLElement @@ -59,15 +64,17 @@ function LimitsGraph(props: { href: string }) { onCleanup(() => observer.disconnect()) }) - const free = 200 + const baseline = 100 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" }, ] @@ -79,15 +86,13 @@ function LimitsGraph(props: { href: string }) { const bottom = 44 const plot = w - left - right - const ratio = (n: number) => n / free + const ratio = (n: number) => n / baseline 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 start = (x(1) / w) * 100 - - const ticks = [1, 5, 10, 25, 50, 100].filter((t) => t <= rmax) + const ticks = [1, 5, 10, 25, 50, 100, 250].filter((t) => t <= rmax) const labels = (() => { const set = new Set() let last = -Infinity @@ -108,10 +113,8 @@ function LimitsGraph(props: { href: string }) { const bh = 8 const gap = 20 const step = bh + gap - 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 gy = (i: number) => top + 22 + step * i + const h = gy(graph.length - 1) + bottom 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}%` @@ -121,10 +124,9 @@ function LimitsGraph(props: { href: string }) { return (
- - {(t) => ( - - - - )} - + {(t) => } - - - - {(m, i) => ( + {m.baseReq && ( + + )} )} @@ -170,9 +173,6 @@ 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 3882be027e..a769ae01bb 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("treats only non-attachment source ranges as inline references", () => { + test("keeps data-backed file mentions inline", () => { expect( inline( file({ @@ -34,18 +34,16 @@ describe("message-file", () => { ), ).toBe(true) - 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) + 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) }) 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 81ce97827f..e09269f33f 100644 --- a/packages/session-ui/src/components/message-file.ts +++ b/packages/session-ui/src/components/message-file.ts @@ -3,11 +3,10 @@ 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:") + return part.url.startsWith("data:") && !inline(part) } 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 2063507241..f4e8f68981 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(--icon-weak-base); + background-color: var(--v2-icon-icon-muted); 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 601d7839ef..e63b5b5685 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -62,7 +62,6 @@ 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" @@ -521,6 +520,7 @@ export function getToolInfo( } } case "bash": + case "shell": return { icon: "console", title: i18n.t("ui.tool.shell"), @@ -538,6 +538,7 @@ 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", @@ -581,29 +582,18 @@ function urls(text: string | undefined) { }) } -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 sessionLink(id: string | undefined, href?: (id: string) => string | undefined) { + if (!id) return undefined + return href?.(id) } function taskSession( input: Record, - path: string, + parentID: string | undefined, sessions: Session[] | undefined, agents?: readonly { name: string; color?: string }[], ) { - const parentID = currentSession(path) - if (!parentID) return + if (!parentID) return undefined const description = typeof input.description === "string" ? input.description : "" const agent = taskAgent(input.subagent_type, agents).name return (sessions ?? []) @@ -729,8 +719,8 @@ export function renderable(part: PartType, showReasoningSummaries = true) { } function toolDefaultOpen(tool: string, shell = false, edit = false) { - if (tool === "bash") return shell - if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit + if (tool === "bash" || tool === "shell") return shell + if (tool === "edit" || tool === "write" || tool === "patch" || tool === "apply_patch") return edit } export function partDefaultOpen(part: PartType, shell = false, edit = false) { @@ -1506,7 +1496,7 @@ export function registerTool(input: { name: string; render?: ToolComponent }) { } export function getTool(name: string) { - return state[name]?.render + return state[name === "apply_patch" ? "patch" : name === "bash" ? "shell" : name]?.render } export const ToolRegistry = { @@ -1573,7 +1563,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { }) const taskHref = createMemo(() => { if (part().tool !== "task") return - return sessionLink(taskId(), useLocation().pathname, data.sessionHref) + return sessionLink(taskId(), data.sessionHref) }) const taskSubtitle = createMemo(() => { if (part().tool !== "task") return undefined @@ -1612,6 +1602,14 @@ 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) + }} /> ) }} @@ -1992,11 +1990,10 @@ 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, location.pathname, data.store.session, data.store.agent) + return taskSession(props.input, data.sessionID, 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")) @@ -2013,18 +2010,13 @@ ToolRegistry.register({ }) const running = createMemo(() => props.status === "pending" || props.status === "running") - const href = createMemo(() => sessionLink(childSessionId(), location.pathname, data.sessionHref)) + const href = createMemo(() => sessionLink(childSessionId(), data.sessionHref)) const clickable = createMemo(() => !!(childSessionId() && (data.navigateToSession || href()))) const open = () => { const id = childSessionId() if (!id) return - if (data.navigateToSession) { - data.navigateToSession(id) - return - } - const value = href() - if (value) window.location.assign(value) + data.navigateToSession?.(id) } const navigate = (event: MouseEvent) => { @@ -2101,7 +2093,7 @@ ToolRegistry.register({ }) ToolRegistry.register({ - name: "bash", + name: "shell", render(props) { const i18n = useI18n() const pending = () => props.status === "pending" || props.status === "running" @@ -2126,13 +2118,14 @@ ToolRegistry.register({ (
- +
@@ -2336,7 +2329,7 @@ ToolRegistry.register({ }) ToolRegistry.register({ - name: "apply_patch", + name: "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 48e8eee310..2fbd022235 100644 --- a/packages/session-ui/src/components/session-diff.ts +++ b/packages/session-ui/src/components/session-diff.ts @@ -1,6 +1,7 @@ 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 @@ -13,7 +14,7 @@ type LegacyDiff = { } type SnapshotDiff = SnapshotFileDiff & { file: string } -type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff +type ReviewDiff = SnapshotDiff | FileDiffInfo | 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 8db21f025b..1585a8aa32 100644 --- a/packages/session-ui/src/components/session-review.tsx +++ b/packages/session-ui/src/components/session-review.tsx @@ -16,6 +16,7 @@ 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" @@ -62,10 +63,10 @@ export type SessionReviewCommentActions = { export type SessionReviewFocus = { file: string; id: string } -type RawReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { +type RawReviewDiff = (SnapshotFileDiff | FileDiffInfo | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } -type ReviewDiff = ((SnapshotFileDiff & { file: string }) | VcsFileDiff) & { +type ReviewDiff = ((SnapshotFileDiff & { file: string }) | FileDiffInfo | 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 75274fc50e..301a74d3f0 100644 --- a/packages/session-ui/src/components/session-turn.tsx +++ b/packages/session-ui/src/components/session-turn.tsx @@ -4,6 +4,7 @@ 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" @@ -90,7 +91,7 @@ function list(value: T[] | undefined | null, fallback: T[]) { return fallback } -type SummaryDiff = SnapshotFileDiff & { file: string } +type SummaryDiff = (SnapshotFileDiff & { file: string }) | FileDiffInfo 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 35720a2753..2251641857 100644 --- a/packages/session-ui/src/components/tool-error-card.tsx +++ b/packages/session-ui/src/components/tool-error-card.tsx @@ -16,6 +16,7 @@ export interface ToolErrorCardProps extends Omit, "c onOpenChange?: (open: boolean) => void subtitle?: string href?: string + onSubtitleClick?: (event: MouseEvent) => void } export function ToolErrorCard(props: ToolErrorCardProps) { @@ -35,6 +36,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) { "onOpenChange", "subtitle", "href", + "onSubtitleClick", ]) const setOpen = (value: boolean) => { if (props.open === undefined) setState("open", value) @@ -51,6 +53,8 @@ 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", } @@ -111,7 +115,10 @@ export function ToolErrorCard(props: ToolErrorCardProps) { data-slot="basic-tool-tool-subtitle" class="clickable subagent-link" href={split.href!} - onClick={(e) => e.stopPropagation()} + onClick={(event) => { + event.stopPropagation() + split.onSubtitleClick?.(event) + }} > {subtitle()} diff --git a/packages/session-ui/src/context/data.tsx b/packages/session-ui/src/context/data.tsx index 999ff510d5..76eacb3336 100644 --- a/packages/session-ui/src/context/data.tsx +++ b/packages/session-ui/src/context/data.tsx @@ -1,4 +1,5 @@ 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" @@ -21,7 +22,7 @@ type Data = { [sessionID: string]: SessionStatus } session_diff: { - [sessionID: string]: SnapshotFileDiff[] + [sessionID: string]: (SnapshotFileDiff | FileDiffInfo)[] } session_diff_preload?: { [sessionID: string]: PreloadMultiFileDiffResult[] @@ -46,6 +47,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ init: (props: { data: Data directory: string + sessionID?: string onNavigateToSession?: NavigateToSessionFn onSessionHref?: SessionHrefFn }) => { @@ -56,6 +58,9 @@ 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 new file mode 100644 index 0000000000..3b8173d105 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/attachments.css @@ -0,0 +1,44 @@ +@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 new file mode 100644 index 0000000000..89f38ec4a3 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/attachments.ts @@ -0,0 +1,278 @@ +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 new file mode 100644 index 0000000000..d29fd4df23 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx @@ -0,0 +1,714 @@ +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 new file mode 100644 index 0000000000..f4a9fa74b7 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/interaction.ts @@ -0,0 +1,482 @@ +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 new file mode 100644 index 0000000000..e36982666e --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/machine.test.ts @@ -0,0 +1,164 @@ +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 new file mode 100644 index 0000000000..d2508e249c --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/machine.ts @@ -0,0 +1,261 @@ +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 new file mode 100644 index 0000000000..6c535b0284 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/prompt-input.stories.tsx @@ -0,0 +1,221 @@ +// @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 new file mode 100644 index 0000000000..768a222450 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/store.test.ts @@ -0,0 +1,116 @@ +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 new file mode 100644 index 0000000000..5a27de6c4d --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/store.ts @@ -0,0 +1,152 @@ +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 new file mode 100644 index 0000000000..c6b47a92ce --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/types.ts @@ -0,0 +1,106 @@ +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 3e7bd83051..ba276a8f52 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,6 +7,7 @@ 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" @@ -27,7 +28,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 }) | VcsFileDiff +type ReviewDiff = (SnapshotFileDiff & { file: string }) | FileDiffInfo | VcsFileDiff export type SessionReviewFilePreviewV2Props = { file: string @@ -263,7 +264,9 @@ 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 dccc5e2538..704e8fd7d7 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 8px 8px; + padding: 12px 16px 12px 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 8px; + padding: 0 8px 4px; flex-shrink: 0; min-width: 0; box-sizing: border-box; @@ -109,10 +109,71 @@ [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: 0 8px 12px; + padding: 4px 8px 12px; } [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] .scroll-view__thumb { @@ -149,7 +210,7 @@ align-items: center; justify-content: space-between; gap: 8px; - padding: 8px 12px; + padding: 10px 12px; padding-left: 8px; flex-shrink: 0; border-bottom: 1px solid var(--border-weaker-base, var(--v2-border-border-weak)); @@ -243,16 +304,8 @@ color: var(--v2-icon-icon-base); } -[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="icon-button-v2"][data-variant="ghost"].session-review-v2-sidebar-toggle[aria-expanded="true"] { + background-color: var(--v2-background-bg-layer-02); } [data-component="session-review-v2"] [data-slot="session-review-v2-file-header"] { @@ -273,9 +326,37 @@ flex: 1; } -[data-component="session-review-v2"] [data-slot="session-review-v2-file-header"] [data-component="diff-changes"] { +[data-component="session-review-v2"] [data-slot="session-review-v2-file-diff"] { + position: relative; 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 811df52e1a..d2f3b6b8a6 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-v2.tsx @@ -139,6 +139,7 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) { min={minWidth()} max={maxWidth()} onResize={(next) => props.onWidthChange?.(next)} + onDblClick={() => props.onWidthChange?.(SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT)} />
@@ -164,15 +165,13 @@ export function SessionReviewV2(props: SessionReviewV2Props) { } const prev = () => { - const files = props.files - if (files.length === 0) return - return files[(fileIndex() - 1 + files.length) % files.length] + if (!canCycle()) return + return props.files[(fileIndex() - 1 + props.files.length) % props.files.length] } const next = () => { - const files = props.files - if (files.length === 0) return - return files[(fileIndex() + 1) % files.length] + if (!canCycle()) return + return props.files[(fileIndex() + 1) % props.files.length] } const canCycle = () => props.files.length > 0 @@ -186,16 +185,18 @@ export function SessionReviewV2(props: SessionReviewV2Props) { props.onSelectFile(file) } - // The prev/next tooltips advertise < and >; keep the keys working while the + // Keep the advertised arrow 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 !== "<" && event.key !== ">") return + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") 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(event.key === "<" ? prev() : next()) + cycle(file) }) const toolbarStart = () => ( @@ -216,10 +217,11 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
{i18n.t("ui.sessionReviewV2.previousFile")} - + } > @@ -228,17 +230,18 @@ export function SessionReviewV2(props: SessionReviewV2Props) { variant="ghost" size="small" class="session-review-v2-file-nav-button" - disabled={!canCycle()} + disabled={!prev()} onClick={() => cycle(prev())} aria-label={i18n.t("ui.sessionReviewV2.previousFile")} /> {i18n.t("ui.sessionReviewV2.nextFile")} - "]} variant="neutral" /> + } > @@ -247,7 +250,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) { variant="ghost" size="small" class="session-review-v2-file-nav-button" - disabled={!canCycle()} + disabled={!next()} onClick={() => cycle(next())} aria-label={i18n.t("ui.sessionReviewV2.nextFile")} /> @@ -330,6 +333,7 @@ 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 224dc34c2f..46d7d185e3 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.18.2", + "version": "1.18.11", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index c1270fda3e..a04d6761a7 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.2", + "version": "1.18.11", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/app/src/app.tsx b/packages/stats/app/src/app.tsx index e8dc678af1..c23c7e7934 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 "./app.css" +import "./routes/index.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 3043ea1e80..d02723e461 100644 --- a/packages/stats/app/src/component/model-compare-detail.tsx +++ b/packages/stats/app/src/component/model-compare-detail.tsx @@ -1,4 +1,3 @@ -import "../routes/index.css" import { Link, Meta, Title } from "@solidjs/meta" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { @@ -263,9 +262,8 @@ export default function ModelCompareDetailPage(props: ModelCompareDetailPageProp }} /> -
@@ -276,6 +274,7 @@ 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 f3c33edde0..40a0ac9c50 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -1,4 +1,3 @@ -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 da88b05fcd..01bb74c039 100644 --- a/packages/stats/app/src/routes/[lab]/index.tsx +++ b/packages/stats/app/src/routes/[lab]/index.tsx @@ -1,4 +1,3 @@ -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 5c9b758cc1..d7403809ac 100644 --- a/packages/stats/app/src/routes/compare-radar.tsx +++ b/packages/stats/app/src/routes/compare-radar.tsx @@ -86,26 +86,10 @@ export function ComparisonRadar(props: ComparisonRadarProps) { {(model) => ( - - {(points) => } - - - - {(connection) => ( - - )} - - + {(score, index) => { - if (score === undefined) return null - const point = () => radarPoint(index(), axes().length, score) + const point = () => radarPoint(index(), axes().length, score ?? 0) return ( <> @@ -341,27 +325,12 @@ 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 39877f670a..7aa5cbe4c7 100644 --- a/packages/stats/app/src/routes/compare/index.tsx +++ b/packages/stats/app/src/routes/compare/index.tsx @@ -1,4 +1,3 @@ -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 f60108ddae..b55c3153f2 100644 --- a/packages/stats/app/src/routes/index.css +++ b/packages/stats/app/src/routes/index.css @@ -42,6 +42,11 @@ 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; @@ -5321,10 +5326,8 @@ [data-page="stats"] [data-component="comparison-card"] b { min-width: 0; - overflow: hidden; font: inherit; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: anywhere; } [data-page="stats"] [data-component="comparison-card"] i { @@ -5772,9 +5775,7 @@ } [data-page="stats"] [data-slot="compare-home-select-name"] { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: anywhere; } [data-page="stats"] [data-slot="compare-home-select-panel"][data-selected="true"] [data-slot="compare-home-plus"] { @@ -5805,10 +5806,10 @@ [data-page="stats"] [data-component="compare-home-card"] { position: relative; display: grid; - grid-template-rows: 62px 1px 20px 20px; + grid-template-rows: 62px 1px auto 20px; gap: 16px; min-width: 0; - height: 184px; + min-height: 184px; box-sizing: border-box; padding: 16px; overflow: hidden; @@ -5890,10 +5891,11 @@ [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; - white-space: nowrap; + overflow-wrap: anywhere; } [data-page="stats"] [data-slot="compare-home-card-models"] span { @@ -6076,6 +6078,7 @@ 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); @@ -6113,11 +6116,9 @@ [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; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: anywhere; } [data-page="stats"] [data-slot="compare-radar-legend"] strong { @@ -6273,7 +6274,7 @@ white-space: nowrap; } -[data-page="stats"] [data-component="compare-detail-table"] { +[data-page="stats"] [data-component="compare-detail-comparison"] { --compare-detail-label-column: 292px; --compare-detail-model-column-min: 360px; position: relative; @@ -6330,10 +6331,10 @@ } [data-page="stats"] - [data-component="compare-detail-table"][data-model-count="3"] + [data-component="compare-detail-comparison"][data-model-count="3"] [data-section="compare-detail-selector"], [data-page="stats"] - [data-component="compare-detail-table"][data-model-count="3"] + [data-component="compare-detail-comparison"][data-model-count="3"] [data-section="compare-detail-matrix"] { min-width: calc( var(--compare-detail-label-column) + var(--compare-detail-model-column-min) + @@ -6342,10 +6343,10 @@ } [data-page="stats"] - [data-component="compare-detail-table"][data-model-count="4"] + [data-component="compare-detail-comparison"][data-model-count="4"] [data-section="compare-detail-selector"], [data-page="stats"] - [data-component="compare-detail-table"][data-model-count="4"] + [data-component="compare-detail-comparison"][data-model-count="4"] [data-section="compare-detail-matrix"] { min-width: calc( var(--compare-detail-label-column) + var(--compare-detail-model-column-min) + @@ -6355,10 +6356,10 @@ } [data-page="stats"] - [data-component="compare-detail-table"][data-model-count="5"] + [data-component="compare-detail-comparison"][data-model-count="5"] [data-section="compare-detail-selector"], [data-page="stats"] - [data-component="compare-detail-table"][data-model-count="5"] + [data-component="compare-detail-comparison"][data-model-count="5"] [data-section="compare-detail-matrix"] { min-width: calc( var(--compare-detail-label-column) + var(--compare-detail-model-column-min) + @@ -6368,10 +6369,10 @@ } [data-page="stats"] - [data-component="compare-detail-table"][data-model-count="6"] + [data-component="compare-detail-comparison"][data-model-count="6"] [data-section="compare-detail-selector"], [data-page="stats"] - [data-component="compare-detail-table"][data-model-count="6"] + [data-component="compare-detail-comparison"][data-model-count="6"] [data-section="compare-detail-matrix"] { min-width: calc( var(--compare-detail-label-column) + var(--compare-detail-model-column-min) + @@ -6427,13 +6428,11 @@ [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; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: anywhere; } [data-page="stats"] button[data-slot="compare-detail-select-model"] > svg { @@ -6663,7 +6662,7 @@ aspect-ratio: 1; } - [data-page="stats"] [data-component="compare-detail-table"] { + [data-page="stats"] [data-component="compare-detail-comparison"] { --compare-detail-label-column: 220px; --compare-detail-model-column-min: 320px; } @@ -6754,7 +6753,7 @@ margin-left: -6px; } - [data-page="stats"] [data-component="compare-detail-table"] { + [data-page="stats"] [data-component="compare-detail-comparison"] { --compare-detail-label-column: 188px; --compare-detail-model-column-min: 246px; } @@ -6909,13 +6908,11 @@ [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; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: anywhere; } [data-page="stats"] [data-slot="compare-model-modal-badge"] { @@ -6972,13 +6969,11 @@ [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; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: anywhere; } [data-page="stats"] [data-slot="compare-model-modal-description"] { @@ -7427,13 +7422,15 @@ @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"] { - flex: 1 1 0; + min-width: 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 7d882597bd..7debf5c30b 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -1,4 +1,3 @@ -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 efd6f6b2e0..47fa1cf347 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.dev/catalog.json" -export const modelCatalogPricingUrl = "https://models.dev/api.json" -export const modelCatalogLabSourceUrl = "https://models.dev/labs" +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 type ModelCatalogCost = { input: number diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index 4d40971d76..e02d8e1314 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.2", + "version": "1.18.11", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index a5875742c7..9eb222ec80 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.2", + "version": "1.18.11", "private": true, "type": "module", "license": "MIT", diff --git a/packages/storybook/.storybook/main.ts b/packages/storybook/.storybook/main.ts index f592863e56..3fb9dae29a 100644 --- a/packages/storybook/.storybook/main.ts +++ b/packages/storybook/.storybook/main.ts @@ -47,6 +47,8 @@ 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 0c7ff8bbdd..0741f7e4ef 100644 --- a/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx +++ b/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx @@ -1,9 +1,8 @@ -import { splitProps } from "solid-js" +import { splitProps, type JSX } from "solid-js" -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 function ModelSelectorPopover(props: { trigger: (props: Record) => JSX.Element }) { + const [local] = splitProps(props, ["trigger"]) + return <>{local.trigger({})} } 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 673019f2f0..16ff08bef4 100644 --- a/packages/storybook/.storybook/mocks/app/context/command.ts +++ b/packages/storybook/.storybook/mocks/app/context/command.ts @@ -22,5 +22,8 @@ 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 2510acc6f9..081dc26bfd 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": "Run a shell command... {{example}}", + "prompt.placeholder.shell": "Enter shell command... {{example}}", "prompt.placeholder.summarizeComment": "Summarize this comment", "prompt.placeholder.summarizeComments": "Summarize these comments", "prompt.action.attachFile": "Attach files", @@ -22,6 +22,69 @@ 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", @@ -73,6 +136,7 @@ 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 74233cd1e5..bab923bff5 100644 --- a/packages/storybook/.storybook/mocks/app/context/platform.ts +++ b/packages/storybook/.storybook/mocks/app/context/platform.ts @@ -2,13 +2,10 @@ import type { Platform } from "../../../../../app/src/context/platform" const value: Platform = { platform: "web", - openLink() {}, + openExternal() {}, 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 new file mode 100644 index 0000000000..d0026dcff8 --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/server-sdk.ts @@ -0,0 +1,47 @@ +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 new file mode 100644 index 0000000000..1e835afdb8 --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/server-sync.ts @@ -0,0 +1,33 @@ +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 6f554fd7dd..1942927a3e 100644 --- a/packages/storybook/.storybook/mocks/app/context/sync.ts +++ b/packages/storybook/.storybook/mocks/app/context/sync.ts @@ -12,6 +12,8 @@ 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 caa5e759a0..64ada6421b 100644 --- a/packages/storybook/.storybook/mocks/app/hooks/use-providers.ts +++ b/packages/storybook/.storybook/mocks/app/hooks/use-providers.ts @@ -13,6 +13,7 @@ export const popularProviders = [ const provider = { id: "anthropic", + name: "Anthropic", models: { [model_id]: { id: model_id, @@ -23,12 +24,33 @@ 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: () => [provider], + all: () => new Map(catalog.map((item) => [item.id, item])), default: () => ({ anthropic: model_id }), connected: () => [provider], paid: () => [provider], - popular: () => [provider], + popular: () => popular, } } diff --git a/packages/storybook/package.json b/packages/storybook/package.json index f7089bafa3..e611ed6ba8 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -21,6 +21,7 @@ "@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 a669208df8..e1bc700ac7 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.2", + "version": "1.18.11", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index 5c422b595f..957f3151e9 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.18.2", + "version": "1.18.11", "type": "module", "license": "MIT", "repository": { @@ -104,6 +104,7 @@ "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 fd34304464..91fe502264 100644 --- a/packages/ui/src/components/dock-surface.css +++ b/packages/ui/src/components/dock-surface.css @@ -7,6 +7,20 @@ 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 d7774a684b..bc89b257b1 100644 --- a/packages/ui/src/components/resize-handle.tsx +++ b/packages/ui/src/components/resize-handle.tsx @@ -8,6 +8,8 @@ export interface ResizeHandleProps extends Omit void onCollapse?: () => void + /** Called while dragging when size crosses `collapseThreshold`. */ + onCollapseChange?: (collapsed: boolean) => void collapseThreshold?: number } @@ -20,17 +22,26 @@ 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" @@ -46,8 +57,12 @@ export function ResizeHandle(props: ResizeHandleProps) { ? start - pos : pos - start current = startSize + delta - const clamped = Math.min(local.max, Math.max(local.min, current)) - local.onResize(clamped) + const nextCollapsed = threshold > 0 && current < threshold + if (nextCollapsed !== collapsed) { + collapsed = nextCollapsed + onCollapseChange?.(collapsed) + } + onResize(Math.min(max, Math.max(min, current))) } const onMouseUp = () => { @@ -56,10 +71,11 @@ export function ResizeHandle(props: ResizeHandleProps) { document.removeEventListener("mousemove", onMouseMove) document.removeEventListener("mouseup", onMouseUp) - const threshold = local.collapseThreshold ?? 0 - if (local.onCollapse && threshold > 0 && current < threshold) { - local.onCollapse() + if (collapsed) { + onCollapse?.() + return } + 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 7c5d8ed0f7..e2425a01e4 100644 --- a/packages/ui/src/components/scroll-view.css +++ b/packages/ui/src/components/scroll-view.css @@ -27,6 +27,7 @@ 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 d338fa59bc..84131b1f17 100644 --- a/packages/ui/src/components/scroll-view.test.ts +++ b/packages/ui/src/components/scroll-view.test.ts @@ -72,4 +72,19 @@ 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 0a567ce1c5..5116d94f9e 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -1,4 +1,15 @@ -import { onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js" +import { + createEffect, + createMemo, + mergeProps, + onCleanup, + onMount, + Show, + splitProps, + type Accessor, + type ComponentProps, +} from "solid-js" +import { Portal } from "solid-js/web" import { createResizeObserver } from "@solid-primitives/resize-observer" import { createStore } from "solid-js/store" import { useI18n } from "../context/i18n" @@ -8,7 +19,17 @@ 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) => { @@ -65,12 +86,14 @@ 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.clientHeight) + return (thumbTop / maxThumbTop) * Math.max(0, input.scrollHeight - (input.scrollClientHeight ?? input.clientHeight)) } export function ScrollView(props: ScrollViewProps) { @@ -78,7 +101,16 @@ export function ScrollView(props: ScrollViewProps) { const merged = mergeProps({ orientation: "vertical", thumbVisibility: "hover" }, props) const [local, events, rest] = splitProps( merged, - ["class", "children", "viewportRef", "orientation", "thumbVisibility", "style"], + [ + "class", + "children", + "viewportRef", + "orientation", + "thumbVisibility", + "thumbContainer", + "thumbHoverTarget", + "style", + ], [ "onScroll", "onWheel", @@ -96,6 +128,15 @@ 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, @@ -114,7 +155,6 @@ 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) @@ -122,8 +162,8 @@ export function ScrollView(props: ScrollViewProps) { const thumbVisible = () => { if (isDragging()) return true - if (local.thumbVisibility === "scroll") return isScrolling() - return isHovered() + if (isScrolling()) return true + return local.thumbVisibility === "hover" && isHovered() } onCleanup(() => { @@ -141,7 +181,8 @@ export function ScrollView(props: ScrollViewProps) { setState("showThumb", true) const trackPadding = 8 - const trackHeight = clientHeight - trackPadding * 2 + const trackClientHeight = thumbMount()?.clientHeight || clientHeight + const trackHeight = trackClientHeight - trackPadding * 2 const minThumbHeight = 32 // Calculate raw thumb height based on ratio @@ -165,16 +206,40 @@ export function ScrollView(props: ScrollViewProps) { local.viewportRef(viewportRef) } - createResizeObserver([viewportRef, viewportRef.firstElementChild], updateThumb) + createResizeObserver( + () => [viewportRef, viewportRef.firstElementChild, thumbMount()].filter(Boolean) as HTMLElement[], + 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) @@ -182,9 +247,10 @@ export function ScrollView(props: ScrollViewProps) { const { scrollHeight, clientHeight } = viewportRef viewportRef.scrollTop = scrollTopFromThumbPointer({ pointer: e.clientY, - viewportTop: viewportRef.getBoundingClientRect().top, + viewportTop: track.getBoundingClientRect().top, grabOffset, - clientHeight, + clientHeight: track.clientHeight, + scrollClientHeight: clientHeight, scrollHeight, thumbHeight: thumbHeight(), }) @@ -203,6 +269,23 @@ 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, @@ -253,8 +336,12 @@ export function ScrollView(props: ScrollViewProps) { ref={rootRef} class={`scroll-view ${local.class || ""}`} style={local.style} - onPointerEnter={() => setState("isHovered", true)} - onPointerLeave={() => setState("isHovered", false)} + onPointerEnter={() => { + if (hoverRoot()) setState("isHovered", true) + }} + onPointerLeave={() => { + if (hoverRoot()) setState("isHovered", false) + }} {...rest} > {/* Viewport */} @@ -290,20 +377,11 @@ export function ScrollView(props: ScrollViewProps) { {local.children}
- {/* Thumb Overlay */} + {/* Thumb Overlay — optionally portaled into an external track */} -
+ + {(mount) => {renderThumb()}} +
) diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index 0978ec5ad2..ef630e9547 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -723,7 +723,11 @@ body[data-new-layout] #review-panel [data-component="tabs"][data-variant="normal } &:has([data-slot="tabs-trigger-close-button"]) { - padding-right: 10px; + padding-right: 8px; + + [data-slot="tabs-trigger-close-button"] { + margin-left: 2px; + } } &:has([data-selected]) { @@ -789,6 +793,62 @@ 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 97f38e6b8b..531efe9f81 100644 --- a/packages/ui/src/v2/components/button-v2.css +++ b/packages/ui/src/v2/components/button-v2.css @@ -113,6 +113,29 @@ 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 bc9b9f05f0..fbb38cc5a2 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" | "contrast" | "ghost" | "ghost-muted" | "loading". +- \`variant\`: "neutral" | "danger" | "warning" | "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", "contrast", "ghost", "ghost-muted", "loading"], + options: ["neutral", "danger", "warning", "contrast", "ghost", "ghost-muted", "loading"], }, size: { control: "select", @@ -62,6 +62,7 @@ export const Variants = { > Neutral Danger + Warning Contrast Ghost @@ -117,7 +118,7 @@ export const Icon = { export const AllStates = { render: () => { - const variants = ["neutral", "danger", "contrast", "ghost", "ghost-muted", "loading"] as const + const variants = ["neutral", "danger", "warning", "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 c6c6099e21..14b793ba11 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" | "outline" | "contrast" | "ghost" | "ghost-muted" | "loading" + variant?: "neutral" | "danger" | "warning" | "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 48a6f53712..b9dd2521e2 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() + autofocusEl.focus({ preventScroll: true }) } }} > diff --git a/packages/ui/src/v2/components/divider-v2.css b/packages/ui/src/v2/components/divider-v2.css index 94c89bbed2..94a584b40e 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: 0.5px; - margin-block: 0.25px; + height: 1px; + transform: scaleY(0.5); 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 81d3fe508e..c2f13e605d 100644 --- a/packages/ui/src/v2/components/inline-input-v2.css +++ b/packages/ui/src/v2/components/inline-input-v2.css @@ -67,9 +67,14 @@ 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); @@ -89,6 +94,7 @@ 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"] { @@ -149,6 +155,7 @@ [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 cce1af2693..9d41437c22 100644 --- a/packages/ui/src/v2/components/inline-input-v2.tsx +++ b/packages/ui/src/v2/components/inline-input-v2.tsx @@ -37,6 +37,8 @@ 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