diff --git a/.github/TEAM_MEMBERS b/.github/TEAM_MEMBERS index 5eb465e0dd..ee2e26f452 100644 --- a/.github/TEAM_MEMBERS +++ b/.github/TEAM_MEMBERS @@ -18,4 +18,4 @@ simonklee Slickstef11 usrnk1 vimtor -starptech +StarpTech 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 3e9cbb84de..4d6d657727 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -95,7 +95,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.17.18", + "version": "1.18.3", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -143,7 +143,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -157,7 +157,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -193,7 +193,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -220,7 +220,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -242,7 +242,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -266,7 +266,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -286,7 +286,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.17.18", + "version": "1.18.3", "bin": { "opencode": "./bin/opencode", }, @@ -294,7 +294,7 @@ "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.49", + "@ai-sdk/azure": "3.0.88", "@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", @@ -303,7 +303,7 @@ "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", - "@ai-sdk/openai": "3.0.53", + "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", @@ -338,7 +338,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.11.0", + "gitlab-ai-provider": "6.11.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -380,7 +380,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -434,7 +434,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -448,7 +448,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "effect": "catalog:", }, @@ -460,7 +460,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -492,7 +492,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -508,7 +508,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -539,7 +539,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -558,7 +558,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.17.18", + "version": "1.18.3", "bin": { "opencode": "./bin/opencode", }, @@ -569,7 +569,7 @@ "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.49", + "@ai-sdk/azure": "3.0.88", "@ai-sdk/cerebras": "2.0.60", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", @@ -578,7 +578,7 @@ "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", - "@ai-sdk/openai": "3.0.53", + "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", @@ -631,7 +631,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.11.0", + "gitlab-ai-provider": "6.11.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -689,7 +689,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -706,9 +706,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 +765,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "cross-spawn": "catalog:", }, @@ -780,7 +780,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -795,7 +795,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -839,7 +839,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -852,9 +852,10 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "@ibm/plex": "6.4.1", + "@kobalte/core": "catalog:", "@opencode-ai/stats-core": "workspace:*", "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -885,7 +886,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -904,7 +905,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -936,6 +937,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", @@ -945,7 +947,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -972,7 +974,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1023,7 +1025,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.17.18", + "version": "1.18.3", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -1066,6 +1068,7 @@ ], "patchedDependencies": { "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", @@ -1073,10 +1076,8 @@ "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", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", - "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", }, "overrides": { @@ -1099,9 +1100,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", @@ -1112,7 +1113,7 @@ "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "@tailwindcss/vite": "4.1.11", - "@tanstack/solid-virtual": "3.13.28", + "@tanstack/solid-virtual": "3.13.32", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", "@types/bun": "1.3.13", @@ -1132,7 +1133,7 @@ "hono": "4.10.7", "hono-openapi": "1.1.2", "luxon": "3.6.1", - "marked": "17.0.1", + "marked": "17.0.6", "marked-shiki": "1.2.1", "opentui-spinner": "0.0.7", "remeda": "2.26.0", @@ -1174,7 +1175,7 @@ "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="], - "@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="], + "@ai-sdk/azure": ["@ai-sdk/azure@3.0.88", "", { "dependencies": { "@ai-sdk/deepseek": "2.0.47", "@ai-sdk/openai": "3.0.84", "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RRjZkB1lYplh8dpBarnvkl1j7sYLHsyXua7erL3oNcMK7fHcl4bPO5C7iQhD1O/DqD/zCceDifnege1s+8yEvw=="], "@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="], @@ -1184,7 +1185,7 @@ "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="], - "@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="], + "@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MzcQ321JO8OY+TVLFI81A7cIIuoeLLxrLCDD+8C1E3Ro6UFyfMtRXo9bw9OhTMRSDMo6hgSDOo4Fekz8aJtQYQ=="], "@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EtvsWfGrqx3OhzJdoi82qH+4yzEPPKZr2utyQ+w8cHKoFeg0+8Lou9Z3uixy73WEwz8Z1+AR8QT9fZ64AWGYPA=="], @@ -2038,27 +2039,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=="], @@ -2780,9 +2781,9 @@ "@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="], - "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.28", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-kRuOEL5orH/rzGgxNgfgOttsgV6cgrUeupVtrHMITb5p0rZ3hnxhbu/lhKcR9+7x+EJdfUtJIb2CVC85mlw15g=="], + "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.32", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-yhX4A4Kgn+wyTg6Mmu8+zwoMTwjz4K1ucvLfRJ8f0rPGDDAIqSaf0v6oU0yT9+SvrjmUaZQ0VX7g4byexbhNng=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="], "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], @@ -3850,7 +3851,7 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "gitlab-ai-provider": ["gitlab-ai-provider@6.11.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-/LTD5qtSbGGnWd7/HB72YioSMNYqF7RlABojBqRqZ8UK5DpxoTp/dnT7K/tIScDWMYPGpJqX/B7JIOh0xeXFLQ=="], + "gitlab-ai-provider": ["gitlab-ai-provider@6.11.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-SJ6f5qa7P8md6lPrserryER3zerLkrezlnqqYQ2AbvDPpHLbwtbyk0FYJ5kNRcmbI80i/VMcsMBP0YIRdc3ucQ=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], @@ -4312,7 +4313,7 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], "marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="], @@ -5654,7 +5655,11 @@ "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], - "@ai-sdk/azure/@ai-sdk/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/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], + + "@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + + "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], "@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], @@ -5666,9 +5671,9 @@ "@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], "@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], @@ -6020,7 +6025,7 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], - "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], + "@opencode-ai/core/@ai-sdk/openai": ["@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=="], @@ -6046,6 +6051,8 @@ "@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], @@ -6162,6 +6169,10 @@ "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="], + "ai-gateway-provider/@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="], + + "ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="], + "ai-gateway-provider/@ai-sdk/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=="], @@ -6388,7 +6399,7 @@ "opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="], - "opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], + "opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], "opencode/@ai-sdk/openai-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=="], @@ -6896,6 +6907,10 @@ "@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], + "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + + "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], + "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], "@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], @@ -6966,6 +6981,14 @@ "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="], + + "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], + + "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + + "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "ai-gateway-provider/@ai-sdk/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=="], @@ -7118,6 +7141,10 @@ "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="], + "opencode/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + + "opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], + "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], @@ -7366,6 +7393,8 @@ "@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], @@ -7382,6 +7411,10 @@ "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "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=="], @@ -7452,6 +7485,8 @@ "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "opencode/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], diff --git a/infra/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 d3d3e556fa..b5ca7802d5 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-QlwoEmN1jiw9TqQgOutPOv4mEMCNUR0Mm3ivYIWlc1o=", - "aarch64-linux": "sha256-KS8unXRXev29zbPskvp2ddtfAKXJN8VfInXnmPqypBI=", - "aarch64-darwin": "sha256-T3tgbuodPitCx2U5JJIQFLkRguAFeiMM7flTsF2H5Iw=", - "x86_64-darwin": "sha256-aITMNkQpFYAChDz5jZCchFkSylsDhJJQoyPu9k23Hl8=" + "x86_64-linux": "sha256-zAftZieWR0hWQQVkhEa826iueHaRnKxtyRodhNb+Apc=", + "aarch64-linux": "sha256-cTG+gB7CuiFgaYHSFbzXaXUI1C2kiugV3B9rUDRUwlg=", + "aarch64-darwin": "sha256-ap4Xv5ZliourkI4mcD0ktbzSI88jb4oVBv5mMK3WdUs=", + "x86_64-darwin": "sha256-NGRJMMnaMNrf3FkcYwr5PcfVm5thhlNLGIeMmAK0MeE=" } } diff --git a/package.json b/package.json index 4fa3399264..332d7520a6 100644 --- a/package.json +++ b/package.json @@ -40,10 +40,10 @@ "@octokit/rest": "22.0.0", "@hono/standard-validator": "0.2.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.4.3", - "@opentui/keymap": "0.4.3", - "@opentui/solid": "0.4.3", - "@tanstack/solid-virtual": "3.13.28", + "@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", "@kobalte/core": "0.13.11", @@ -70,7 +70,7 @@ "hono-openapi": "1.1.2", "fuzzysort": "3.1.0", "luxon": "3.6.1", - "marked": "17.0.1", + "marked": "17.0.6", "marked-shiki": "1.2.1", "remend": "1.3.0", "@playwright/test": "1.59.1", @@ -152,10 +152,9 @@ "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", - "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", - "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch" + "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch" } } diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index e63445e87d..5095d95db0 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -136,6 +136,9 @@ export async function setupTimeline( }, }), ) + if (settings.newLayoutDesigns === false) { + localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" })) + } }, input.settings ?? {}) if (input.locale) { await page.addInitScript((locale) => { diff --git a/packages/app/e2e/regression/legacy-new-session.spec.ts b/packages/app/e2e/regression/legacy-new-session.spec.ts index 30233a5aae..45cd64adcc 100644 --- a/packages/app/e2e/regression/legacy-new-session.spec.ts +++ b/packages/app/e2e/regression/legacy-new-session.spec.ts @@ -24,6 +24,7 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => { await page.addInitScript( ({ directory, draftID, server }) => { localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } })) + localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" })) localStorage.setItem( "opencode.window.browser.dat:tabs", JSON.stringify([{ type: "draft", draftID, server, directory }]), diff --git a/packages/app/e2e/regression/prompt-thinking-level.spec.ts b/packages/app/e2e/regression/prompt-thinking-level.spec.ts index 4219699f28..9ddb484709 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.locator('button[title="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 new file mode 100644 index 0000000000..c17ae5c1c6 --- /dev/null +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -0,0 +1,270 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page, type Route } from "@playwright/test" +import { installSseTransport } from "../utils/sse-transport" + +const serverA = "http://127.0.0.1:4096" +const serverB = "http://127.0.0.1:4097" +const directoryA = "C:/server-a" +const directoryB = "/home/server-b" +const sessionA = session("ses_server_a", directoryA, "Server A session") +const childSessionA = { ...session("ses_server_a_child", directoryA, "Server A child session"), parentID: sessionA.id } +const sessionB = session("ses_server_b", directoryB, "Server B session") + +test("session settings use the remote server context", async ({ page }) => { + const permissionRequests: string[] = [] + await mockServers(page, permissionRequests) + await configureServers(page) + + await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`) + await expect(page.getByText(sessionB.title).first()).toBeVisible() + await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,") + + const dialog = page.locator(".settings-v2-dialog") + const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]') + const input = autoAccept.getByRole("switch") + await expect(autoAccept).toBeVisible() + await expect(input).toBeEnabled() + permissionRequests.length = 0 + await autoAccept.locator('[data-slot="switch-control"]').click() + await expect(input).toBeChecked() + await expect + .poll(() => + permissionRequests.some((request) => { + const url = new URL(request) + return url.origin === serverB && url.searchParams.get("directory") === directoryB + }), + ) + .toBe(true) + expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true) + + await dialog.getByRole("tab", { name: "Models" }).click() + await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled() + await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0) +}) + +test("auto-accept responds for an unfocused server session", async ({ page }) => { + const permissionRequests: string[] = [] + const permissionResponses: PermissionResponse[] = [] + const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { + server: serverA, + retry: 20, + }) + await mockServers(page, permissionRequests, permissionResponses) + await configureServers(page, [ + { type: "session", server: serverA, sessionId: sessionA.id }, + { type: "session", server: serverB, sessionId: sessionB.id }, + ]) + + const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` + await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`) + await expect(page.getByText(sessionA.title).first()).toBeVisible() + await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,") + const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]') + await autoAccept.locator('[data-slot="switch-control"]').click() + await expect(autoAccept.getByRole("switch")).toBeChecked() + await expect + .poll(() => + permissionRequests.some((request) => { + const url = new URL(request) + return url.origin === serverA && url.searchParams.get("directory") === directoryA + }), + ) + .toBe(true) + await page.keyboard.press("Escape") + + await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click() + await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) + await expect(page.getByText(sessionB.title).first()).toBeVisible() + await transport.waitForConnection() + + await transport.send({ + directory: directoryA, + payload: { + id: "event-permission-background-a", + type: "permission.asked", + properties: { + id: "permission-background-a", + sessionID: sessionA.id, + permission: "bash", + patterns: ["git status"], + metadata: {}, + always: [], + }, + }, + }) + + await expect + .poll(() => permissionResponses) + .toEqual([ + { + origin: serverA, + directory: directoryA, + sessionID: sessionA.id, + permissionID: "permission-background-a", + body: { response: "once" }, + }, + ]) + + await transport.send({ + directory: directoryA, + payload: { + id: "event-permission-background-a-child", + type: "permission.asked", + properties: { + id: "permission-background-a-child", + sessionID: childSessionA.id, + permission: "bash", + patterns: ["git diff"], + metadata: {}, + always: [], + }, + }, + }) + + await expect + .poll(() => permissionResponses) + .toEqual([ + { + origin: serverA, + directory: directoryA, + sessionID: sessionA.id, + permissionID: "permission-background-a", + body: { response: "once" }, + }, + { + origin: serverA, + directory: directoryA, + sessionID: childSessionA.id, + permissionID: "permission-background-a-child", + body: { response: "once" }, + }, + ]) +}) + +type PermissionResponse = { + origin: string + directory?: string + sessionID: string + permissionID: string + body: unknown +} + +async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) { + await page.addInitScript( + ({ serverB, tabs }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) + localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(tabs)) + }, + { serverB, tabs }, + ) +} + +async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) { + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()) + if (url.origin !== serverA && url.origin !== serverB) return route.fallback() + const remote = url.origin === serverB + const directory = remote ? directoryB : directoryA + const sessions = remote ? [sessionB] : [sessionA, childSessionA] + const requestDirectory = url.searchParams.get("directory") + const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/) + if (route.request().method() === "POST" && response) { + permissionResponses.push({ + origin: url.origin, + directory: requestDirectory ?? undefined, + sessionID: response[1]!, + permissionID: response[2]!, + body: route.request().postDataJSON(), + }) + return json(route, true) + } + if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) + if (url.pathname === "/global/event" || url.pathname === "/event") 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) + const current = sessions.find((session) => url.pathname === `/session/${session.id}`) + if (current) return json(route, current) + if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) + if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, []) + if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) + if (url.pathname === "/permission") { + permissionRequests.push(url.toString()) + return json(route, []) + } + if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname)) + return json(route, []) + if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) + if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a")) + if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) + if (url.pathname === "/project" || url.pathname === "/project/current") { + const project = { + id: remote ? sessionB.projectID : "project-server-a", + worktree: directory, + vcs: "git", + time: { created: 1, updated: 1 }, + sandboxes: [], + } + return json(route, url.pathname === "/project" ? [project] : project) + } + if (url.pathname === "/path") + return json(route, { + state: directory, + config: directory, + worktree: directory, + directory, + home: directory, + }) + if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + return json(route, {}) + }) +} + +function session(id: string, directory: string, title: string) { + return { + id, + slug: id, + projectID: `project-${id}`, + directory, + title, + version: "dev", + time: { created: 1, updated: 1 }, + } +} + +function provider(id: string) { + const name = id === "server-b" ? "Server B" : "Server A" + return { + all: [ + { + id, + name: `${name} Provider`, + models: { + [id]: { + id, + name: `${name} Model`, + family: id, + release_date: "2026-01-01", + limit: { context: 200_000 }, + }, + }, + }, + ], + connected: [id], + default: { providerID: id, modelID: id }, + } +} + +function json(route: Route, body: unknown, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify(body), + }) +} + +function sse(route: Route) { + return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) +} diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index 036eaaef42..714d6ca96f 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -1,6 +1,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" +import { installSseTransport } from "../utils/sse-transport" import { expectSessionTitle } from "../utils/waits" const directory = "C:/OpenCode/RequestDocks" @@ -100,6 +101,67 @@ test("shows a pending permission dock", async ({ page }) => { expect(request.postDataJSON()).toEqual({ response: "once" }) }) +test("restores the draft caret before typing after a request dock closes", async ({ page }) => { + const transport = await installSseTransport(page, { + server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, + retry: 20, + }) + await mockServer(page, { questions: [] }) + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await transport.waitForConnection() + await expectSessionTitle(page, title) + + const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]') + const draft = "keep the caret at the end" + await editor.fill(draft) + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))) + for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft") + const cursor = draft.length - 4 + await expect + .poll(() => + editor.evaluate((element) => { + const selection = window.getSelection() + if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1 + const range = selection.getRangeAt(0).cloneRange() + range.selectNodeContents(element) + range.setEnd(selection.anchorNode!, selection.anchorOffset) + return range.toString().length + }), + ) + .toBe(cursor) + await transport.send({ + directory, + payload: { + type: "question.asked", + properties: { + id: "question-caret", + sessionID, + questions: [ + { + header: "Continue", + question: "Continue?", + options: [{ label: "Yes", description: "Continue the session" }], + }, + ], + tool: { messageID: "message-caret", callID: "call-caret" }, + }, + }, + }) + const question = page.locator('[data-component="dock-prompt"][data-kind="question"]') + await expect(question).toBeVisible() + await expect(editor).toHaveCount(0) + + await transport.send({ + directory, + payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } }, + }) + await expect(question).toHaveCount(0) + await expect(editor).toBeVisible() + await page.keyboard.press("x") + + await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`) +}) + async function mockServer( page: Page, requests: { diff --git a/packages/app/e2e/regression/session-timeline-history-root.spec.ts b/packages/app/e2e/regression/session-timeline-history-root.spec.ts index 15375cafed..e5ef7998ea 100644 --- a/packages/app/e2e/regression/session-timeline-history-root.spec.ts +++ b/packages/app/e2e/regression/session-timeline-history-root.spec.ts @@ -17,12 +17,14 @@ import { mockOpenCodeServer } from "../utils/mock-server" import { installSseTransport } from "../utils/sse-transport" import { expectSessionTitle } from "../utils/waits" -const assistants = Array.from({ length: 14 }, (_, index) => +const initialPageSize = 20 +const historyPageSize = 200 +const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) => assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, parentID: userID, created: 1700000001000 + index * 1_000, - completed: index < 13, + completed: index < initialPageSize, }), ) const messages = [userMessage(), ...assistants] @@ -46,7 +48,7 @@ const scenarios = [ test.use({ viewport: { width: 646, height: 1385 } }) for (const scenario of scenarios) { - test(`keeps the latest user turn visible through ${scenario.name}`, async ({ page }) => { + test(`keeps visible timeline content visible through ${scenario.name}`, async ({ page }) => { const requests: { before?: string; phase: "start" | "end" }[] = [] const pages: { before?: string; limit: number }[] = [] const roots: { sessionID: string; messageID: string }[] = [] @@ -101,36 +103,51 @@ for (const scenario of scenarios) { } }, }) - await page.addInitScript( - ({ userPartID, lastPartID }) => { - const state = { armed: false, hidden: false, samples: 0, stop: false } - ;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state - const sample = () => { - if (state.armed) { - const virtual = document.querySelector("[data-timeline-virtual-content]") - const viewport = virtual?.closest(".scroll-view__viewport") - const view = viewport?.getBoundingClientRect() - const visible = (partID: string) => { - const part = viewport?.querySelector(`[data-timeline-part-id="${partID}"]`) - const rect = part?.getBoundingClientRect() - return ( - !!rect && - !!view && - rect.width > 0 && - rect.height > 0 && - rect.bottom > view.top && - rect.top < view.bottom - ) - } - if (!virtual || !visible(userPartID) || !visible(lastPartID)) state.hidden = true - state.samples++ + await page.addInitScript(() => { + const visibleParts = () => { + const virtual = document.querySelector("[data-timeline-virtual-content]") + const viewport = virtual?.closest(".scroll-view__viewport") + const view = viewport?.getBoundingClientRect() + if (!viewport || !view) return [] + return [...viewport.querySelectorAll("[data-timeline-part-id]")] + .filter((part) => { + const rect = part.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom + }) + .flatMap((part) => (part.dataset.timelinePartId ? [part.dataset.timelinePartId] : [])) + } + const state = { + armed: false, + hidden: false, + visibleParts: [] as string[], + samples: 0, + stop: false, + arm() { + state.visibleParts = visibleParts() + state.armed = true + }, + } + ;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state + const sample = () => { + if (state.armed) { + const virtual = document.querySelector("[data-timeline-virtual-content]") + const viewport = virtual?.closest(".scroll-view__viewport") + const view = viewport?.getBoundingClientRect() + const visible = (partID: string) => { + const part = viewport?.querySelector(`[data-timeline-part-id="${CSS.escape(partID)}"]`) + const rect = part?.getBoundingClientRect() + return ( + !!rect && !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom + ) } - if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0)) + if (!virtual || state.visibleParts.length === 0 || state.visibleParts.some((partID) => !visible(partID))) + state.hidden = true + state.samples++ } - requestAnimationFrame(() => setTimeout(sample, 0)) - }, - { userPartID, lastPartID }, - ) + if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0)) + } + requestAnimationFrame(() => setTimeout(sample, 0)) + }) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await transport.waitForConnection() @@ -143,23 +160,28 @@ for (const scenario of scenarios) { "messages:start:latest", "messages:end:latest", `message:${userID}`, - `messages:start:${messages.at(-2)!.info.id}`, + `messages:start:${messages.at(-initialPageSize)!.info.id}`, ]) + await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize) await page.evaluate(() => { ;( window as Window & { - __historyRootProbe?: { armed: boolean } + __historyRootProbe?: { arm(): void } } - ).__historyRootProbe!.armed = true + ).__historyRootProbe!.arm() }) await waitForProbeSamples(page, 0) - expect(await historyRootHidden(page)).toBe(false) + expect(await visibleContentHidden(page)).toBe(false) const beforeHistory = await probeSamples(page) history.resolve() - await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(14) + await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length) + await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2) await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() await waitForProbeSamples(page, beforeHistory) - expect(pages[0]).toEqual({ before: undefined, limit: 2 }) + expect(pages).toEqual([ + { before: undefined, limit: initialPageSize }, + { before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize }, + ]) expect(roots).toEqual([{ sessionID, messageID: userID }]) const message = messageUpdated(scenario.info) @@ -213,7 +235,7 @@ async function waitForProbeSamples(page: Page, after: number) { ) } -function historyRootHidden(page: Page) { +function visibleContentHidden(page: Page) { return page.evaluate( () => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden, ) diff --git a/packages/app/e2e/regression/terminal-composer-focus.spec.ts b/packages/app/e2e/regression/terminal-composer-focus.spec.ts index 2c2801d4b5..f672602782 100644 --- a/packages/app/e2e/regression/terminal-composer-focus.spec.ts +++ b/packages/app/e2e/regression/terminal-composer-focus.spec.ts @@ -1,5 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test } from "@playwright/test" +import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" @@ -7,10 +7,11 @@ const directory = "C:/OpenCode/TerminalComposerFocus" const projectID = "proj_terminal_composer_focus" const sessionID = "ses_terminal_composer_focus" const ptyID = "pty_terminal_composer_focus" +const newPtyID = "pty_terminal_composer_focus_new" test.use({ viewport: { width: 1440, height: 900 } }) -test("routes typing to the composer unless the open terminal is focused", async ({ page }) => { +test.beforeEach(async ({ page }) => { await mockOpenCodeServer(page, { directory, project: { @@ -67,7 +68,9 @@ test("routes typing to the composer unless the open terminal is focused", async await page.addInitScript(() => { localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) }) +}) +test("routes typing to the composer unless the open terminal is focused", async ({ page }) => { await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await expectSessionTitle(page, "Terminal composer focus") @@ -87,3 +90,120 @@ test("routes typing to the composer unless the open terminal is focused", async await expect(composer).toBeFocused() await expect(composer).toHaveText("a") }) + +test("keeps composer focus when a cached terminal finishes mounting", async ({ page }) => { + const ghostty = Promise.withResolvers() + const release = Promise.withResolvers() + const created = { count: 0 } + await page.route("**/pty", (route) => { + created.count += 1 + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ id: ptyID, title: "Terminal 1" }), + }) + }) + await page.route(/ghostty-web/, async (route) => { + ghostty.resolve() + await release.promise + await route.continue() + }) + await seedCachedTerminal(page) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" }) + await expectSessionTitle(page, "Terminal composer focus") + + const composer = page.locator('[data-component="prompt-input"]') + const terminal = page.locator('[data-component="terminal"]') + await expect(terminal).toBeVisible() + expect(created.count).toBe(0) + await ghostty.promise + await composer.click() + await expect(composer).toBeFocused() + + release.resolve() + await expect(terminal.locator("textarea")).toHaveCount(1) + await page.waitForTimeout(300) + await expect(composer).toBeFocused() +}) + +test("keeps newer composer focus while an explicit terminal open finishes", async ({ page }) => { + const ghostty = Promise.withResolvers() + const release = Promise.withResolvers() + await page.route(/ghostty-web/, async (route) => { + ghostty.resolve() + await release.promise + await route.continue() + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, "Terminal composer focus") + + const composer = page.locator('[data-component="prompt-input"]') + const terminal = page.locator('[data-component="terminal"]') + await page.keyboard.press("Control+Backquote") + await expect(terminal).toBeVisible() + await ghostty.promise + await composer.click() + await expect(composer).toBeFocused() + + release.resolve() + await expect(terminal.locator("textarea")).toHaveCount(1) + await page.waitForTimeout(50) + await expect(composer).toBeFocused() +}) + +test("focuses a terminal created from the new-terminal button", async ({ page }) => { + const created = { count: 0 } + await page.route("**/pty", (route) => { + created.count += 1 + const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(next), + }) + }) + await page.route(`**/pty/${newPtyID}`, (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), + ) + await page.route(`**/pty/${newPtyID}/connect-token*`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify({ ticket: "e2e-ticket" }), + }), + ) + await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, "Terminal composer focus") + + const composer = page.locator('[data-component="prompt-input"]') + const terminal = page.locator('[data-component="terminal"]') + await page.keyboard.press("Control+Backquote") + await expect(terminal.locator("textarea")).toHaveCount(1) + await composer.click() + await expect(composer).toBeFocused() + + await page.getByRole("button", { name: "New terminal" }).click() + await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true") + await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true) +}) + +function seedCachedTerminal(page: Page) { + return page.addInitScript( + ({ terminalKey, ptyID }) => { + localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } })) + localStorage.setItem( + terminalKey, + JSON.stringify({ + active: ptyID, + all: [{ id: ptyID, title: "Terminal 1", titleNumber: 1 }], + }), + ) + }, + { terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID }, + ) +} diff --git a/packages/app/e2e/reproduction/timeline-suspense/index.html b/packages/app/e2e/reproduction/timeline-suspense/index.html new file mode 100644 index 0000000000..995008a9f7 --- /dev/null +++ b/packages/app/e2e/reproduction/timeline-suspense/index.html @@ -0,0 +1,34 @@ + + + + + + Timeline Suspense Reproduction + + + +
+ + + diff --git a/packages/app/e2e/reproduction/timeline-suspense/main.tsx b/packages/app/e2e/reproduction/timeline-suspense/main.tsx new file mode 100644 index 0000000000..cdcd809be3 --- /dev/null +++ b/packages/app/e2e/reproduction/timeline-suspense/main.tsx @@ -0,0 +1,315 @@ +import { createResource, createSignal, For, onMount, Suspense } from "solid-js" +import { render } from "solid-js/web" +import { createVirtualizer, observeElementOffset, observeElementRect } from "@tanstack/solid-virtual" +import { observeElementOffsetReconnectAware } from "../../../src/pages/session/timeline/observe-element-offset" + +const rowCount = 2_000 +const rowHeight = 40 +const parameters = new URLSearchParams(location.search) +const resourceMode = parameters.get("resource") === "guard" ? "guard" : "baseline" +const reconnectMode = parameters.get("reconnect") === "candidate" ? "candidate" : "baseline" + +type MutationEvent = { + kind: "removed" | "added" + callbackTime: number + callbackFrame: number + routeConnectedInCallback: boolean + nativeOffsetInCallback: number +} + +type Snapshot = { + mode: { + resource: "baseline" | "guard" + reconnect: "baseline" | "candidate" + } + operation: { + sequence: number + phase: string + time: number + frame: number + } + resourceState: string + routeConnected: boolean + viewportConnected: boolean + viewportOwnedByRoute: boolean + sameRoute: boolean + sameViewport: boolean + sameSurface: boolean + sameMountedRows: boolean + nativeOffset: number + coreOffset: number + rangeStart: number + rangeEnd: number + indexes: number[] + domIndexes: number[] + logicalSurfaceHeight: number + renderedSurfaceHeight: number + viewportClientHeight: number + viewportScrollHeight: number + visibleRows: number + minimumRowTop: number + domScrollEvents: number + lastScrollTrusted: boolean + coreOffsetCallbackCalls: number + offsetCallbackSources: "observer"[] + rectObserverCallbacks: number + ignoredDetachedZeroRects: number + syntheticScrollDispatches: number + mutationEvents: MutationEvent[] +} + +declare global { + interface Window { + timelineSuspense: { + prepare: () => Promise + trigger: () => void + resolve: () => void + frames: (count?: number) => Promise + snapshot: () => Snapshot + } + } +} + +function App() { + const [refresh, setRefresh] = createSignal(false) + let resolveResource: (() => void) | undefined + const [resource] = createResource( + refresh, + (version) => + new Promise((resolve) => { + resolveResource = () => resolve(`settled-${version}`) + }), + { initialValue: "settled" }, + ) + + function Route() { + let route: HTMLElement | undefined + let viewport: HTMLDivElement | undefined + let surface: HTMLDivElement | undefined + let initialRoute: HTMLElement | undefined + let initialViewport: HTMLDivElement | undefined + let initialSurface: HTMLDivElement | undefined + let initialRows: HTMLElement[] = [] + let phase = "mounting" + let browserFrame = 0 + let snapshotSequence = 0 + let domScrollEvents = 0 + let lastScrollTrusted = false + let coreOffsetCallbackCalls = 0 + let rectObserverCallbacks = 0 + let ignoredDetachedZeroRects = 0 + const offsetCallbackSources: "observer"[] = [] + const mutationEvents: MutationEvent[] = [] + const virtualizer = createVirtualizer({ + count: rowCount, + getScrollElement: () => viewport ?? null, + estimateSize: () => rowHeight, + initialRect: { width: 900, height: 600 }, + overscan: 2, + observeElementRect: (instance, callback) => + observeElementRect(instance, (rect) => { + rectObserverCallbacks++ + // A fixed 600px viewport has no usable geometry while detached. Keep the last connected rect. + if (!instance.scrollElement?.isConnected && rect.height === 0) { + ignoredDetachedZeroRects++ + return + } + callback(rect) + }), + observeElementOffset: (instance, callback) => { + const deliver = (offset: number, isScrolling: boolean) => { + coreOffsetCallbackCalls++ + offsetCallbackSources.push("observer") + callback(offset, isScrolling) + } + if (reconnectMode === "candidate") return observeElementOffsetReconnectAware(instance, deliver) + return observeElementOffset(instance, deliver) + }, + }) + + const frames = async (count = 2) => { + for (let index = 0; index < count; index++) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + } + } + const mountedRows = () => [...(surface?.querySelectorAll("[data-row-index]") ?? [])] + const snapshot = (): Snapshot => { + const rows = mountedRows() + const view = viewport?.getBoundingClientRect() + const visibleRows = + viewport?.isConnected && view + ? rows.filter((row) => { + const rect = row.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }).length + : 0 + return { + mode: { resource: resourceMode, reconnect: reconnectMode }, + operation: { + sequence: ++snapshotSequence, + phase, + time: performance.now(), + frame: browserFrame, + }, + resourceState: resource.state, + routeConnected: route?.isConnected ?? false, + viewportConnected: viewport?.isConnected ?? false, + viewportOwnedByRoute: !!route && !!viewport && route.contains(viewport), + sameRoute: route === initialRoute, + sameViewport: viewport === initialViewport, + sameSurface: surface === initialSurface, + sameMountedRows: + initialRows.length > 0 && + initialRows.length === rows.length && + initialRows.every((row, index) => row === rows[index]), + nativeOffset: viewport?.scrollTop ?? -1, + coreOffset: virtualizer.scrollOffset ?? -1, + rangeStart: virtualizer.range?.startIndex ?? -1, + rangeEnd: virtualizer.range?.endIndex ?? -1, + indexes: virtualizer.getVirtualItems().map((item) => item.index), + domIndexes: rows.map((row) => Number(row.dataset.rowIndex)), + logicalSurfaceHeight: Number.parseFloat(surface?.style.height ?? "-1"), + renderedSurfaceHeight: surface?.getBoundingClientRect().height ?? -1, + viewportClientHeight: viewport?.clientHeight ?? -1, + viewportScrollHeight: viewport?.scrollHeight ?? -1, + visibleRows, + minimumRowTop: + rows.length && view ? Math.min(...rows.map((row) => row.getBoundingClientRect().top - view.top)) : -1, + domScrollEvents, + lastScrollTrusted, + coreOffsetCallbackCalls, + offsetCallbackSources: [...offsetCallbackSources], + rectObserverCallbacks, + ignoredDetachedZeroRects, + syntheticScrollDispatches: 0, + mutationEvents: mutationEvents.map((event) => ({ ...event })), + } + } + + onMount(() => { + if (!route || !viewport || !surface) throw new Error("Timeline fixture did not mount") + const routeRoot = route.parentElement + if (!routeRoot) throw new Error("Timeline route root did not mount") + initialRoute = route + initialViewport = viewport + initialSurface = surface + viewport.addEventListener("scroll", (event) => { + domScrollEvents++ + lastScrollTrusted = event.isTrusted + }) + const countFrames = () => { + browserFrame++ + requestAnimationFrame(countFrames) + } + requestAnimationFrame(countFrames) + new MutationObserver((records) => { + const callbackTime = performance.now() + records.forEach((record) => { + ;([...(record.removedNodes ?? [])] as Node[]).forEach((node) => { + if (node !== route) return + phase = "detached" + mutationEvents.push({ + kind: "removed", + callbackTime, + callbackFrame: browserFrame, + routeConnectedInCallback: route.isConnected, + nativeOffsetInCallback: viewport.scrollTop, + }) + }) + ;([...(record.addedNodes ?? [])] as Node[]).forEach((node) => { + if (node !== route) return + phase = "reinserted" + mutationEvents.push({ + kind: "added", + callbackTime, + callbackFrame: browserFrame, + routeConnectedInCallback: route.isConnected, + nativeOffsetInCallback: viewport.scrollTop, + }) + }) + }) + }).observe(routeRoot, { childList: true }) + window.timelineSuspense = { + prepare: async () => { + phase = "preparing" + await frames(2) + viewport.scrollTop = viewport.scrollHeight + await frames(3) + await new Promise((resolve) => setTimeout(resolve, 200)) + await frames(2) + initialRows = mountedRows() + phase = "prepared" + return snapshot() + }, + trigger: () => { + phase = "triggering" + setRefresh(true) + }, + resolve: () => { + if (!resolveResource) throw new Error("Resource is not pending") + phase = "resolving" + resolveResource() + }, + frames, + snapshot, + } + }) + + return ( +
+ +
+
+ + {(item) => ( +
+ logical row {item.index} +
+ )} +
+
+
+
+ ) + } + + return ( +
+ + + +
+ ) +} + +render(() => , document.getElementById("root")!) diff --git a/packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts b/packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts new file mode 100644 index 0000000000..7fff1737ae --- /dev/null +++ b/packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from "@playwright/test" + +const port = Number(process.env.PLAYWRIGHT_TIMELINE_SUSPENSE_PORT ?? 4317) + +export default defineConfig({ + testDir: ".", + testMatch: "timeline-suspense.repro.ts", + outputDir: "../../test-results/timeline-suspense", + fullyParallel: false, + workers: 1, + retries: 0, + reporter: "line", + timeout: 30_000, + expect: { + timeout: 10_000, + }, + webServer: { + command: `bunx vite --config vite.config.ts --host 127.0.0.1 --port ${port} --strictPort`, + cwd: import.meta.dirname, + url: `http://127.0.0.1:${port}`, + reuseExistingServer: false, + }, + use: { + baseURL: `http://127.0.0.1:${port}`, + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}) diff --git a/packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts b/packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts new file mode 100644 index 0000000000..3edae33a9b --- /dev/null +++ b/packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts @@ -0,0 +1,179 @@ +import { expect, test, type Page } from "@playwright/test" + +test.beforeEach(async ({ page }) => { + page.on("pageerror", (error) => console.error(error)) + await page.goto("/") + await expect.poll(() => page.evaluate(() => !!window.timelineSuspense)).toBe(true) +}) + +test("desired: preserves visible timeline continuity across descendant resource suspension", async ({ page }) => { + await page.goto("/?reconnect=candidate") + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.reconnect)).toBe("candidate") + const before = await prepare(page) + await triggerBaselineSuspension(page) + const pending = await page.evaluate(() => window.timelineSuspense.snapshot()) + expect(pending.nativeOffset).toBe(0) + expect(pending.coreOffset).toBe(before.coreOffset) + expect(pending.indexes).toEqual(before.indexes) + expect(pending.sameRoute).toBe(true) + expect(pending.sameViewport).toBe(true) + expect(pending.sameSurface).toBe(true) + expect(pending.sameMountedRows).toBe(true) + + await resolveSuspension(page) + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().coreOffset)).toBe(0) + await page.waitForTimeout(250) + await page.evaluate(() => window.timelineSuspense.frames(2)) + const after = await page.evaluate(() => window.timelineSuspense.snapshot()) + expect(after.sameRoute).toBe(true) + expect(after.sameViewport).toBe(true) + expect(after.sameSurface).toBe(true) + expect(after.nativeOffset).toBe(0) + expect(after.coreOffset).toBe(0) + expect(after.rangeStart).toBeLessThan(10) + expect(after.visibleRows, diagnostic({ before, pending, after })).toBeGreaterThan(0) + expect(after.domScrollEvents).toBe(before.domScrollEvents) + expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls + 1) + expect(after.offsetCallbackSources.at(-1)).toBe("observer") + expect(after.syntheticScrollDispatches).toBe(0) +}) + +test("forensic: proves detached same-node viewport leaves TanStack's bottom range blank until a real scroll", async ({ + page, +}) => { + const before = await prepare(page) + const beforeRows = before.domIndexes + expect(before.mode).toEqual({ resource: "baseline", reconnect: "baseline" }) + expect(before.logicalSurfaceHeight).toBe(80_000) + expect(before.renderedSurfaceHeight).toBe(80_000) + expect(before.viewportClientHeight).toBe(600) + expect(before.viewportScrollHeight).toBe(80_000) + expect(before.rangeStart).toBeGreaterThan(1_900) + expect(before.nativeOffset).toBe(before.coreOffset) + expect(before.visibleRows).toBeGreaterThan(0) + + await triggerBaselineSuspension(page) + const pending = await page.evaluate(() => window.timelineSuspense.snapshot()) + expect(pending.resourceState).toBe("refreshing") + expect(pending.routeConnected).toBe(false) + expect(pending.viewportConnected).toBe(false) + expect(pending.viewportOwnedByRoute).toBe(true) + expect(pending.nativeOffset).toBe(0) + expect(pending.coreOffset).toBe(before.coreOffset) + expect(pending.rangeStart).toBe(before.rangeStart) + expect(pending.rangeEnd).toBe(before.rangeEnd) + expect(pending.indexes).toEqual(before.indexes) + expect(pending.domIndexes).toEqual(beforeRows) + expect(pending.sameMountedRows).toBe(true) + expect(pending.domScrollEvents).toBe(before.domScrollEvents) + expect(pending.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls) + expect(pending.ignoredDetachedZeroRects).toBeGreaterThan(before.ignoredDetachedZeroRects) + expect(pending.mutationEvents).toHaveLength(1) + expect(pending.mutationEvents[0]).toMatchObject({ + kind: "removed", + routeConnectedInCallback: false, + nativeOffsetInCallback: 0, + }) + expect(pending.mutationEvents[0]!.callbackTime).toBeLessThanOrEqual(pending.operation.time) + expect(pending.mutationEvents[0]!.callbackFrame).toBeLessThanOrEqual(pending.operation.frame) + + const after = await resolveSuspension(page) + expect(after.resourceState).toBe("ready") + expect(after.routeConnected).toBe(true) + expect(after.viewportConnected).toBe(true) + expect(after.viewportOwnedByRoute).toBe(true) + expect(after.sameRoute).toBe(true) + expect(after.sameViewport).toBe(true) + expect(after.sameSurface).toBe(true) + expect(after.sameMountedRows).toBe(true) + expect(after.nativeOffset).toBe(0) + expect(after.coreOffset).toBe(before.coreOffset) + expect(after.rangeStart).toBe(before.rangeStart) + expect(after.rangeEnd).toBe(before.rangeEnd) + expect(after.indexes).toEqual(before.indexes) + expect(after.domIndexes).toEqual(beforeRows) + expect(after.domScrollEvents).toBe(before.domScrollEvents) + expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls) + expect(after.mutationEvents).toHaveLength(2) + expect(after.mutationEvents[1]).toMatchObject({ + kind: "added", + routeConnectedInCallback: true, + nativeOffsetInCallback: 0, + }) + expect(after.mutationEvents[1]!.callbackTime).toBeLessThanOrEqual(after.operation.time) + expect(after.mutationEvents[1]!.callbackFrame).toBeLessThanOrEqual(after.operation.frame) + expect(after.visibleRows).toBe(0) + expect(after.minimumRowTop).toBeGreaterThan(50_000) + expect(after.syntheticScrollDispatches).toBe(0) + + await page.locator("[data-viewport]").hover() + await page.mouse.wheel(0, 80) + await expect + .poll(() => + page.evaluate(() => { + const value = window.timelineSuspense.snapshot() + return value.nativeOffset > 0 && value.coreOffset === value.nativeOffset + }), + ) + .toBe(true) + await page.evaluate(() => window.timelineSuspense.frames(2)) + const recovered = await page.evaluate(() => window.timelineSuspense.snapshot()) + expect(recovered.domScrollEvents).toBeGreaterThan(after.domScrollEvents) + expect(recovered.coreOffsetCallbackCalls).toBeGreaterThan(after.coreOffsetCallbackCalls) + expect(recovered.offsetCallbackSources.at(-1)).toBe("observer") + expect(recovered.lastScrollTrusted).toBe(true) + expect(recovered.rangeStart).toBeLessThan(10) + expect(recovered.visibleRows).toBeGreaterThan(0) +}) + +test("matrix: fixture-only settled-resource guard keeps the route connected", async ({ page }) => { + await page.goto("/?resource=guard") + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.resource)).toBe("guard") + const before = await prepare(page) + + await page.evaluate(() => window.timelineSuspense.trigger()) + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing") + await page.evaluate(() => window.timelineSuspense.frames(3)) + const pending = await page.evaluate(() => window.timelineSuspense.snapshot()) + expect(pending.routeConnected).toBe(true) + expect(pending.mutationEvents).toEqual([]) + expect(pending.nativeOffset).toBe(before.nativeOffset) + expect(pending.coreOffset).toBe(before.coreOffset) + expect(pending.visibleRows).toBeGreaterThan(0) + + const after = await resolveSuspension(page) + expect(after.routeConnected).toBe(true) + expect(after.nativeOffset).toBe(before.nativeOffset) + expect(after.coreOffset).toBe(before.coreOffset) + expect(after.visibleRows).toBeGreaterThan(0) +}) + +async function prepare(page: Page) { + const before = await page.evaluate(() => window.timelineSuspense.prepare()) + expect(before.routeConnected).toBe(true) + expect(before.viewportConnected).toBe(true) + expect(before.viewportOwnedByRoute).toBe(true) + expect(before.sameMountedRows).toBe(true) + expect(before.rangeStart).toBeGreaterThan(1_900) + expect(before.nativeOffset).toBe(before.coreOffset) + return before +} + +async function triggerBaselineSuspension(page: Page) { + await page.evaluate(() => window.timelineSuspense.trigger()) + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing") + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(false) + await page.evaluate(() => window.timelineSuspense.frames(3)) +} + +async function resolveSuspension(page: Page) { + await page.evaluate(() => window.timelineSuspense.resolve()) + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("ready") + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(true) + await page.evaluate(() => window.timelineSuspense.frames(3)) + return page.evaluate(() => window.timelineSuspense.snapshot()) +} + +function diagnostic(value: unknown) { + return JSON.stringify(value, null, 2) +} diff --git a/packages/app/e2e/reproduction/timeline-suspense/vite.config.ts b/packages/app/e2e/reproduction/timeline-suspense/vite.config.ts new file mode 100644 index 0000000000..efb75ab731 --- /dev/null +++ b/packages/app/e2e/reproduction/timeline-suspense/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite" +import solid from "vite-plugin-solid" + +export default defineConfig({ + root: import.meta.dirname, + plugins: [solid()], +}) diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index 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/tsconfig.json b/packages/app/e2e/tsconfig.json index 53aacbda02..4a6046e4fa 100644 --- a/packages/app/e2e/tsconfig.json +++ b/packages/app/e2e/tsconfig.json @@ -10,6 +10,9 @@ "./performance/timeline-stability/fixture.test.ts", "./performance/timeline-stability/fixture.ts", "./performance/unit/visual-stability.test.ts", + "./reproduction/timeline-suspense/**/*.ts", + "./reproduction/timeline-suspense/**/*.tsx", + "../src/pages/session/timeline/observe-element-offset.ts", "./regression/new-session-panel-corner.spec.ts", "./regression/session-timeline-context-resize.spec.ts", "./utils/**/*.ts" diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index e1946b8fba..2bfba5871a 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -56,6 +56,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { 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 === "/permission") return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) @@ -132,6 +137,30 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }) } +function v2Session(session: { id: string } & Record, fallbackDirectory: string) { + const time = session.time && typeof session.time === "object" ? session.time : {} + return { + id: session.id, + parentID: session.parentID, + projectID: session.projectID ?? "project", + cost: session.cost ?? 0, + tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { + created: "created" in time && typeof time.created === "number" ? time.created : 0, + updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0, + ...(session.time && typeof session.time === "object" && "archived" in session.time + ? { archived: session.time.archived } + : {}), + }, + title: session.title ?? session.id, + location: { + directory: typeof session.directory === "string" ? session.directory : fallbackDirectory, + ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}), + }, + ...(typeof session.path === "string" ? { subpath: session.path } : {}), + } +} + function json(route: Route, body: unknown, headers?: Record, status = 200) { return route.fulfill({ status, diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index 186962998d..55420485f3 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -162,7 +162,7 @@ export async function installSseTransport( const request = new Request(input, init) const url = new URL(request.url) if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event")) - return originalFetch(input, init) + return originalFetch(request) const id = ++nextConnectionID const record = { diff --git a/packages/app/package.json b/packages/app/package.json index 510e2015db..482fe7ff85 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.17.18", + "version": "1.18.3", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 86263d172d..b4496ba7de 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" @@ -153,8 +163,7 @@ function LegacyTargetSessionRedirect() { } // Wraps the non-draft routes. They are gated on (and keyed to) the globally selected -// server via ServerKey, then provide the server-scoped shell (Permission/Layout/ -// Notification/Models + the visual Layout) for that server. +// server via ServerKey, then provide the server-scoped shell for that server. function SelectedServerProviders(props: ParentProps) { return ( @@ -207,7 +216,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) { - + @@ -215,7 +224,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) { - + @@ -309,24 +318,21 @@ function DesktopCommands() { // Server-scoped providers shared by the legacy shell and the top-level new shell. type ServerScopedShellProps = ParentProps<{ directory?: () => string | undefined - sessionID?: () => string | undefined serverScoped?: JSX.Element }> function ServerScopedProviders(props: ServerScopedShellProps) { return ( - - - {props.serverScoped} - {props.children} - - + + {props.serverScoped} + {props.children} + ) } function LegacyServerScopedShell(props: ServerScopedShellProps) { return ( - + {props.children} ) @@ -342,14 +348,6 @@ function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) { ) } -function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) { - return ( - - {props.children} - - ) -} - // The draft page only renders the prompt composer, so it drops TerminalProvider. // FileProvider and CommentsProvider stay because PromptInput uses file search and comment context. function DraftProviders(props: ParentProps) { @@ -559,13 +557,15 @@ export function AppInterface(props: { component={props.router ?? Router} root={(routerProps) => ( - - - - {routerProps.children} - - - + + + + + {routerProps.children} + + + + )} > diff --git a/packages/app/src/assets/help/home.png b/packages/app/src/assets/help/home.png new file mode 100644 index 0000000000..aeca6d977a Binary files /dev/null and b/packages/app/src/assets/help/home.png differ diff --git a/packages/app/src/assets/help/introducing-tabs.mp4 b/packages/app/src/assets/help/introducing-tabs.mp4 index 2bcc8aeef6..fd46be1f46 100644 Binary files a/packages/app/src/assets/help/introducing-tabs.mp4 and b/packages/app/src/assets/help/introducing-tabs.mp4 differ diff --git a/packages/app/src/assets/help/tabs.png b/packages/app/src/assets/help/tabs.png new file mode 100644 index 0000000000..d2c6e68a3d Binary files /dev/null and b/packages/app/src/assets/help/tabs.png differ diff --git a/packages/app/src/components/command-palette.ts b/packages/app/src/components/command-palette.ts index b61a61977e..59d3cbd1da 100644 --- a/packages/app/src/components/command-palette.ts +++ b/packages/app/src/components/command-palette.ts @@ -1,17 +1,18 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" import { getFilename } from "@opencode-ai/core/util/path" +import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client" 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" export type CommandPaletteEntry = { id: string @@ -24,6 +25,8 @@ export type CommandPaletteEntry = { path?: string directory?: string sessionID?: string + server?: ServerConnection.Key + project?: LocalProject archived?: number updated?: number } @@ -75,28 +78,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 +105,11 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT) const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base const category = language.t("palette.group.commands") - return sorted.map((option) => 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 +140,12 @@ 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.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }), untitled: () => language.t("command.session.new"), category: () => language.t("command.category.session"), }) @@ -191,8 +167,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 +203,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 +215,65 @@ 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?: GlobalSession[] }> 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 ?? []) + .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) : session.project?.name || 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..67c9a89929 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?: { @@ -107,8 +108,45 @@ 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 +154,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 +181,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 +539,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..85ca44ae69 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,105 @@ 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.client.experimental.session.list({ roots: true, 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 +152,7 @@ export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => v }) createEffect(() => { - palette.highlight(activeEntry()) + props.highlight(activeEntry()) }) let resultsRef: HTMLDivElement | undefined @@ -86,12 +179,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 +198,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 +210,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 +225,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 +251,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..4c58857249 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -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, @@ -27,6 +32,7 @@ import { Link } from "@/components/link" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" +import { useSettings } from "@/context/settings" import { popularProviders, useProviders } from "@/hooks/use-providers" import { CustomProviderForm } from "./dialog-custom-provider" @@ -50,32 +56,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,14 +84,76 @@ 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 }) { +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") @@ -163,6 +221,171 @@ 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 serverSync = useServerSync() + const serverSDK = useServerSDK() + 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?.() + if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) { + props.onSelect(provider) + return + } + if (store.connecting) return + setStore("connecting", provider) + void serverSDK() + .client.provider.auth() + .then((response) => { + serverSync().set("provider_auth", response.data ?? {}) + props.onSelect(provider) + }) + .catch(() => 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 @@ -173,6 +396,8 @@ function ProviderConnection(props: { const serverSync = useServerSync() const serverSDK = useServerSDK() const language = useLanguage() + const settings = useSettings() + const newLayout = settings.general.newLayoutDesigns const providers = useProviders(props.directory) const alive = { value: true } @@ -207,11 +432,19 @@ function ProviderConnection(props: { ) const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider]) const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()) + const cachedMethods = serverSync().data.provider_auth[props.provider] + const directMethod = + cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined const [store, setStore] = createStore({ - methodIndex: undefined as undefined | number, + methodIndex: directMethod as undefined | number, authorization: undefined as undefined | ProviderAuthAuthorization, promptInputs: undefined as undefined | Record, - state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", + state: (directMethod === undefined ? "pending" : undefined) as + | undefined + | "pending" + | "complete" + | "error" + | "prompt", error: undefined as string | undefined, }) @@ -279,6 +512,16 @@ function ProviderConnection(props: { 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 === "api" ? "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 @@ -519,6 +762,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 ( <>
@@ -552,11 +826,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() @@ -581,6 +862,58 @@ function ProviderConnection(props: { 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 (
@@ -605,7 +938,8 @@ function ProviderConnection(props: {
{ + if (!newLayout()) return + codeInput?.focus({ preventScroll: true }) + }) + async function handleSubmit(e: SubmitEvent) { e.preventDefault() @@ -657,6 +998,46 @@ 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 (
@@ -666,7 +1047,8 @@ function ProviderConnection(props: {
-
- -
+
+
+ +
{language.t("provider.connect.title.anthropicProMax")} @@ -750,8 +1141,12 @@ 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..9e04cd83ad 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -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() @@ -192,7 +192,7 @@ export function CustomProviderForm() {
({ + id: name.toLowerCase().replaceAll(" ", "-"), + name, + provider: { id: "opencode", name: "OpenCode" }, + cost: { input: 0, output: 0 }, + limit: { context: 128_000 }, + capabilities: { + reasoning: index !== 5, + input: { text: true, image: false, audio: false, video: false, pdf: false }, + }, + })) + const [current, setCurrent] = createSignal(models[2]) + const model = { + list: () => models, + current, + set(value) { + setCurrent(models.find((item) => item.id === value?.modelID)) + }, + } + const open = () => dialog.show(() => ) + + onMount(open) + + return ( + + ) +} + +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..de86485ef9 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,109 @@ 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/help-button.tsx b/packages/app/src/components/help-button.tsx index de156e1b33..23a955a837 100644 --- a/packages/app/src/components/help-button.tsx +++ b/packages/app/src/components/help-button.tsx @@ -1,77 +1,34 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { createSignal, Show } from "solid-js" -import { createStore } from "solid-js/store" import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer" import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4" -import { Persist, persisted } from "@/utils/persist" - -const helpIcon = ( - -) - -const triggerClass = - "size-7 !rounded-full shrink-0 bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]" +import homeImage from "@/assets/help/home.png" +import tabsImage from "@/assets/help/tabs.png" // TODO: wire to changelog / seen-state when available const showPopover = () => true -export function HelpButton() { - if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null - - const platform = usePlatform() - - return ( - { - event.preventDefault() - platform.openLink(event.currentTarget.href) - }} - > - {helpIcon} - - ) -} - // can remove this after the tabs rollout has been out for a while export function TabsInfoPopup() { - if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null - - const [state, setState] = persisted(Persist.global("tabsInfoPopup"), createStore({ dismissed: false })) - // setState({ dismissed: false }) // for testing + const settings = useSettings() + const platform = usePlatform() const [drawerOpen, setDrawerOpen] = createSignal(false) return ( - +
@@ -116,27 +73,45 @@ export function TabsInfoPopup() {

- June 16 + July 14

- } - /> + + } + /> +
-
+

- Introducing Tabs Navigation. -

-

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

+
+

OpenCode Desktop is now built around tabs.

+ +

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

+

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

+

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

+ +

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

+

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

+
diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx new file mode 100644 index 0000000000..d42a786b0d --- /dev/null +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -0,0 +1,587 @@ +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 { Prompt, 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 + edit?: PromptInputProps["edit"] + onEditLoaded?: PromptInputProps["onEditLoaded"] +} + +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() + + useCommands(props) + useEditHandler(props) + + return ( +
+ + dialog.show(() => ) + } + /> + } + /> +
+ ) +} + +const useEditHandler = (props: PromptInputV2ComposerProps) => { + const prompt = usePrompt() + + 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, + }), + ) + props.controller.dispatch({ type: "mode.normal" }) + props.controller.resetHistory() + prompt.set(edit.prompt, promptLength(edit.prompt)) + props.controller.restoreFocus() + props.onEditLoaded?.() + }, + { defer: true }, + ), + ) +} + +const useCommands = (props: PromptInputV2ComposerProps) => { + const command = useCommand() + const language = useLanguage() + + command.register("prompt-input", () => [ + { + id: "file.attach", + title: language.t("prompt.action.attachFile"), + category: language.t("command.category.file"), + keybind: "mod+u", + disabled: props.controller.state.mode !== "normal", + onSelect: () => props.controller.attach(), + }, + { + id: "prompt.mode.shell", + title: language.t("command.prompt.mode.shell"), + category: language.t("command.category.session"), + keybind: "mod+shift+x", + disabled: props.controller.state.mode === "shell", + onSelect: () => props.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: props.controller.state.mode === "normal", + onSelect: () => props.controller.dispatch({ type: "mode.normal" }), + }, + ]) +} + +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.client}:${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.client, + 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"), + }), + onError: (error) => + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }), + readClipboardImage: platform.readClipboardImage, + getPathForFile: platform.getPathForFile, + }, + view: { + placeholder: designPlaceholder, + agent: + 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: props.controls.agents.select, + } + : 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), + }, + submit: { + stopping, + working, + onSubmit: () => void submission.handleSubmit(new Event("submit")), + onStop: () => void submission.abort(), + }, + }, + }) + Object.defineProperty(controller, "model", { get: () => props.controls.model }) + 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()} + + + + + ) +} + +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 7db0583c51..bcc5acc0bb 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", @@ -633,7 +543,9 @@ export const PromptInput: Component = (props) => { const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 const handleBlur = () => { - savedCursor = currentCursor() + const cursor = currentCursor() + savedCursor = cursor + if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor) closePopover() setComposing(false) } @@ -1189,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()) } @@ -1537,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)} @@ -1605,761 +1456,335 @@ 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()} +
+
+ + ) } @@ -683,6 +806,7 @@ function HomeProjectColumn(props: { openSettings: () => void openHelp: () => void language: ReturnType + onWheel: (event: WheelEvent) => void }) { const global = useGlobal() const dialog = useDialog() @@ -699,8 +823,12 @@ function HomeProjectColumn(props: { return (
- {(_) => 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() + }, + shouldQueue: queueEnabled, + onQueue: queueFollowup, + onAbort: () => { + const id = params.id + if (!id) return + setFollowup("paused", id, true) + }, + }) + return ( + + ) + }} + + } + /> + ) + }} + {mobileTabs(true, true)} ) 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 8d5d96719c..f52b7f4b42 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" @@ -27,7 +26,6 @@ export function createPromptInputController(input: { const layout = useLayout() const local = useLocal() const providers = useProviders() - const settings = useSettings() const sync = useSync() const sdk = useSDK() const view = layout.view(input.sessionKey) @@ -41,7 +39,7 @@ export function createPromptInputController(input: { options: local.agent.list().map((agent) => agent.name), current: local.agent.current()?.name ?? "", loading: agentsQuery.isLoading, - visible: settings.visibility.customAgents(), + visible: local.agent.visible(), select: local.agent.set, }, model: { @@ -54,7 +52,6 @@ export function createPromptInputController(input: { tabs: layout.tabs(input.sessionKey), reviewPanel: view.reviewPanel, }, - newLayoutDesigns: settings.general.newLayoutDesigns(), })) } 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 b54fbc4e4c..044ebda7ae 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -757,7 +757,7 @@ function SessionFileViewV2(props: { tab: string }) { commentsUi.onLineNumberSelectionEnd(range) }} search={search} - class="select-text" + class="select-text [--opencode-diffs-bg:var(--v2-background-bg-base)]" media={{ mode: "auto", path: path(), diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 184fa684ec..0a741ef98b 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -237,6 +237,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 +551,7 @@ export function SessionSidePanel(props: { > {(toggle) => ( -
+
{toggle()(activeTab() === SESSION_OPEN_FILE_TAB)}
)} @@ -570,9 +571,15 @@ export function SessionSidePanel(props: { + {language.t("common.closeTab")} + 0}> + + + + } placement="bottom" gutter={10} > @@ -583,7 +590,7 @@ export function SessionSidePanel(props: { onClick={() => tabs().close("context")} aria-label={language.t("common.closeTab")} /> - + } hideCloseButton onMiddleClick={() => tabs().close("context")} @@ -611,9 +618,15 @@ export function SessionSidePanel(props: { + {language.t("common.closeTab")} + 0}> + + + + } placement="bottom" gutter={10} > @@ -624,7 +637,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)} diff --git a/packages/app/src/pages/session/terminal-panel-v2.tsx b/packages/app/src/pages/session/terminal-panel-v2.tsx index b74d0fe227..6a92e24ea6 100644 --- a/packages/app/src/pages/session/terminal-panel-v2.tsx +++ b/packages/app/src/pages/session/terminal-panel-v2.tsx @@ -28,7 +28,6 @@ import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" export function TerminalPanelV2(props: { stacked?: boolean } = {}) { - const delays = [120, 240] const layout = useLayout() const terminal = useTerminal() const sdk = useSDK() @@ -46,6 +45,8 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) { let root: HTMLDivElement | undefined let tabList: HTMLDivElement | undefined + onCleanup(() => terminal.cancelFocus()) + const [store, setStore] = createStore({ autoCreated: false, recovered: {} as Record, @@ -94,36 +95,12 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) { ), ) - const focus = (id: string) => { - focusTerminalById(id) - - const frame = requestAnimationFrame(() => { - if (!opened()) return - if (terminal.active() !== id) return - focusTerminalById(id) - }) - - const timers = delays.map((ms) => - window.setTimeout(() => { - if (!opened()) return - if (terminal.active() !== id) return - focusTerminalById(id) - }, ms), - ) - - return () => { - cancelAnimationFrame(frame) - for (const timer of timers) clearTimeout(timer) - } - } - createEffect( on( - () => [opened(), terminal.active()] as const, - ([next, id]) => { - if (!next || !id) return - const stop = focus(id) - onCleanup(stop) + () => [opened(), terminal.active(), terminal.focusRequested(terminal.active())] as const, + ([next, id, requested]) => { + if (!next || !id || !requested) return + focusTerminalById(id) }, ), ) @@ -285,7 +262,15 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) { onChange={(id) => terminal.open(id)} class={newLayout() ? "!h-[52px] !flex-none" : "!h-auto !flex-none"} > - + { + const active = document.activeElement + if (event.target === active) return + if (active instanceof HTMLInputElement && event.currentTarget.contains(active)) active.blur() + }} + > {(pty, index) => ( @@ -304,7 +289,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) { icon="plus-small" variant="ghost" iconSize="large" - onClick={terminal.new} + onClick={() => terminal.new({ focus: true })} aria-label={language.t("command.terminal.new")} /> @@ -326,7 +311,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) { icon="plus-small" variant="ghost" iconSize="large" - onClick={terminal.new} + onClick={() => terminal.new({ focus: true })} aria-label={language.t("command.terminal.new")} /> @@ -344,7 +329,8 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
terminal.consumeFocus(id)} class="!px-[14px]" onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)} onCleanup={ops.update} diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 54195e2ebc..2596eaee83 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -38,6 +38,8 @@ export function TerminalPanel() { const close = () => view().terminal.close() let root: HTMLDivElement | undefined + onCleanup(() => terminal.cancelFocus()) + const [store, setStore] = createStore({ autoCreated: false, activeDraggable: undefined as string | undefined, @@ -285,7 +287,7 @@ export function TerminalPanel() { icon="plus-small" variant="ghost" iconSize="large" - onClick={terminal.new} + onClick={() => terminal.new()} aria-label={language.t("command.terminal.new")} /> @@ -303,6 +305,7 @@ export function TerminalPanel() { terminal.consumeFocus(id)} onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)} onCleanup={ops.update} onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)} diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 8a1e9c5fae..2959e9f8a9 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -72,6 +72,7 @@ import { useSync } from "@/context/sync" import { notifySessionTabsRemoved } from "@/components/titlebar-session-events" import { sessionTitle } from "@/utils/session-title" import { scheduleConnectedMeasure } from "./measure" +import { observeElementOffsetReconnectAware } from "./observe-element-offset" import { createTimelineProjection } from "./projection" import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows" import { filterVirtualIndexes } from "./virtual-items" @@ -408,6 +409,7 @@ export function MessageTimeline(props: { return timelineRows().length }, getScrollElement: () => listRoot() ?? null, + observeElementOffset: observeElementOffsetReconnectAware, initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0), initialMeasurementsCache: initialMeasurements, estimateSize: () => timelineFallbackItemSize, diff --git a/packages/app/src/pages/session/timeline/observe-element-offset.test.ts b/packages/app/src/pages/session/timeline/observe-element-offset.test.ts new file mode 100644 index 0000000000..d656a31415 --- /dev/null +++ b/packages/app/src/pages/session/timeline/observe-element-offset.test.ts @@ -0,0 +1,199 @@ +import { expect, test } from "bun:test" +import { type Virtualizer } from "@tanstack/solid-virtual" +import { mutationNodesContainElement, observeElementOffsetReconnectAware } from "./observe-element-offset" + +test("matches only the scroll element or an ancestor containing it", () => { + const route = document.createElement("section") + const viewport = document.createElement("div") + const child = document.createElement("div") + const sibling = document.createElement("div") + route.append(viewport) + viewport.append(child) + + expect(mutationNodesContainElement([viewport], viewport)).toBe(true) + expect(mutationNodesContainElement([route], viewport)).toBe(true) + expect(mutationNodesContainElement([child, sibling], viewport)).toBe(false) +}) + +test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => { + const route = document.createElement("section") + const viewport = document.createElement("div") + const unrelated = document.createElement("div") + route.append(viewport) + document.body.append(route) + const instance = { + scrollElement: viewport, + targetWindow: window, + scrollOffset: 79_400, + options: { + horizontal: false, + isRtl: false, + isScrollingResetDelay: 0, + useScrollendEvent: false, + }, + } as unknown as Virtualizer + const calls: [number, boolean][] = [] + const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) => { + calls.push([offset, isScrolling]) + instance.scrollOffset = offset + }) + + document.body.append(unrelated) + unrelated.remove() + await frames(2) + expect(calls).toEqual([]) + + route.remove() + document.body.append(route) + await new Promise((resolve) => setTimeout(resolve, 0)) + await frames(3) + expect(calls).toEqual([[0, false]]) + + route.remove() + document.body.append(route) + await new Promise((resolve) => setTimeout(resolve, 0)) + await frames(3) + expect(calls).toEqual([[0, false]]) + + cleanup?.() + route.remove() +}) + +test("keeps checking until stale reset-delay callbacks can no longer win", async () => { + const route = document.createElement("section") + const viewport = document.createElement("div") + route.append(viewport) + document.body.append(route) + const instance = { + scrollElement: viewport, + targetWindow: window, + scrollOffset: 79_400, + options: { + horizontal: false, + isRtl: false, + isScrollingResetDelay: 20, + useScrollendEvent: false, + }, + } as unknown as Virtualizer + const calls: number[] = [] + const cleanup = observeElementOffsetReconnectAware(instance, (offset) => { + calls.push(offset) + instance.scrollOffset = offset + }) + + route.remove() + document.body.append(route) + await new Promise((resolve) => setTimeout(resolve, 0)) + await frames(1) + expect(instance.scrollOffset).toBe(0) + + instance.scrollOffset = 79_400 + await new Promise((resolve) => setTimeout(resolve, 25)) + await frames(3) + + expect(instance.scrollOffset).toBe(0) + expect(calls).toEqual([0, 0]) + cleanup?.() + route.remove() +}) + +test.each([ + { name: "LTR", isRtl: false, expected: 240 }, + { name: "RTL", isRtl: true, expected: -240 }, +])("reports the TanStack horizontal $name offset after reconnect", async ({ isRtl, expected }) => { + const route = document.createElement("section") + const viewport = document.createElement("div") + route.append(viewport) + document.body.append(route) + viewport.scrollLeft = 240 + const instance = { + scrollElement: viewport, + targetWindow: window, + scrollOffset: 0, + options: { + horizontal: true, + isRtl, + isScrollingResetDelay: 0, + useScrollendEvent: false, + }, + } as unknown as Virtualizer + const calls: [number, boolean][] = [] + const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) => { + calls.push([offset, isScrolling]) + instance.scrollOffset = offset + }) + + route.remove() + document.body.append(route) + await new Promise((resolve) => setTimeout(resolve, 0)) + await frames(3) + + expect(calls).toEqual([[expected, false]]) + cleanup?.() + route.remove() +}) + +test("cleanup suppresses an already queued delegated offset callback", async () => { + const viewport = document.createElement("div") + document.body.append(viewport) + viewport.scrollTop = 100 + const instance = { + scrollElement: viewport, + targetWindow: window, + scrollOffset: 0, + options: { + horizontal: false, + isRtl: false, + isScrollingResetDelay: 10, + useScrollendEvent: false, + }, + } as unknown as Virtualizer + const calls: [number, boolean][] = [] + const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) => + calls.push([offset, isScrolling]), + ) + + viewport.dispatchEvent(new Event("scroll")) + cleanup?.() + await new Promise((resolve) => setTimeout(resolve, 25)) + + expect(calls).toEqual([[100, true]]) + viewport.remove() +}) + +test("cleanup cancels reconnect checks and delegated offset observation", async () => { + const route = document.createElement("section") + const viewport = document.createElement("div") + route.append(viewport) + document.body.append(route) + const instance = { + scrollElement: viewport, + targetWindow: window, + scrollOffset: 0, + options: { + horizontal: false, + isRtl: false, + isScrollingResetDelay: 50, + useScrollendEvent: false, + }, + } as unknown as Virtualizer + const calls: number[] = [] + const cleanup = observeElementOffsetReconnectAware(instance, (offset) => calls.push(offset)) + + route.remove() + document.body.append(route) + await new Promise((resolve) => setTimeout(resolve, 0)) + cleanup?.() + instance.scrollOffset = 100 + viewport.dispatchEvent(new Event("scroll")) + await frames(4) + + expect(calls).toEqual([]) + route.remove() +}) + +async function frames(count: number) { + for (let index = 0; index < count; index++) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + } +} diff --git a/packages/app/src/pages/session/timeline/observe-element-offset.ts b/packages/app/src/pages/session/timeline/observe-element-offset.ts new file mode 100644 index 0000000000..e815423a66 --- /dev/null +++ b/packages/app/src/pages/session/timeline/observe-element-offset.ts @@ -0,0 +1,73 @@ +import { observeElementOffset, type Virtualizer } from "@tanstack/solid-virtual" + +export function observeElementOffsetReconnectAware( + instance: Virtualizer, + callback: (offset: number, isScrolling: boolean) => void, +) { + let active = true + const deliver = (offset: number, isScrolling: boolean) => { + if (!active) return + callback(offset, isScrolling) + } + const cleanupOffset = observeElementOffset(instance, deliver) + const element = instance.scrollElement + const targetWindow = instance.targetWindow + const root = element?.closest("main") ?? element?.ownerDocument.body + if (!element || !targetWindow || !root) + return () => { + active = false + cleanupOffset?.() + } + + let removed = false + let frame: number | undefined + const clearCheck = () => { + if (frame === undefined) return + targetWindow.cancelAnimationFrame(frame) + frame = undefined + } + const startCheck = () => { + clearCheck() + const deadline = targetWindow.performance.now() + instance.options.isScrollingResetDelay + let framesAfterDeadline = 0 + const check = (time: number) => { + frame = undefined + if (element.isConnected) { + const offset = instance.options.horizontal + ? element.scrollLeft * (instance.options.isRtl ? -1 : 1) + : element.scrollTop + if (instance.scrollOffset === null || Math.abs(offset - instance.scrollOffset) > 1) deliver(offset, false) + } + if (time >= deadline) framesAfterDeadline += 1 + if (framesAfterDeadline >= 2) return + frame = targetWindow.requestAnimationFrame(check) + } + frame = targetWindow.requestAnimationFrame(check) + } + const observer = new targetWindow.MutationObserver((records) => { + if (!active) return + records.forEach((record) => { + if (record.target === element || element.contains(record.target)) return + if (mutationNodesContainElement(record.removedNodes, element)) { + removed = true + clearCheck() + } + if (!removed || !element.isConnected || !mutationNodesContainElement(record.addedNodes, element)) return + removed = false + startCheck() + }) + }) + // Session routes are replaced below persistent main; body is the fallback for isolated hosts. + observer.observe(root, { childList: true, subtree: true }) + + return () => { + active = false + observer.disconnect() + clearCheck() + cleanupOffset?.() + } +} + +export function mutationNodesContainElement(nodes: Iterable, element: Element) { + return [...nodes].some((node) => node === element || node.contains(element)) +} diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index 72cd9a180c..a9c9a2d288 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -1,7 +1,8 @@ import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" -import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2" +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" @@ -137,14 +138,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 +163,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-composer-commands.tsx b/packages/app/src/pages/session/use-composer-commands.tsx index 9b75c7bf4c..e7e51489ee 100644 --- a/packages/app/src/pages/session/use-composer-commands.tsx +++ b/packages/app/src/pages/session/use-composer-commands.tsx @@ -1,7 +1,6 @@ import { useCommand, type CommandOption } from "@/context/command" import { useLanguage } from "@/context/language" import { useLocal, type ModelSelection } from "@/context/local" -import { useSettings } from "@/context/settings" import { useDialog } from "@opencode-ai/ui/context/dialog" import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom" import { useSessionLayout } from "./session-layout" @@ -19,7 +18,6 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { const dialog = useDialog() const language = useLanguage() const local = useLocal() - const settings = useSettings() const { sessionKey } = useSessionLayout() const sessionOwnership = createSessionOwnership(sessionKey) const model = input.model ?? local.model @@ -70,7 +68,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { description: language.t("command.agent.cycle.description"), keybind: "mod+.", slash: "agent", - disabled: !settings.visibility.customAgents(), + disabled: !local.agent.visible(), onSelect: () => local.agent.move(1), }), agentCommand({ @@ -78,7 +76,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { title: language.t("command.agent.cycle.reverse"), description: language.t("command.agent.cycle.reverse.description"), keybind: "shift+mod+.", - disabled: !settings.visibility.customAgents(), + disabled: !local.agent.visible(), onSelect: () => local.agent.move(-1), }), ]) diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index e23268c64b..275e6ec4bc 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -264,7 +264,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => { } const openTerminal = () => { - if (terminal.all().length > 0) terminal.new() + if (terminal.all().length > 0) terminal.new({ focus: true }) + if (terminal.all().length === 0) terminal.requestFocus() view().terminal.open() } @@ -467,7 +468,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { id: "file.open", title: language.t("command.file.open"), description: language.t("palette.search.placeholder"), - keybind: "mod+k,mod+p", + keybind: "mod+p", slash: "open", onSelect: openFile, }), @@ -498,7 +499,15 @@ export const useSessionCommands = (actions: SessionCommandContext) => { title: language.t("command.terminal.toggle"), keybind: "ctrl+`", slash: "terminal", - onSelect: () => view().terminal.toggle(), + onSelect: () => { + if (view().terminal.opened()) { + terminal.cancelFocus() + view().terminal.close() + return + } + terminal.requestFocus(terminal.active()) + view().terminal.open() + }, }), viewCommand({ id: "review.toggle", diff --git a/packages/app/src/pages/session/v2/review-panel-v2-state.ts b/packages/app/src/pages/session/v2/review-panel-v2-state.ts index d5894bfb69..645055e107 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2-state.ts +++ b/packages/app/src/pages/session/v2/review-panel-v2-state.ts @@ -9,7 +9,7 @@ import { createStore } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" export function createReviewPanelV2State() { - const [store, setStore] = persisted( + const [store, setStore, , ready] = persisted( Persist.global("review-panel-v2"), createStore({ sidebarOpened: true, @@ -24,6 +24,7 @@ export function createReviewPanelV2State() { return { sidebarOpened: () => store.sidebarOpened, sidebarWidth: () => store.sidebarWidth, + sidebarTransition: ready, filter, setFilter, expandMode: () => store.expandMode, 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 143b4f5f52..4f0cf612e1 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -200,6 +200,7 @@ function ReviewPanelV2Sidebar(props: { return ( } filter={props.state.filter()} diff --git a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx index 04ad1e3c82..639429e80b 100644 --- a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx +++ b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx @@ -19,6 +19,7 @@ const emptyFiles: string[] = [] export type SessionFileBrowserState = { sidebarOpened: () => boolean sidebarWidth: () => number + sidebarTransition: () => boolean resizeSidebar: (width: number) => void toggleSidebar: () => void } @@ -96,6 +97,7 @@ export function SessionFileBrowserTab(props: { sidebar={ {title()}} filter={filter()} onFilterChange={setFilter} 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..421a2e71fd --- /dev/null +++ b/packages/app/test-browser/command-palette.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test" +import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client" +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: GlobalSession = { + id: "session-1", + slug: "session-1", + projectID: stored.id, + directory: stored.worktree, + title: "Palette session", + version: "1", + time: { created: 1, updated: 2 }, + project: { id: stored.id, name: stored.name, worktree: stored.worktree }, +} + +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-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/review-panel-v2-state.test.ts b/packages/app/test-browser/review-panel-v2-state.test.ts new file mode 100644 index 0000000000..2a7862ce17 --- /dev/null +++ b/packages/app/test-browser/review-panel-v2-state.test.ts @@ -0,0 +1,65 @@ +import { beforeAll, expect, mock, test } from "bun:test" +import type { AsyncStorage } from "@solid-primitives/storage" +import { createEffect, createRoot } from "solid-js" + +let createReviewPanelV2State: typeof import("@/pages/session/v2/review-panel-v2-state").createReviewPanelV2State +let read: ((value: string | null) => void) | undefined + +const storage: AsyncStorage = { + getItem: () => new Promise((resolve) => (read = resolve)), + setItem: async () => undefined, + removeItem: async () => undefined, + clear: async () => undefined, + key: async () => null, + getLength: async () => 0, + length: Promise.resolve(0), +} + +beforeAll(async () => { + mock.module("@opencode-ai/session-ui/v2/session-review-v2", () => ({ + SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT: 240, + SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN: 200, + SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX: 480, + })) + mock.module("@/context/platform", () => ({ + usePlatform: () => ({ platform: "desktop", storage: () => storage }), + })) + + createReviewPanelV2State = (await import("@/pages/session/v2/review-panel-v2-state")).createReviewPanelV2State +}) + +test("enables sidebar motion only after custom width hydration", async () => { + await new Promise((resolve, reject) => { + createRoot((dispose) => { + const state = createReviewPanelV2State() + const transition = + "sidebarTransition" in state && typeof state.sidebarTransition === "function" + ? (state.sidebarTransition as () => boolean) + : undefined + + try { + expect(transition).toBeFunction() + expect(transition?.()).toBeFalse() + expect(state.sidebarWidth()).toBe(240) + } catch (error) { + dispose() + reject(error) + return + } + + createEffect(() => { + if (!transition?.()) return + try { + expect(state.sidebarWidth()).toBe(360) + dispose() + resolve() + } catch (error) { + dispose() + reject(error) + } + }) + + read?.(JSON.stringify({ sidebarOpened: true, sidebarWidth: 360, expandMode: "collapse" })) + }) + }) +}) diff --git a/packages/app/test-browser/solid-virtual.test.ts b/packages/app/test-browser/solid-virtual.test.ts index 716fa7fa9e..9327eacf42 100644 --- a/packages/app/test-browser/solid-virtual.test.ts +++ b/packages/app/test-browser/solid-virtual.test.ts @@ -1,8 +1,32 @@ import { expect, test } from "bun:test" -import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" +import { createVirtualizer, defaultRangeExtractor, Virtualizer } from "@tanstack/solid-virtual" import { createRoot, createSignal } from "solid-js" import { filterVirtualIndexes } from "@/pages/session/timeline/virtual-items" +test("end anchoring survives consecutive resizes when the first scroll write is clamped", () => { + const writes: { offset: number; adjustments?: number }[] = [] + const virtualizer = new Virtualizer({ + count: 5, + estimateSize: () => 50, + initialOffset: 50, + initialRect: { width: 400, height: 200 }, + anchorTo: "end", + scrollEndThreshold: 1, + getScrollElement: () => null, + scrollToFn: (offset, options) => writes.push({ offset, adjustments: options.adjustments }), + observeElementRect: () => {}, + observeElementOffset: () => {}, + }) + + virtualizer.getTotalSize() + virtualizer.resizeItem(4, 120) + expect(writes).toEqual([{ offset: 50, adjustments: 70 }]) + writes.length = 0 + + virtualizer.resizeItem(4, 200) + expect(writes).toEqual([{ offset: 120, adjustments: 80 }]) +}) + test("reactive count updates preserve measured row sizes", () => { createRoot((dispose) => { const [count, setCount] = createSignal(2) @@ -42,23 +66,26 @@ test("initial rect projects rows before a scroll element connects", () => { }) }) -test("logical scroll offset includes pending measurement adjustments", () => { - createRoot((dispose) => { - const virtualizer = createVirtualizer({ - count: 2, - getScrollElement: () => null, - estimateSize: () => 60, - initialOffset: 100, - initialRect: { width: 800, height: 60 }, - }) - - virtualizer.getTotalSize() - virtualizer.resizeItem(0, 100) - - expect(virtualizer.scrollOffset).toBe(100) - expect(virtualizer.getLogicalScrollOffset()).toBe(140) - dispose() +test("clamps oversized offsets with scroll margin and padding changes", () => { + const options = (paddingEnd: number) => ({ + count: 20, + estimateSize: () => 60, + initialOffset: Number.MAX_SAFE_INTEGER, + initialRect: { width: 800, height: 600 }, + scrollMargin: 64, + paddingEnd, + overscan: 1, + getScrollElement: () => null, + scrollToFn: () => {}, + observeElementRect: () => {}, + observeElementOffset: () => {}, }) + const virtualizer = new Virtualizer(options(64)) + + expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 18, 19]) + + virtualizer.setOptions(options(600)) + expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([18, 19]) }) test("stale pinned indexes do not produce missing virtual items after count shrinks", () => { diff --git a/packages/cli/package.json b/packages/cli/package.json index dd660aa2ee..2152bc3e2a 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.17.18", + "version": "1.18.3", "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/codemode/package.json b/packages/codemode/package.json index 263daa6a53..45a7eb80d9 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.17.18", + "version": "1.18.3", "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 35d31f9342..c10a9aff34 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.17.18", + "version": "1.18.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/app/src/app.tsx b/packages/console/app/src/app.tsx index 1f1d0066ec..99b1010767 100644 --- a/packages/console/app/src/app.tsx +++ b/packages/console/app/src/app.tsx @@ -9,6 +9,7 @@ import "./app.css" import { LanguageProvider } from "~/context/language" import { I18nProvider, useI18n } from "~/context/i18n" import { strip } from "~/lib/language" +import { DesktopPromo } from "~/component/desktop-promo" function AppMeta() { const i18n = useI18n() @@ -33,6 +34,7 @@ export default function App() { {props.children} + diff --git a/packages/console/app/src/asset/lander/desktop-tabs-landscape.mp4 b/packages/console/app/src/asset/lander/desktop-tabs-landscape.mp4 new file mode 100644 index 0000000000..c6e710c024 Binary files /dev/null and b/packages/console/app/src/asset/lander/desktop-tabs-landscape.mp4 differ diff --git a/packages/console/app/src/component/desktop-promo.css b/packages/console/app/src/component/desktop-promo.css new file mode 100644 index 0000000000..5e9a4863c0 --- /dev/null +++ b/packages/console/app/src/component/desktop-promo.css @@ -0,0 +1,103 @@ +[data-component="desktop-promo"] { + --promo-background: hsl(0, 20%, 99%); + --promo-background-weak: hsl(0, 8%, 97%); + --promo-text: hsl(0, 1%, 39%); + --promo-text-strong: hsl(0, 5%, 12%); + --promo-border: hsla(0, 100%, 3%, 0.12); + + position: fixed; + z-index: 20; + right: 1.5rem; + bottom: 1.5rem; + width: min(28rem, calc(100vw - 2rem)); + padding: 4px; + overflow: hidden; + color: var(--promo-text); + border: 1px solid var(--promo-border); + border-radius: 8px; + background: var(--promo-background); + box-shadow: 0 0.75rem 2rem rgb(0 0 0 / 15%); + font-family: var(--font-mono); + + @media (prefers-color-scheme: dark) { + --promo-background: hsl(0, 9%, 7%); + --promo-background-weak: hsl(0, 6%, 10%); + --promo-text: hsl(0, 4%, 71%); + --promo-text-strong: hsl(0, 15%, 94%); + --promo-border: hsl(0, 4%, 23%); + } + + @media (max-width: 40rem) { + right: 1rem; + bottom: 1rem; + } + + [data-slot="desktop-promo-link"] { + display: block; + color: var(--promo-text); + text-decoration: none; + } + + [data-slot="desktop-promo-link"]:focus-visible { + outline: 2px solid var(--promo-text-strong); + outline-offset: -3px; + } + + video { + display: block; + width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; + border-radius: 4px; + background: var(--promo-background-weak); + } + + [data-slot="desktop-promo-copy"] { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 1rem; + font-size: 0.875rem; + line-height: 1.4; + } + + [data-slot="desktop-promo-copy"] strong { + color: var(--promo-text-strong); + font-weight: 500; + } + + [data-slot="desktop-promo-close"] { + position: absolute; + top: 0.5rem; + right: 0.5rem; + display: grid; + width: 2rem; + height: 2rem; + padding: 0; + place-items: center; + cursor: pointer; + color: white; + border: none; + border-radius: 0.25rem; + background: rgb(0 0 0 / 70%); + opacity: 0; + transition: + opacity 150ms ease, + background 150ms ease; + } + + &:hover [data-slot="desktop-promo-close"], + [data-slot="desktop-promo-close"]:focus-visible { + opacity: 1; + } + + [data-slot="desktop-promo-close"]:hover { + background: rgb(0 0 0 / 90%); + } + + @media (hover: none) { + [data-slot="desktop-promo-close"] { + opacity: 1; + } + } +} diff --git a/packages/console/app/src/component/desktop-promo.tsx b/packages/console/app/src/component/desktop-promo.tsx new file mode 100644 index 0000000000..504088e3c7 --- /dev/null +++ b/packages/console/app/src/component/desktop-promo.tsx @@ -0,0 +1,60 @@ +import "./desktop-promo.css" +import { A, useLocation } from "@solidjs/router" +import { createSignal, Show } from "solid-js" +import { getRequestEvent } from "solid-js/web" +import desktopPromoVideo from "~/asset/lander/desktop-tabs-landscape.mp4" +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 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" + + return ( + + + + ) +} diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index ddb66b4135..1102332286 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -102,10 +102,14 @@ export const dict = { "temp.logoDarkAlt": "شعار opencode الداكن", "home.banner.badge": "جديد", - "home.banner.text": "تطبيق سطح المكتب متاح بنسخة تجريبية", - "home.banner.platforms": "على macOS، Windows، وLinux", + "home.banner.text": "نقدّم علامات التبويب لتطبيق سطح المكتب.", + "home.banner.platforms": "متاحة على macOS وWindows وLinux", "home.banner.downloadNow": "حمّل الآن", "home.banner.downloadBetaNow": "حمّل النسخة التجريبية لتطبيق سطح المكتب الآن", + "home.promo.title": "نقدّم علامات التبويب لتطبيق سطح المكتب", + "home.promo.body": "نظّم عملك وجلساتك النشطة باستخدام علامات التبويب.", + "home.promo.cta": "نزّل أحدث إصدار للبدء.", + "home.promo.close": "إغلاق إعلان تطبيق سطح المكتب", "home.hero.title": "وكيل برمجة بالذكاء الاصطناعي مفتوح المصدر", "home.hero.subtitle.a": "نماذج مجانية مضمّنة أو اربط أي نموذج من أي مزوّد،", @@ -248,8 +252,9 @@ export const dict = { "zen.privacy.exceptionsLink": "الاستثناءات التالية", "go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع", + "go.banner.text": "يحصل Kimi K3 على حدود استخدام مضاعفة لفترة محدودة", "go.meta.description": - "يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.2 وGLM-5.1 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.", + "يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.", "go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع", "go.hero.body": "يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.", @@ -266,7 +271,6 @@ export const dict = { "go.graph.go": "Go", "go.graph.label": "الطلبات كل 5 ساعات", "go.graph.usageLimits": "حدود الاستخدام", - "go.graph.tick": "{{n}}x", "go.graph.aria": "الطلبات كل 5 ساعات: {{free}} مقابل {{go}}", "go.testimonials.brand.zen": "Zen", @@ -298,7 +302,7 @@ export const dict = { "go.problem.item2": "حدود سخية ووصول موثوق", "go.problem.item3": "مصمم لأكبر عدد ممكن من المبرمجين", "go.problem.item4": - "يتضمن GLM-5.2 وGLM-5.1 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash", + "يتضمن Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash", "go.how.title": "كيف يعمل Go", "go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.", "go.how.step1.title": "أنشئ حسابًا", @@ -322,7 +326,7 @@ export const dict = { "go.faq.a2": "يتضمن Go النماذج المدرجة أدناه، مع حدود سخية وإتاحة موثوقة.", "go.faq.q3": "هل Go هو نفسه Zen؟", "go.faq.a3": - "لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5.2 وGLM-5.1 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.", + "لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.", "go.faq.q4": "كم تكلفة Go؟", "go.faq.a4.p1.beforePricing": "تكلفة Go", "go.faq.a4.p1.pricingLink": "$5 للشهر الأول", @@ -345,7 +349,7 @@ export const dict = { "go.faq.q9": "ما الفرق بين النماذج المجانية وGo؟", "go.faq.a9": - "تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5.2 وGLM-5.1 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).", + "تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).", "zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.", "zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم", @@ -700,11 +704,11 @@ export const dict = { "download.title": "OpenCode | تنزيل", "download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux", - "download.hero.title": "تنزيل OpenCode", + "download.hero.title": "تنزيل OpenCode لسطح المكتب", "download.hero.subtitle": "متاح في نسخة تجريبية لـ macOS، Windows، وLinux", "download.hero.button": "تنزيل لـ {{os}}", "download.section.terminal": "OpenCode للطرفية", - "download.section.desktop": "OpenCode لسطح المكتب (Beta)", + "download.section.desktop": "OpenCode لسطح المكتب", "download.section.extensions": "امتدادات OpenCode", "download.section.integrations": "تكاملات OpenCode", "download.action.download": "تنزيل", diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 3c00dc8643..9df305b8d8 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -102,10 +102,14 @@ export const dict = { "temp.logoDarkAlt": "logo opencode escuro", "home.banner.badge": "Novo", - "home.banner.text": "App desktop disponível em beta", - "home.banner.platforms": "no macOS, Windows e Linux", + "home.banner.text": "Conheça as abas no app desktop.", + "home.banner.platforms": "Disponível no macOS, Windows e Linux", "home.banner.downloadNow": "Baixar agora", "home.banner.downloadBetaNow": "Baixe agora o beta do desktop", + "home.promo.title": "Conheça as abas no app desktop", + "home.promo.body": "Organize seu trabalho e suas sessões ativas com abas.", + "home.promo.cta": "Baixe a versão mais recente para começar.", + "home.promo.close": "Fechar anúncio do app desktop", "home.hero.title": "O agente de codificação de código aberto com IA", "home.hero.subtitle.a": "Modelos grátis incluídos ou conecte qualquer modelo de qualquer provedor,", @@ -252,8 +256,9 @@ export const dict = { "zen.privacy.exceptionsLink": "seguintes exceções", "go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos", + "go.banner.text": "Kimi K3 tem limites de uso 2x maiores por tempo limitado", "go.meta.description": - "O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.hero.title": "Modelos de codificação de baixo custo para todos", "go.hero.body": "O Go traz a codificação com agentes para programadores em todo o mundo. Oferecendo limites generosos e acesso confiável aos modelos de código aberto mais capazes, para que você possa construir com agentes poderosos sem se preocupar com custos ou disponibilidade.", @@ -270,7 +275,6 @@ export const dict = { "go.graph.go": "Go", "go.graph.label": "Requisições por 5 horas", "go.graph.usageLimits": "Limites de uso", - "go.graph.tick": "{{n}}x", "go.graph.aria": "Requisições por 5h: {{free}} vs {{go}}", "go.testimonials.brand.zen": "Zen", @@ -303,7 +307,7 @@ export const dict = { "go.problem.item2": "Limites generosos e acesso confiável", "go.problem.item3": "Feito para o maior número possível de programadores", "go.problem.item4": - "Inclui GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash", + "Inclui Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash", "go.how.title": "Como o Go funciona", "go.how.body": "O Go começa em $5 no primeiro mês, depois $10/mês. Você pode usá-lo com o OpenCode ou qualquer agente.", @@ -329,7 +333,7 @@ export const dict = { "go.faq.a2": "O Go inclui os modelos listados abaixo, com limites generosos e acesso confiável.", "go.faq.q3": "O Go é o mesmo que o Zen?", "go.faq.a3": - "Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.faq.q4": "Quanto custa o Go?", "go.faq.a4.p1.beforePricing": "O Go custa", "go.faq.a4.p1.pricingLink": "$5 no primeiro mês", @@ -353,7 +357,7 @@ export const dict = { "go.faq.q9": "Qual a diferença entre os modelos gratuitos e o Go?", "go.faq.a9": - "Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).", + "Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).", "zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.", "zen.api.error.modelNotSupported": "Modelo {{model}} não suportado", @@ -711,11 +715,11 @@ export const dict = { "download.title": "OpenCode | Baixar", "download.meta.description": "Baixe o OpenCode para macOS, Windows e Linux", - "download.hero.title": "Baixar OpenCode", + "download.hero.title": "Baixar OpenCode Desktop", "download.hero.subtitle": "Disponível em Beta para macOS, Windows e Linux", "download.hero.button": "Baixar para {{os}}", "download.section.terminal": "OpenCode Terminal", - "download.section.desktop": "OpenCode Desktop (Beta)", + "download.section.desktop": "OpenCode Desktop", "download.section.extensions": "Extensões OpenCode", "download.section.integrations": "Integrações OpenCode", "download.action.download": "Baixar", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index b87aa04cd9..286fa1ea93 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -102,10 +102,14 @@ export const dict = { "temp.logoDarkAlt": "opencode logo dark", "home.banner.badge": "Ny", - "home.banner.text": "Desktop-app tilgængelig i beta", - "home.banner.platforms": "på macOS, Windows og Linux", + "home.banner.text": "Introduktion af Tabs til Desktop.", + "home.banner.platforms": "Tilgængelig på macOS, Windows og Linux", "home.banner.downloadNow": "Download nu", "home.banner.downloadBetaNow": "Download desktop-betaen nu", + "home.promo.title": "Introduktion af Tabs til Desktop", + "home.promo.body": "Organiser dit arbejde og aktive sessioner med faner.", + "home.promo.cta": "Download den nyeste version for at komme i gang.", + "home.promo.close": "Luk meddelelsen om Desktop-appen", "home.hero.title": "Den open source AI-kodningsagent", "home.hero.subtitle.a": "Gratis modeller inkluderet, eller forbind enhver model fra enhver udbyder,", @@ -250,8 +254,9 @@ export const dict = { "zen.privacy.exceptionsLink": "følgende undtagelser", "go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle", + "go.banner.text": "Kimi K3 får fordoblet brugsgrænse i en begrænset periode", "go.meta.description": - "Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.hero.title": "Kodningsmodeller til lav pris for alle", "go.hero.body": "Go bringer agentisk kodning til programmører over hele verden. Med generøse grænser og pålidelig adgang til de mest kapable open source-modeller, så du kan bygge med kraftfulde agenter uden at bekymre dig om omkostninger eller tilgængelighed.", @@ -268,7 +273,6 @@ export const dict = { "go.graph.go": "Go", "go.graph.label": "Forespørgsler pr. 5 timer", "go.graph.usageLimits": "Brugsgrænser", - "go.graph.tick": "{{n}}x", "go.graph.aria": "Forespørgsler pr. 5t: {{free}} vs {{go}}", "go.testimonials.brand.zen": "Zen", @@ -300,7 +304,7 @@ export const dict = { "go.problem.item2": "Generøse grænser og pålidelig adgang", "go.problem.item3": "Bygget til så mange programmører som muligt", "go.problem.item4": - "Inkluderer GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash", + "Inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash", "go.how.title": "Hvordan Go virker", "go.how.body": "Go starter ved $5 for den første måned, derefter $10/måned. Du kan bruge det med OpenCode eller enhver agent.", @@ -326,7 +330,7 @@ export const dict = { "go.faq.a2": "Go inkluderer modellerne nedenfor med generøse grænser og pålidelig adgang.", "go.faq.q3": "Er Go det samme som Zen?", "go.faq.a3": - "Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.faq.q4": "Hvad koster Go?", "go.faq.a4.p1.beforePricing": "Go koster", "go.faq.a4.p1.pricingLink": "$5 første måned", @@ -349,7 +353,7 @@ export const dict = { "go.faq.q9": "Hvad er forskellen på gratis modeller og Go?", "go.faq.a9": - "Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).", + "Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).", "zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.", "zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke", @@ -706,11 +710,11 @@ export const dict = { "download.title": "OpenCode | Download", "download.meta.description": "Download OpenCode til macOS, Windows og Linux", - "download.hero.title": "Download OpenCode", + "download.hero.title": "Download OpenCode Desktop", "download.hero.subtitle": "Tilgængelig i beta til macOS, Windows og Linux", "download.hero.button": "Download til {{os}}", "download.section.terminal": "OpenCode Terminal", - "download.section.desktop": "OpenCode Desktop (Beta)", + "download.section.desktop": "OpenCode Desktop", "download.section.extensions": "OpenCode Extensions", "download.section.integrations": "OpenCode Integrations", "download.action.download": "Download", diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 5579796f3a..59abd02cc1 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -102,10 +102,14 @@ export const dict = { "temp.logoDarkAlt": "OpenCode Logo dunkel", "home.banner.badge": "Neu", - "home.banner.text": "Desktop-App in der Beta verfügbar", - "home.banner.platforms": "auf macOS, Windows und Linux", + "home.banner.text": "Neu: Tabs für Desktop.", + "home.banner.platforms": "Verfügbar für macOS, Windows und Linux", "home.banner.downloadNow": "Jetzt herunterladen", "home.banner.downloadBetaNow": "Desktop-Beta jetzt herunterladen", + "home.promo.title": "Neu: Tabs für Desktop", + "home.promo.body": "Organisiere deine Arbeit und aktiven Sitzungen mit Tabs.", + "home.promo.cta": "Lade die neueste Version herunter, um loszulegen.", + "home.promo.close": "Ankündigung zur Desktop-App schließen", "home.hero.title": "Der Open-Source AI-Coding-Agent", "home.hero.subtitle.a": "Kostenlose Modelle inklusive oder verbinde jedes Modell eines beliebigen Anbieters,", @@ -252,8 +256,9 @@ export const dict = { "zen.privacy.exceptionsLink": "folgenden Ausnahmen", "go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle", + "go.banner.text": "Kimi K3 erhält für begrenzte Zeit 2x Nutzungslimits", "go.meta.description": - "Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.", + "Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.", "go.hero.title": "Kostengünstige Coding-Modelle für alle", "go.hero.body": "Go bringt Agentic Coding zu Programmierern auf der ganzen Welt. Mit großzügigen Limits und zuverlässigem Zugang zu den leistungsfähigsten Open-Source-Modellen, damit du mit leistungsstarken Agenten entwickeln kannst, ohne dir Gedanken über Kosten oder Verfügbarkeit zu machen.", @@ -270,7 +275,6 @@ export const dict = { "go.graph.go": "Go", "go.graph.label": "Anfragen pro 5 Stunden", "go.graph.usageLimits": "Nutzungslimits", - "go.graph.tick": "{{n}}x", "go.graph.aria": "Anfragen pro 5h: {{free}} vs {{go}}", "go.testimonials.brand.zen": "Zen", @@ -302,7 +306,7 @@ export const dict = { "go.problem.item2": "Großzügige Limits und zuverlässiger Zugang", "go.problem.item3": "Für so viele Programmierer wie möglich gebaut", "go.problem.item4": - "Beinhaltet GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash", + "Beinhaltet Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash", "go.how.title": "Wie Go funktioniert", "go.how.body": "Go beginnt bei $5 für den ersten Monat, danach $10/Monat. Du kannst es mit OpenCode oder jedem Agenten nutzen.", @@ -328,7 +332,7 @@ export const dict = { "go.faq.a2": "Go umfasst die unten aufgeführten Modelle mit großzügigen Limits und zuverlässigem Zugriff.", "go.faq.q3": "Ist Go dasselbe wie Zen?", "go.faq.a3": - "Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.", + "Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.", "go.faq.q4": "Wie viel kostet Go?", "go.faq.a4.p1.beforePricing": "Go kostet", "go.faq.a4.p1.pricingLink": "$5 im ersten Monat", @@ -352,7 +356,7 @@ export const dict = { "go.faq.q9": "Was ist der Unterschied zwischen kostenlosen Modellen und Go?", "go.faq.a9": - "Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).", + "Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).", "zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.", "zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt", @@ -711,11 +715,11 @@ export const dict = { "download.title": "OpenCode | Download", "download.meta.description": "Lade OpenCode für macOS, Windows und Linux herunter", - "download.hero.title": "OpenCode herunterladen", + "download.hero.title": "OpenCode Desktop herunterladen", "download.hero.subtitle": "In Beta verfügbar für macOS, Windows und Linux", "download.hero.button": "Download für {{os}}", "download.section.terminal": "OpenCode Terminal", - "download.section.desktop": "OpenCode Desktop (Beta)", + "download.section.desktop": "OpenCode Desktop", "download.section.extensions": "OpenCode Extensions", "download.section.integrations": "OpenCode Integrationen", "download.action.download": "Download", diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index 9cd10f6407..413037022a 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -102,11 +102,16 @@ export const dict = { "temp.logoDarkAlt": "opencode logo dark", "home.banner.badge": "New", - "home.banner.text": "Desktop app available in beta", - "home.banner.platforms": "on macOS, Windows, and Linux", + "home.banner.text": "Introducing tabs for desktop.", + "home.banner.platforms": "Available on macOS, Windows, and Linux", "home.banner.downloadNow": "Download now", "home.banner.downloadBetaNow": "Download the desktop beta now", + "home.promo.title": "Introducing Tabs for Desktop", + "home.promo.body": "Organize your work and active sessions with tabs.", + "home.promo.cta": "Download the latest to get started.", + "home.promo.close": "Dismiss desktop app announcement", + "home.hero.title": "The open source AI coding agent", "home.hero.subtitle.a": "Free models included or connect any model from any provider,", "home.hero.subtitle.b": "including Claude, GPT, Gemini and more.", @@ -248,8 +253,9 @@ export const dict = { "zen.privacy.exceptionsLink": "following exceptions", "go.title": "OpenCode Go | Low cost coding models for everyone", + "go.banner.text": "Kimi K3 gets 2× usage limits for a limited time", "go.meta.description": - "Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.", + "Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.", "go.hero.title": "Low cost coding models for everyone", "go.hero.body": "Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable open-source models, so you can build with powerful agents without worrying about cost or availability.", @@ -265,7 +271,6 @@ export const dict = { "go.graph.go": "Go", "go.graph.label": "Requests per 5 hour", "go.graph.usageLimits": "Usage limits", - "go.graph.tick": "{{n}}x", "go.graph.aria": "Requests per 5h: {{free}} vs {{go}}", "go.testimonials.brand.zen": "Zen", @@ -297,7 +302,7 @@ export const dict = { "go.problem.item2": "Generous limits and reliable access", "go.problem.item3": "Built for as many programmers as possible", "go.problem.item4": - "Includes GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash", + "Includes Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash", "go.how.title": "How Go works", "go.how.body": "Go starts at $5 for your first month, then $10/month. You can use it with OpenCode or any agent.", "go.how.step1.title": "Create an account", @@ -322,7 +327,7 @@ export const dict = { "go.faq.a2": "Go includes the models listed below, with generous limits and reliable access.", "go.faq.q3": "Is Go the same as Zen?", "go.faq.a3": - "No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.", + "No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.", "go.faq.q4": "How much does Go cost?", "go.faq.a4.p1.beforePricing": "Go costs", "go.faq.a4.p1.pricingLink": "$5 first month", @@ -346,7 +351,7 @@ export const dict = { "go.faq.q9": "What is the difference between free models and Go?", "go.faq.a9": - "Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).", + "Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).", "zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.", "zen.api.error.modelNotSupported": "Model {{model}} is not supported", @@ -703,11 +708,11 @@ export const dict = { "download.title": "OpenCode | Download", "download.meta.description": "Download OpenCode for macOS, Windows, and Linux", - "download.hero.title": "Download OpenCode", + "download.hero.title": "Download OpenCode Desktop", "download.hero.subtitle": "Available in Beta for macOS, Windows, and Linux", "download.hero.button": "Download for {{os}}", "download.section.terminal": "OpenCode Terminal", - "download.section.desktop": "OpenCode Desktop (Beta)", + "download.section.desktop": "OpenCode Desktop", "download.section.extensions": "OpenCode Extensions", "download.section.integrations": "OpenCode Integrations", "download.action.download": "Download", @@ -772,6 +777,10 @@ export const dict = { "enterprise.faq.q4": "Is my data secure with OpenCode Enterprise?", "enterprise.faq.a4": "Yes. OpenCode does not store your code or context data. All processing happens locally or through direct API calls to your AI provider. With central config and SSO integration, your data remains secure within your organization's infrastructure.", + "enterprise.faq.q5": "Where can I find your security and compliance documentation?", + "enterprise.faq.a5.before": + "Our Trust Center has everything: SOC 2 Type 2 report, security policies, subprocessor list, and answers to common security questions. Visit", + "enterprise.faq.a5.after": "to review or request documents under NDA.", "brand.title": "OpenCode | Brand", "brand.meta.description": "OpenCode brand guidelines", diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index 96d760e0b6..544baaa185 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -102,10 +102,14 @@ export const dict = { "temp.logoDarkAlt": "logo de opencode oscuro", "home.banner.badge": "Nuevo", - "home.banner.text": "Aplicación de escritorio disponible en beta", - "home.banner.platforms": "en macOS, Windows y Linux", + "home.banner.text": "Presentamos Tabs para Desktop.", + "home.banner.platforms": "Disponible en macOS, Windows y Linux", "home.banner.downloadNow": "Descargar ahora", "home.banner.downloadBetaNow": "Descargar la beta de escritorio ahora", + "home.promo.title": "Presentamos Tabs para Desktop", + "home.promo.body": "Organiza tu trabajo y tus sesiones activas con pestañas.", + "home.promo.cta": "Descarga la última versión para empezar.", + "home.promo.close": "Cerrar el anuncio de la aplicación Desktop", "home.hero.title": "El agente de codificación IA de código abierto", "home.hero.subtitle.a": "Modelos gratuitos incluidos o conecta cualquier modelo de cualquier proveedor,", @@ -253,8 +257,9 @@ export const dict = { "zen.privacy.exceptionsLink": "siguientes excepciones", "go.title": "OpenCode Go | Modelos de programación de bajo coste para todos", + "go.banner.text": "Kimi K3 tiene límites de uso 2x mayores por tiempo limitado", "go.meta.description": - "Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.", + "Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.", "go.hero.title": "Modelos de programación de bajo coste para todos", "go.hero.body": "Go lleva la programación agéntica a programadores de todo el mundo. Ofrece límites generosos y acceso fiable a los modelos de código abierto más capaces, para que puedas crear con agentes potentes sin preocuparte por el coste o la disponibilidad.", @@ -271,7 +276,6 @@ export const dict = { "go.graph.go": "Go", "go.graph.label": "Solicitudes por 5 horas", "go.graph.usageLimits": "Límites de uso", - "go.graph.tick": "{{n}}x", "go.graph.aria": "Solicitudes por 5h: {{free}} vs {{go}}", "go.testimonials.brand.zen": "Zen", @@ -304,7 +308,7 @@ export const dict = { "go.problem.item2": "Límites generosos y acceso fiable", "go.problem.item3": "Creado para tantos programadores como sea posible", "go.problem.item4": - "Incluye GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash", + "Incluye Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash", "go.how.title": "Cómo funciona Go", "go.how.body": "Go comienza en $5 el primer mes, luego 10 $/mes. Puedes usarlo con OpenCode o cualquier agente.", "go.how.step1.title": "Crear una cuenta", @@ -329,7 +333,7 @@ export const dict = { "go.faq.a2": "Go incluye los modelos que se indican abajo, con límites generosos y acceso confiable.", "go.faq.q3": "¿Es Go lo mismo que Zen?", "go.faq.a3": - "No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.", + "No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.", "go.faq.q4": "¿Cuánto cuesta Go?", "go.faq.a4.p1.beforePricing": "Go cuesta", "go.faq.a4.p1.pricingLink": "$5 el primer mes", @@ -353,7 +357,7 @@ export const dict = { "go.faq.q9": "¿Cuál es la diferencia entre los modelos gratuitos y Go?", "go.faq.a9": - "Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).", + "Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).", "zen.api.error.rateLimitExceeded": "Límite de tasa excedido. Por favor, inténtalo de nuevo más tarde.", "zen.api.error.modelNotSupported": "Modelo {{model}} no soportado", @@ -711,11 +715,11 @@ export const dict = { "download.title": "OpenCode | Descargar", "download.meta.description": "Descarga OpenCode para macOS, Windows y Linux", - "download.hero.title": "Descargar OpenCode", + "download.hero.title": "Descargar OpenCode Desktop", "download.hero.subtitle": "Disponible en Beta para macOS, Windows y Linux", "download.hero.button": "Descargar para {{os}}", "download.section.terminal": "OpenCode Terminal", - "download.section.desktop": "OpenCode Desktop (Beta)", + "download.section.desktop": "OpenCode Desktop", "download.section.extensions": "Extensiones OpenCode", "download.section.integrations": "Integraciones OpenCode", "download.action.download": "Descargar", diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index 039692ef6b..0219952e9d 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -100,10 +100,14 @@ export const dict = { "temp.logoDarkAlt": "opencode logo dark", "home.banner.badge": "Nouveau", - "home.banner.text": "Application desktop disponible en bêta", - "home.banner.platforms": "sur macOS, Windows et Linux", + "home.banner.text": "Découvrez Tabs pour Desktop.", + "home.banner.platforms": "Disponible sur macOS, Windows et Linux", "home.banner.downloadNow": "Télécharger maintenant", "home.banner.downloadBetaNow": "Télécharger la bêta desktop maintenant", + "home.promo.title": "Découvrez Tabs pour Desktop", + "home.promo.body": "Organisez votre travail et vos sessions actives avec des onglets.", + "home.promo.cta": "Téléchargez la dernière version pour commencer.", + "home.promo.close": "Fermer l’annonce de l’application Desktop", "home.hero.title": "L'agent de code IA open source", "home.hero.subtitle.a": @@ -254,8 +258,9 @@ export const dict = { "zen.privacy.exceptionsLink": "exceptions suivantes", "go.title": "OpenCode Go | Modèles de code à faible coût pour tous", + "go.banner.text": "Kimi K3 bénéficie de limites d’utilisation 2x supérieures pour une durée limitée", "go.meta.description": - "Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash.", + "Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash.", "go.hero.title": "Modèles de code à faible coût pour tous", "go.hero.body": "Go apporte le codage agentique aux programmeurs du monde entier. Offrant des limites généreuses et un accès fiable aux modèles open source les plus capables, pour que vous puissiez construire avec des agents puissants sans vous soucier du coût ou de la disponibilité.", @@ -272,7 +277,6 @@ export const dict = { "go.graph.go": "Go", "go.graph.label": "Requêtes par tranche de 5 heures", "go.graph.usageLimits": "Limites d'utilisation", - "go.graph.tick": "{{n}}x", "go.graph.aria": "Requêtes par 5h : {{free}} vs {{go}}", "go.testimonials.brand.zen": "Zen", @@ -304,7 +308,7 @@ export const dict = { "go.problem.item2": "Limites généreuses et accès fiable", "go.problem.item3": "Conçu pour autant de programmeurs que possible", "go.problem.item4": - "Inclut GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash", + "Inclut Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash", "go.how.title": "Comment fonctionne Go", "go.how.body": "Go commence à $5 pour le premier mois, puis 10 $/mois. Vous pouvez l'utiliser avec OpenCode ou n'importe quel agent.", @@ -330,7 +334,7 @@ export const dict = { "go.faq.a2": "Go inclut les modèles ci-dessous, avec des limites généreuses et un accès fiable.", "go.faq.q3": "Est-ce que Go est la même chose que Zen ?", "go.faq.a3": - "Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash.", + "Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash.", "go.faq.q4": "Combien coûte Go ?", "go.faq.a4.p1.beforePricing": "Go coûte", "go.faq.a4.p1.pricingLink": "$5 le premier mois", @@ -353,7 +357,7 @@ export const dict = { "Oui, vous pouvez utiliser Go avec n'importe quel agent. Suivez les instructions de configuration dans votre agent de code préféré.", "go.faq.q9": "Quelle est la différence entre les modèles gratuits et Go ?", "go.faq.a9": - "Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).", + "Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).", "zen.api.error.rateLimitExceeded": "Limite de débit dépassée. Veuillez réessayer plus tard.", "zen.api.error.modelNotSupported": "Modèle {{model}} non pris en charge", @@ -718,11 +722,11 @@ export const dict = { "download.title": "OpenCode | Téléchargement", "download.meta.description": "Téléchargez OpenCode pour macOS, Windows et Linux", - "download.hero.title": "Télécharger OpenCode", + "download.hero.title": "Télécharger OpenCode Desktop", "download.hero.subtitle": "Disponible en bêta pour macOS, Windows et Linux", "download.hero.button": "Télécharger pour {{os}}", "download.section.terminal": "OpenCode Terminal", - "download.section.desktop": "OpenCode Desktop (Bêta)", + "download.section.desktop": "OpenCode Desktop", "download.section.extensions": "Extensions OpenCode", "download.section.integrations": "Intégrations OpenCode", "download.action.download": "Télécharger", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index bd080c44d3..06a643508a 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -102,10 +102,14 @@ export const dict = { "temp.logoDarkAlt": "logo scuro di opencode", "home.banner.badge": "Nuovo", - "home.banner.text": "App desktop disponibile in beta", - "home.banner.platforms": "su macOS, Windows e Linux", + "home.banner.text": "Scopri Tabs per Desktop.", + "home.banner.platforms": "Disponibile su macOS, Windows e Linux", "home.banner.downloadNow": "Scarica ora", "home.banner.downloadBetaNow": "Scarica ora la beta desktop", + "home.promo.title": "Scopri Tabs per Desktop", + "home.promo.body": "Organizza il tuo lavoro e le sessioni attive con le schede.", + "home.promo.cta": "Scarica l’ultima versione per iniziare.", + "home.promo.close": "Chiudi l’annuncio dell’app Desktop", "home.hero.title": "L'agente di coding IA open source", "home.hero.subtitle.a": "Modelli gratuiti inclusi o collega qualsiasi modello da qualsiasi provider,", @@ -250,8 +254,9 @@ export const dict = { "zen.privacy.exceptionsLink": "seguenti eccezioni", "go.title": "OpenCode Go | Modelli di coding a basso costo per tutti", + "go.banner.text": "Kimi K3 offre limiti di utilizzo 2x superiori per un periodo limitato", "go.meta.description": - "Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.hero.title": "Modelli di coding a basso costo per tutti", "go.hero.body": "Go porta il coding agentico ai programmatori di tutto il mondo. Offrendo limiti generosi e un accesso affidabile ai modelli open source più capaci, in modo da poter costruire con agenti potenti senza preoccuparsi dei costi o della disponibilità.", @@ -268,7 +273,6 @@ export const dict = { "go.graph.go": "Go", "go.graph.label": "Richieste ogni 5 ore", "go.graph.usageLimits": "Limiti di utilizzo", - "go.graph.tick": "{{n}}x", "go.graph.aria": "Richieste ogni 5h: {{free}} vs {{go}}", "go.testimonials.brand.zen": "Zen", @@ -300,7 +304,7 @@ export const dict = { "go.problem.item2": "Limiti generosi e accesso affidabile", "go.problem.item3": "Costruito per il maggior numero possibile di programmatori", "go.problem.item4": - "Include GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash", + "Include Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash", "go.how.title": "Come funziona Go", "go.how.body": "Go inizia a $5 per il primo mese, poi $10/mese. Puoi usarlo con OpenCode o qualsiasi agente.", "go.how.step1.title": "Crea un account", @@ -325,7 +329,7 @@ export const dict = { "go.faq.a2": "Go include i modelli elencati di seguito, con limiti generosi e accesso affidabile.", "go.faq.q3": "Go è lo stesso di Zen?", "go.faq.a3": - "No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.faq.q4": "Quanto costa Go?", "go.faq.a4.p1.beforePricing": "Go costa", "go.faq.a4.p1.pricingLink": "$5 il primo mese", @@ -349,7 +353,7 @@ export const dict = { "go.faq.q9": "Qual è la differenza tra i modelli gratuiti e Go?", "go.faq.a9": - "I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).", + "I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).", "zen.api.error.rateLimitExceeded": "Limite di richieste superato. Riprova più tardi.", "zen.api.error.modelNotSupported": "Modello {{model}} non supportato", @@ -709,11 +713,11 @@ export const dict = { "download.title": "OpenCode | Download", "download.meta.description": "Scarica OpenCode per macOS, Windows e Linux", - "download.hero.title": "Scarica OpenCode", + "download.hero.title": "Scarica OpenCode Desktop", "download.hero.subtitle": "Disponibile in Beta per macOS, Windows e Linux", "download.hero.button": "Scarica per {{os}}", "download.section.terminal": "OpenCode Terminal", - "download.section.desktop": "OpenCode Desktop (Beta)", + "download.section.desktop": "OpenCode Desktop", "download.section.extensions": "OpenCode Extensions", "download.section.integrations": "OpenCode Integrations", "download.action.download": "Scarica", diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index c3e7ff2d4c..a15acfb884 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -102,10 +102,14 @@ export const dict = { "temp.logoDarkAlt": "opencodeのロゴ(ダーク)", "home.banner.badge": "新着", - "home.banner.text": "デスクトップアプリのベータ版が利用可能", - "home.banner.platforms": "macOS、Windows、Linux で", + "home.banner.text": "デスクトップ版にタブが登場。", + "home.banner.platforms": "macOS、Windows、Linux で利用できます", "home.banner.downloadNow": "今すぐダウンロード", "home.banner.downloadBetaNow": "デスクトップベータ版を今すぐダウンロード", + "home.promo.title": "デスクトップ版にタブが登場", + "home.promo.body": "タブで作業とアクティブなセッションを整理できます。", + "home.promo.cta": "始めるには最新版をダウンロードしてください。", + "home.promo.close": "デスクトップアプリのお知らせを閉じる", "home.hero.title": "オープンソースのAIコーディングエージェント", "home.hero.subtitle.a": "無料モデルが含まれています。また、任意のプロバイダーの任意のモデルに接続でき、", @@ -249,8 +253,9 @@ export const dict = { "zen.privacy.exceptionsLink": "以下の例外", "go.title": "OpenCode Go | すべての人のための低価格なコーディングモデル", + "go.banner.text": "Kimi K3の利用上限が期間限定で2倍に", "go.meta.description": - "Goは最初の月$5、その後$10/月で、GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashに対して5時間のゆとりあるリクエスト上限があります。", + "Goは最初の月$5、その後$10/月で、Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashに対して5時間のゆとりあるリクエスト上限があります。", "go.hero.title": "すべての人のための低価格なコーディングモデル", "go.hero.body": "Goは、世界中のプログラマーにエージェント型コーディングをもたらします。最も高性能なオープンソースモデルへの十分な制限と安定したアクセスを提供し、コストや可用性を気にすることなく強力なエージェントで構築できます。", @@ -267,7 +272,6 @@ export const dict = { "go.graph.go": "Go", "go.graph.label": "5時間あたりのリクエスト数", "go.graph.usageLimits": "利用制限", - "go.graph.tick": "{{n}}倍", "go.graph.aria": "5時間あたりのリクエスト数: {{free}} 対 {{go}}", "go.testimonials.brand.zen": "Zen", @@ -300,7 +304,7 @@ export const dict = { "go.problem.item2": "十分な制限と安定したアクセス", "go.problem.item3": "できるだけ多くのプログラマーのために構築", "go.problem.item4": - "GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashを含む", + "Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashを含む", "go.how.title": "Goの仕組み", "go.how.body": "Goは最初の月$5、その後$10/月で始まります。OpenCodeまたは任意のエージェントで使えます。", "go.how.step1.title": "アカウントを作成", @@ -325,7 +329,7 @@ export const dict = { "go.faq.a2": "Go には、十分な利用上限と安定したアクセスを備えた、以下のモデルが含まれます。", "go.faq.q3": "GoはZenと同じですか?", "go.faq.a3": - "いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashのオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。", + "いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashのオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。", "go.faq.q4": "Goの料金は?", "go.faq.a4.p1.beforePricing": "Goは", "go.faq.a4.p1.pricingLink": "最初の月$5", @@ -349,7 +353,7 @@ export const dict = { "go.faq.q9": "無料モデルとGoの違いは何ですか?", "go.faq.a9": - "無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashが含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。", + "無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGrok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashが含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。", "zen.api.error.rateLimitExceeded": "レート制限を超えました。後でもう一度お試しください。", "zen.api.error.modelNotSupported": "モデル {{model}} はサポートされていません", @@ -708,11 +712,11 @@ export const dict = { "download.title": "OpenCode | ダウンロード", "download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード", - "download.hero.title": "OpenCode をダウンロード", + "download.hero.title": "OpenCode デスクトップ版をダウンロード", "download.hero.subtitle": "macOS、Windows、Linux 向けベータ版を利用可能", "download.hero.button": "{{os}} 向けダウンロード", "download.section.terminal": "OpenCode Terminal", - "download.section.desktop": "OpenCode Desktop (Beta)", + "download.section.desktop": "OpenCode Desktop", "download.section.extensions": "OpenCode Extensions", "download.section.integrations": "OpenCode Integrations", "download.action.download": "ダウンロード", diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index 0245d0c970..2ade66381a 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -102,10 +102,14 @@ export const dict = { "temp.logoDarkAlt": "opencode 어두운 로고", "home.banner.badge": "신규", - "home.banner.text": "데스크톱 앱 베타 버전 출시", - "home.banner.platforms": "macOS, Windows, Linux 지원", + "home.banner.text": "데스크톱 탭을 소개합니다.", + "home.banner.platforms": "macOS, Windows, Linux에서 사용할 수 있습니다", "home.banner.downloadNow": "지금 다운로드", "home.banner.downloadBetaNow": "데스크톱 베타 다운로드", + "home.promo.title": "데스크톱 탭을 소개합니다", + "home.promo.body": "탭으로 작업과 활성 세션을 정리하세요.", + "home.promo.cta": "최신 버전을 다운로드하여 시작하세요.", + "home.promo.close": "데스크톱 앱 안내 닫기", "home.hero.title": "오픈 소스 AI 코딩 에이전트", "home.hero.subtitle.a": "무료 모델이 포함되어 있으며, 어떤 제공자의 모델이든 연결 가능합니다.", @@ -246,8 +250,9 @@ export const dict = { "zen.privacy.exceptionsLink": "다음 예외", "go.title": "OpenCode Go | 모두를 위한 저비용 코딩 모델", + "go.banner.text": "Kimi K3 사용 한도가 한시적으로 2배 확대됩니다", "go.meta.description": - "Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash에 대해 넉넉한 5시간 요청 한도를 제공합니다.", + "Go는 첫 달 $5, 이후 $10/월로 시작하며, Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash에 대해 넉넉한 5시간 요청 한도를 제공합니다.", "go.hero.title": "모두를 위한 저비용 코딩 모델", "go.hero.body": "Go는 전 세계 프로그래머들에게 에이전트 코딩을 제공합니다. 가장 유능한 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공하므로, 비용이나 가용성 걱정 없이 강력한 에이전트로 빌드할 수 있습니다.", @@ -264,7 +269,6 @@ export const dict = { "go.graph.go": "Go", "go.graph.label": "5시간당 요청 수", "go.graph.usageLimits": "사용 한도", - "go.graph.tick": "{{n}}배", "go.graph.aria": "5시간당 요청 수: {{free}} 대 {{go}}", "go.testimonials.brand.zen": "Zen", @@ -297,7 +301,7 @@ export const dict = { "go.problem.item2": "넉넉한 한도와 안정적인 액세스", "go.problem.item3": "가능한 한 많은 프로그래머를 위해 제작됨", "go.problem.item4": - "GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash 포함", + "Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash 포함", "go.how.title": "Go 작동 방식", "go.how.body": "Go는 첫 달 $5, 이후 $10/월로 시작합니다. OpenCode 또는 어떤 에이전트와도 함께 사용할 수 있습니다.", "go.how.step1.title": "계정 생성", @@ -321,7 +325,7 @@ export const dict = { "go.faq.a2": "Go에는 넉넉한 한도와 안정적인 액세스를 제공하는 아래 모델이 포함됩니다.", "go.faq.q3": "Go는 Zen과 같은가요?", "go.faq.a3": - "아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.", + "아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.", "go.faq.q4": "Go 비용은 얼마인가요?", "go.faq.a4.p1.beforePricing": "Go 비용은", "go.faq.a4.p1.pricingLink": "첫 달 $5", @@ -344,7 +348,7 @@ export const dict = { "go.faq.q9": "무료 모델과 Go의 차이점은 무엇인가요?", "go.faq.a9": - "무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).", + "무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).", "zen.api.error.rateLimitExceeded": "속도 제한을 초과했습니다. 나중에 다시 시도해 주세요.", "zen.api.error.modelNotSupported": "{{model}} 모델은 지원되지 않습니다", @@ -700,11 +704,11 @@ export const dict = { "download.title": "OpenCode | 다운로드", "download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드", - "download.hero.title": "OpenCode 다운로드", + "download.hero.title": "OpenCode 데스크톱 다운로드", "download.hero.subtitle": "macOS, Windows, Linux용 베타 버전 사용 가능", "download.hero.button": "{{os}}용 다운로드", "download.section.terminal": "OpenCode 터미널", - "download.section.desktop": "OpenCode 데스크톱 (베타)", + "download.section.desktop": "OpenCode 데스크톱", "download.section.extensions": "OpenCode 확장 프로그램", "download.section.integrations": "OpenCode 통합", "download.action.download": "다운로드", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index bc2f9a935f..4536284428 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -102,10 +102,14 @@ export const dict = { "temp.logoDarkAlt": "opencode logo mørk", "home.banner.badge": "Ny", - "home.banner.text": "Desktop-app tilgjengelig i beta", - "home.banner.platforms": "på macOS, Windows og Linux", + "home.banner.text": "Vi introduserer Tabs for Desktop.", + "home.banner.platforms": "Tilgjengelig på macOS, Windows og Linux", "home.banner.downloadNow": "Last ned nå", "home.banner.downloadBetaNow": "Last ned desktop-betaen nå", + "home.promo.title": "Vi introduserer Tabs for Desktop", + "home.promo.body": "Organiser arbeidet ditt og aktive økter med faner.", + "home.promo.cta": "Last ned den nyeste versjonen for å komme i gang.", + "home.promo.close": "Lukk kunngjøringen om Desktop-appen", "home.hero.title": "Den åpne kildekode AI-kodingsagenten", "home.hero.subtitle.a": @@ -250,8 +254,9 @@ export const dict = { "zen.privacy.exceptionsLink": "følgende unntak", "go.title": "OpenCode Go | Rimelige kodemodeller for alle", + "go.banner.text": "Kimi K3 får 2x bruksgrense i en begrenset periode", "go.meta.description": - "Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.hero.title": "Rimelige kodemodeller for alle", "go.hero.body": "Go bringer agent-koding til programmerere over hele verden. Med rause grenser og pålitelig tilgang til de mest kapable åpen kildekode-modellene, kan du bygge med kraftige agenter uten å bekymre deg for kostnader eller tilgjengelighet.", @@ -268,7 +273,6 @@ export const dict = { "go.graph.go": "Go", "go.graph.label": "Forespørsler per 5 timer", "go.graph.usageLimits": "Bruksgrenser", - "go.graph.tick": "{{n}}x", "go.graph.aria": "Forespørsler per 5t: {{free}} vs {{go}}", "go.testimonials.brand.zen": "Zen", @@ -300,7 +304,7 @@ export const dict = { "go.problem.item2": "Rause grenser og pålitelig tilgang", "go.problem.item3": "Bygget for så mange programmerere som mulig", "go.problem.item4": - "Inkluderer GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash", + "Inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash", "go.how.title": "Hvordan Go fungerer", "go.how.body": "Go starter på $5 for den første måneden, deretter $10/måned. Du kan bruke det med OpenCode eller hvilken som helst agent.", @@ -326,7 +330,7 @@ export const dict = { "go.faq.a2": "Go inkluderer modellene nedenfor, med høye grenser og pålitelig tilgang.", "go.faq.q3": "Er Go det samme som Zen?", "go.faq.a3": - "Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.faq.q4": "Hva koster Go?", "go.faq.a4.p1.beforePricing": "Go koster", "go.faq.a4.p1.pricingLink": "$5 første måned", @@ -350,7 +354,7 @@ export const dict = { "go.faq.q9": "Hva er forskjellen mellom gratis modeller og Go?", "go.faq.a9": - "Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).", + "Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).", "zen.api.error.rateLimitExceeded": "Rate limit overskredet. Vennligst prøv igjen senere.", "zen.api.error.modelNotSupported": "Modell {{model}} støttes ikke", @@ -707,11 +711,11 @@ export const dict = { "download.title": "OpenCode | Last ned", "download.meta.description": "Last ned OpenCode for macOS, Windows og Linux", - "download.hero.title": "Last ned OpenCode", + "download.hero.title": "Last ned OpenCode Desktop", "download.hero.subtitle": "Tilgjengelig i beta for macOS, Windows og Linux", "download.hero.button": "Last ned for {{os}}", "download.section.terminal": "OpenCode Terminal", - "download.section.desktop": "OpenCode Desktop (Beta)", + "download.section.desktop": "OpenCode Desktop", "download.section.extensions": "OpenCode Extensions", "download.section.integrations": "OpenCode Integrations", "download.action.download": "Last ned", diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index 6d7df39d2b..dc3b593caa 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -101,10 +101,14 @@ export const dict = { "temp.logoDarkAlt": "ciemne logo opencode", "home.banner.badge": "Nowość", - "home.banner.text": "Aplikacja desktopowa dostępna w wersji beta", - "home.banner.platforms": "na macOS, Windows i Linux", + "home.banner.text": "Przedstawiamy karty w aplikacji desktopowej.", + "home.banner.platforms": "Dostępne na macOS, Windows i Linux", "home.banner.downloadNow": "Pobierz teraz", "home.banner.downloadBetaNow": "Pobierz betę wersji desktopowej", + "home.promo.title": "Przedstawiamy karty w aplikacji desktopowej", + "home.promo.body": "Organizuj swoją pracę i aktywne sesje za pomocą kart.", + "home.promo.cta": "Pobierz najnowszą wersję, aby rozpocząć.", + "home.promo.close": "Zamknij ogłoszenie o aplikacji desktopowej", "home.hero.title": "Open source'owy agent AI do kodowania", "home.hero.subtitle.a": "Darmowe modele w zestawie lub podłącz dowolny model od dowolnego dostawcy,", @@ -251,8 +255,9 @@ export const dict = { "zen.privacy.exceptionsLink": "następującymi wyjątkami", "go.title": "OpenCode Go | Niskokosztowe modele do kodowania dla każdego", + "go.banner.text": "Kimi K3 oferuje 2x wyższe limity użycia przez ograniczony czas", "go.meta.description": - "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash.", + "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash.", "go.hero.title": "Niskokosztowe modele do kodowania dla każdego", "go.hero.body": "Go udostępnia programowanie z agentami programistom na całym świecie. Oferuje hojne limity i niezawodny dostęp do najzdolniejszych modeli open source, dzięki czemu możesz budować za pomocą potężnych agentów, nie martwiąc się o koszty czy dostępność.", @@ -269,7 +274,6 @@ export const dict = { "go.graph.go": "Go", "go.graph.label": "Żądania na 5 godzin", "go.graph.usageLimits": "Limity użycia", - "go.graph.tick": "{{n}}x", "go.graph.aria": "Żądania na 5h: {{free}} vs {{go}}", "go.testimonials.brand.zen": "Zen", @@ -301,7 +305,7 @@ export const dict = { "go.problem.item2": "Hojne limity i niezawodny dostęp", "go.problem.item3": "Stworzony dla jak największej liczby programistów", "go.problem.item4": - "Zawiera GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash", + "Zawiera Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash", "go.how.title": "Jak działa Go", "go.how.body": "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc. Możesz go używać z OpenCode lub dowolnym agentem.", @@ -327,7 +331,7 @@ export const dict = { "go.faq.a2": "Go obejmuje poniższe modele z wysokimi limitami i niezawodnym dostępem.", "go.faq.q3": "Czy Go to to samo co Zen?", "go.faq.a3": - "Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash.", + "Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash.", "go.faq.q4": "Ile kosztuje Go?", "go.faq.a4.p1.beforePricing": "Go kosztuje", "go.faq.a4.p1.pricingLink": "$5 za pierwszy miesiąc", @@ -351,7 +355,7 @@ export const dict = { "go.faq.q9": "Jaka jest różnica między darmowymi modelami a Go?", "go.faq.a9": - "Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).", + "Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).", "zen.api.error.rateLimitExceeded": "Przekroczono limit zapytań. Spróbuj ponownie później.", "zen.api.error.modelNotSupported": "Model {{model}} nie jest obsługiwany", @@ -708,11 +712,11 @@ export const dict = { "download.title": "OpenCode | Pobierz", "download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux", - "download.hero.title": "Pobierz OpenCode", + "download.hero.title": "Pobierz OpenCode Desktop", "download.hero.subtitle": "Dostępne w wersji Beta na macOS, Windows i Linux", "download.hero.button": "Pobierz na {{os}}", "download.section.terminal": "Terminal OpenCode", - "download.section.desktop": "Pulpit OpenCode (Beta)", + "download.section.desktop": "Pulpit OpenCode", "download.section.extensions": "Rozszerzenia OpenCode", "download.section.integrations": "Integracje OpenCode", "download.action.download": "Pobierz", diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index 90d3c6c8c5..2ec2cd2950 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -102,10 +102,14 @@ export const dict = { "temp.logoDarkAlt": "темный логотип opencode", "home.banner.badge": "Новое", - "home.banner.text": "Доступно десктопное приложение (бета)", - "home.banner.platforms": "на macOS, Windows и Linux", + "home.banner.text": "Представляем вкладки в десктопном приложении.", + "home.banner.platforms": "Доступно на macOS, Windows и Linux", "home.banner.downloadNow": "Скачать", "home.banner.downloadBetaNow": "Скачать бету для десктопа", + "home.promo.title": "Представляем вкладки в десктопном приложении", + "home.promo.body": "Организуйте работу и активные сессии с помощью вкладок.", + "home.promo.cta": "Скачайте последнюю версию, чтобы начать.", + "home.promo.close": "Закрыть объявление о десктопном приложении", "home.hero.title": "AI-агент с открытым кодом для программирования", "home.hero.subtitle.a": "Бесплатные модели включены, или подключите любую модель от любого провайдера,", @@ -254,8 +258,9 @@ export const dict = { "zen.privacy.exceptionsLink": "следующими исключениями", "go.title": "OpenCode Go | Недорогие модели для кодинга для всех", + "go.banner.text": "Kimi K3 получает 2x лимиты использования на ограниченное время", "go.meta.description": - "Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash.", + "Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash.", "go.hero.title": "Недорогие модели для кодинга для всех", "go.hero.body": "Go открывает доступ к агентам-программистам разработчикам по всему миру. Предлагая щедрые лимиты и надежный доступ к наиболее способным моделям с открытым исходным кодом, вы можете создавать проекты с мощными агентами, не беспокоясь о затратах или доступности.", @@ -272,7 +277,6 @@ export const dict = { "go.graph.go": "Go", "go.graph.label": "Запросов за 5 часов", "go.graph.usageLimits": "Лимиты использования", - "go.graph.tick": "{{n}}x", "go.graph.aria": "Запросов за 5ч: {{free}} против {{go}}", "go.testimonials.brand.zen": "Zen", @@ -305,7 +309,7 @@ export const dict = { "go.problem.item2": "Щедрые лимиты и надежный доступ", "go.problem.item3": "Создан для максимального числа программистов", "go.problem.item4": - "Включает GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash", + "Включает Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash", "go.how.title": "Как работает Go", "go.how.body": "Go начинается с $5 за первый месяц, затем $10/месяц. Вы можете использовать его с OpenCode или любым агентом.", @@ -331,7 +335,7 @@ export const dict = { "go.faq.a2": "Go включает перечисленные ниже модели с щедрыми лимитами и надежным доступом.", "go.faq.q3": "Go — это то же самое, что и Zen?", "go.faq.a3": - "Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash.", + "Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash.", "go.faq.q4": "Сколько стоит Go?", "go.faq.a4.p1.beforePricing": "Go стоит", "go.faq.a4.p1.pricingLink": "$5 за первый месяц", @@ -355,7 +359,7 @@ export const dict = { "go.faq.q9": "В чем разница между бесплатными моделями и Go?", "go.faq.a9": - "Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).", + "Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).", "zen.api.error.rateLimitExceeded": "Превышен лимит запросов. Пожалуйста, попробуйте позже.", "zen.api.error.modelNotSupported": "Модель {{model}} не поддерживается", @@ -716,11 +720,11 @@ export const dict = { "download.title": "OpenCode | Скачать", "download.meta.description": "Скачать OpenCode для macOS, Windows и Linux", - "download.hero.title": "Скачать OpenCode", + "download.hero.title": "Скачать OpenCode Desktop", "download.hero.subtitle": "Доступна бета для macOS, Windows и Linux", "download.hero.button": "Скачать для {{os}}", "download.section.terminal": "OpenCode Terminal", - "download.section.desktop": "OpenCode Desktop (Beta)", + "download.section.desktop": "OpenCode Desktop", "download.section.extensions": "Расширения OpenCode", "download.section.integrations": "Интеграции OpenCode", "download.action.download": "Скачать", diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index bf7c2e58e4..3a3fda4945 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -102,10 +102,14 @@ export const dict = { "temp.logoDarkAlt": "โลโก้ opencode แบบมืด", "home.banner.badge": "ใหม่", - "home.banner.text": "แอปเดสก์ท็อปพร้อมใช้งานในเวอร์ชันเบต้า", - "home.banner.platforms": "บน macOS, Windows และ Linux", + "home.banner.text": "ขอแนะนำแท็บสำหรับเดสก์ท็อป.", + "home.banner.platforms": "พร้อมใช้งานบน macOS, Windows และ Linux", "home.banner.downloadNow": "ดาวน์โหลดตอนนี้", "home.banner.downloadBetaNow": "ดาวน์โหลดเบต้าเดสก์ท็อปตอนนี้", + "home.promo.title": "ขอแนะนำแท็บสำหรับเดสก์ท็อป", + "home.promo.body": "จัดระเบียบงานและเซสชันที่ใช้งานอยู่ด้วยแท็บ", + "home.promo.cta": "ดาวน์โหลดเวอร์ชันล่าสุดเพื่อเริ่มต้นใช้งาน", + "home.promo.close": "ปิดประกาศเกี่ยวกับแอปเดสก์ท็อป", "home.hero.title": "เอเจนต์เขียนโค้ดด้วย AI แบบโอเพนซอร์ส", "home.hero.subtitle.a": "มีโมเดลฟรีรวมอยู่ หรือเชื่อมต่อโมเดลใดก็ได้จากผู้ให้บริการรายใดก็ได้,", @@ -249,8 +253,9 @@ export const dict = { "zen.privacy.exceptionsLink": "ข้อยกเว้นดังนี้", "go.title": "OpenCode Go | โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน", + "go.banner.text": "Kimi K3 เพิ่มโควตาการใช้งานเป็น 2 เท่าในช่วงเวลาจำกัด", "go.meta.description": - "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash", + "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash", "go.hero.title": "โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน", "go.hero.body": "Go นำการเขียนโค้ดแบบเอเจนต์มาสู่นักเขียนโปรแกรมทั่วโลก เสนอขีดจำกัดที่กว้างขวางและการเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสูงสุดได้อย่างน่าเชื่อถือ เพื่อให้คุณสามารถสร้างสรรค์ด้วยเอเจนต์ที่ทรงพลังโดยไม่ต้องกังวลเรื่องค่าใช้จ่ายหรือความพร้อมใช้งาน", @@ -266,7 +271,6 @@ export const dict = { "go.graph.go": "Go", "go.graph.label": "คำขอต่อ 5 ชั่วโมง", "go.graph.usageLimits": "ขีดจำกัดการใช้งาน", - "go.graph.tick": "{{n}}x", "go.graph.aria": "คำขอต่อ 5 ชม.: {{free}} vs {{go}}", "go.testimonials.brand.zen": "Zen", @@ -298,7 +302,7 @@ export const dict = { "go.problem.item2": "ขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้", "go.problem.item3": "สร้างขึ้นเพื่อโปรแกรมเมอร์จำนวนมากที่สุดเท่าที่จะเป็นไปได้", "go.problem.item4": - "รวมถึง GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash", + "รวมถึง Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash", "go.how.title": "Go ทำงานอย่างไร", "go.how.body": "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน คุณสามารถใช้กับ OpenCode หรือเอเจนต์ใดก็ได้", "go.how.step1.title": "สร้างบัญชี", @@ -323,7 +327,7 @@ export const dict = { "go.faq.a2": "Go รวมโมเดลด้านล่างนี้ พร้อมขีดจำกัดที่มากและการเข้าถึงที่เชื่อถือได้", "go.faq.q3": "Go เหมือนกับ Zen หรือไม่?", "go.faq.a3": - "ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash อย่างเชื่อถือได้", + "ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash อย่างเชื่อถือได้", "go.faq.q4": "Go ราคาเท่าไหร่?", "go.faq.a4.p1.beforePricing": "Go ราคา", "go.faq.a4.p1.pricingLink": "$5 เดือนแรก", @@ -346,7 +350,7 @@ export const dict = { "go.faq.q9": "ความแตกต่างระหว่างโมเดลฟรีและ Go คืออะไร?", "go.faq.a9": - "โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)", + "โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)", "zen.api.error.rateLimitExceeded": "เกินขีดจำกัดอัตราการใช้งาน กรุณาลองใหม่ในภายหลัง", "zen.api.error.modelNotSupported": "ไม่รองรับโมเดล {{model}}", @@ -703,11 +707,11 @@ export const dict = { "download.title": "OpenCode | ดาวน์โหลด", "download.meta.description": "ดาวน์โหลด OpenCode สำหรับ macOS, Windows และ Linux", - "download.hero.title": "ดาวน์โหลด OpenCode", + "download.hero.title": "ดาวน์โหลด OpenCode สำหรับเดสก์ท็อป", "download.hero.subtitle": "พร้อมใช้งานในเวอร์ชันเบต้าสำหรับ macOS, Windows และ Linux", "download.hero.button": "ดาวน์โหลดสำหรับ {{os}}", "download.section.terminal": "OpenCode Terminal", - "download.section.desktop": "OpenCode Desktop (Beta)", + "download.section.desktop": "OpenCode Desktop", "download.section.extensions": "ส่วนขยาย OpenCode", "download.section.integrations": "การเชื่อมต่อ OpenCode", "download.action.download": "ดาวน์โหลด", diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 7bc45ee2e2..1e55ba91c9 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -102,10 +102,14 @@ export const dict = { "temp.logoDarkAlt": "opencode koyu logo", "home.banner.badge": "Yeni", - "home.banner.text": "Masaüstü uygulaması beta olarak mevcut", - "home.banner.platforms": "macOS, Windows ve Linux'ta", + "home.banner.text": "Masaüstü uygulamasında sekmelerle tanışın.", + "home.banner.platforms": "macOS, Windows ve Linux'ta kullanılabilir", "home.banner.downloadNow": "Şimdi indir", "home.banner.downloadBetaNow": "Masaüstü betayı şimdi indir", + "home.promo.title": "Masaüstü uygulamasında sekmelerle tanışın", + "home.promo.body": "Çalışmalarınızı ve etkin oturumlarınızı sekmelerle düzenleyin.", + "home.promo.cta": "Başlamak için en son sürümü indirin.", + "home.promo.close": "Masaüstü uygulaması duyurusunu kapat", "home.hero.title": "Açık kaynaklı yapay zeka kodlama ajanı", "home.hero.subtitle.a": "Ücretsiz modeller dahil veya herhangi bir sağlayıcıdan herhangi bir modeli bağlayın,", @@ -252,8 +256,9 @@ export const dict = { "zen.privacy.exceptionsLink": "aşağıdaki istisnalar", "go.title": "OpenCode Go | Herkes için düşük maliyetli kodlama modelleri", + "go.banner.text": "Kimi K3 sınırlı bir süre için 2x kullanım limiti sunuyor", "go.meta.description": - "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash için cömert 5 saatlik istek limitleri sunar.", + "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash için cömert 5 saatlik istek limitleri sunar.", "go.hero.title": "Herkes için düşük maliyetli kodlama modelleri", "go.hero.body": "Go, dünya çapındaki programcılara ajan tabanlı kodlama getiriyor. En yetenekli açık kaynaklı modellere cömert limitler ve güvenilir erişim sunarak, maliyet veya erişilebilirlik konusunda endişelenmeden güçlü ajanlarla geliştirme yapmanızı sağlar.", @@ -270,7 +275,6 @@ export const dict = { "go.graph.go": "Go", "go.graph.label": "5 saat başına istekler", "go.graph.usageLimits": "Kullanım limitleri", - "go.graph.tick": "{{n}}x", "go.graph.aria": "5 saatlik istekler: {{free}} vs {{go}}", "go.testimonials.brand.zen": "Zen", @@ -303,7 +307,7 @@ export const dict = { "go.problem.item2": "Cömert limitler ve güvenilir erişim", "go.problem.item3": "Mümkün olduğunca çok programcı için geliştirildi", "go.problem.item4": - "GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash içerir", + "Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash içerir", "go.how.title": "Go nasıl çalışır?", "go.how.body": "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar. OpenCode veya herhangi bir ajanla kullanabilirsiniz.", @@ -329,7 +333,7 @@ export const dict = { "go.faq.a2": "Go, aşağıda listelenen modelleri cömert limitler ve güvenilir erişimle sunar.", "go.faq.q3": "Go, Zen ile aynı mı?", "go.faq.a3": - "Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.", + "Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.", "go.faq.q4": "Go ne kadar?", "go.faq.a4.p1.beforePricing": "Go'nun maliyeti", "go.faq.a4.p1.pricingLink": "İlk ay $5", @@ -353,7 +357,7 @@ export const dict = { "go.faq.q9": "Ücretsiz modeller ve Go arasındaki fark nedir?", "go.faq.a9": - "Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).", + "Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).", "zen.api.error.rateLimitExceeded": "İstek limiti aşıldı. Lütfen daha sonra tekrar deneyin.", "zen.api.error.modelNotSupported": "{{model}} modeli desteklenmiyor", @@ -711,11 +715,11 @@ export const dict = { "download.title": "OpenCode | İndir", "download.meta.description": "OpenCode'u macOS, Windows ve Linux için indirin", - "download.hero.title": "OpenCode'u İndir", + "download.hero.title": "OpenCode Desktop'u İndir", "download.hero.subtitle": "macOS, Windows ve Linux için Beta olarak sunuluyor", "download.hero.button": "{{os}} için indir", "download.section.terminal": "OpenCode Terminal", - "download.section.desktop": "OpenCode Desktop (Beta)", + "download.section.desktop": "OpenCode Desktop", "download.section.extensions": "OpenCode Eklentileri", "download.section.integrations": "OpenCode Entegrasyonları", "download.action.download": "İndir", diff --git a/packages/console/app/src/i18n/uk.ts b/packages/console/app/src/i18n/uk.ts index 8486fa81b5..a1e8561c43 100644 --- a/packages/console/app/src/i18n/uk.ts +++ b/packages/console/app/src/i18n/uk.ts @@ -102,10 +102,14 @@ export const dict = { "temp.logoDarkAlt": "темний логотип opencode", "home.banner.badge": "Нове", - "home.banner.text": "Десктопний застосунок доступний у бета-версії", - "home.banner.platforms": "на macOS, Windows та Linux", + "home.banner.text": "Представляємо вкладки в десктопному застосунку.", + "home.banner.platforms": "Доступно на macOS, Windows та Linux", "home.banner.downloadNow": "Завантажити зараз", "home.banner.downloadBetaNow": "Завантажити бета-версію десктопного застосунку", + "home.promo.title": "Представляємо вкладки в десктопному застосунку", + "home.promo.body": "Упорядковуйте роботу й активні сесії за допомогою вкладок.", + "home.promo.cta": "Завантажте останню версію, щоб почати.", + "home.promo.close": "Закрити оголошення про десктопний застосунок", "home.hero.title": "Відкритий AI-агент для кодування", "home.hero.subtitle.a": "Безкоштовні моделі включено або підключіть будь-яку модель від будь-якого провайдера,", @@ -251,8 +255,9 @@ export const dict = { "zen.privacy.exceptionsLink": "такими винятками", "go.title": "OpenCode Go | Недорогі моделі кодування для всіх", + "go.banner.text": "Kimi K3 отримує 2x ліміти використання протягом обмеженого часу", "go.meta.description": - "Go починається від $5 за перший місяць, потім $10/місяць, з generous 5-годинними лімітами запитів для GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash.", + "Go починається від $5 за перший місяць, потім $10/місяць, зі щедрими 5-годинними лімітами запитів для Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash.", "go.hero.title": "Недорогі моделі кодування для всіх", "go.hero.body": "Go надає агентне програмування програмістам у всьому світі, пропонуючи щедрі ліміти та надійний доступ до найкращих моделей з відкритим кодом.", @@ -269,7 +274,6 @@ export const dict = { "go.graph.go": "Go", "go.graph.label": "Запитів за 5 годин", "go.graph.usageLimits": "Ліміти використання", - "go.graph.tick": "{{n}}x", "go.graph.aria": "Запитів за 5 год: {{free}} vs {{go}}", "go.testimonials.brand.zen": "Zen", @@ -301,7 +305,7 @@ export const dict = { "go.problem.item2": "Щедрі ліміти та надійний доступ", "go.problem.item3": "Створено для якомога більшої кількості програмістів", "go.problem.item4": - "Включає GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash", + "Включає Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash", "go.how.title": "Як працює Go", "go.how.body": "Go починається від $5 за перший місяць, потім $10/місяць. Використовуйте з OpenCode або будь-яким агентом.", @@ -327,7 +331,7 @@ export const dict = { "go.faq.a2": "Go включає моделі, перелічені нижче, із щедрими лімітами та надійним доступом.", "go.faq.q3": "Чи Go те саме, що Zen?", "go.faq.a3": - "Ні. Zen — це плата за використання, тоді як Go починається від $5 за перший місяць, потім $10/місяць, із щедрими лімітами та надійним доступом до моделей з відкритим кодом GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash.", + "Ні. Zen — це плата за використання, тоді як Go починається від $5 за перший місяць, потім $10/місяць, із щедрими лімітами та надійним доступом до моделей з відкритим кодом Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash.", "go.faq.q4": "Скільки коштує Go?", "go.faq.a4.p1.beforePricing": "Go коштує", "go.faq.a4.p1.pricingLink": "$5 за перший місяць", @@ -350,7 +354,7 @@ export const dict = { "go.faq.q9": "Яка різниця між безкоштовними моделями та Go?", "go.faq.a9": - "Безкоштовні моделі включають Big Pickle та акційні моделі з лімітом 200 запитів/день. Go включає GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash із вищими лімітами.", + "Безкоштовні моделі включають Big Pickle та акційні моделі з лімітом 200 запитів/день. Go включає Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash із вищими лімітами.", "zen.api.error.rateLimitExceeded": "Перевищено ліміт запитів. Спробуйте пізніше.", "zen.api.error.modelNotSupported": "Модель {{model}} не підтримується", @@ -670,11 +674,11 @@ export const dict = { "download.title": "OpenCode | Завантажити", "download.meta.description": "Завантажте OpenCode для macOS, Windows та Linux", - "download.hero.title": "Завантажити OpenCode", + "download.hero.title": "Завантажити OpenCode Desktop", "download.hero.subtitle": "Доступно в бета-версії для macOS, Windows та Linux", "download.hero.button": "Завантажити для {{os}}", "download.section.terminal": "Термінал OpenCode", - "download.section.desktop": "Десктоп OpenCode (Бета)", + "download.section.desktop": "Десктоп OpenCode", "download.section.extensions": "Розширення OpenCode", "download.section.integrations": "Інтеграції OpenCode", "download.action.download": "Завантажити", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index 5bcb84ac82..2fa8a8ca51 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -103,10 +103,14 @@ export const dict = { "temp.logoDarkAlt": "opencode logo 暗色", "home.banner.badge": "新", - "home.banner.text": "桌面应用 Beta 版现已推出", - "home.banner.platforms": "支持 macOS, Windows, 和 Linux", + "home.banner.text": "桌面版标签页现已推出。", + "home.banner.platforms": "适用于 macOS、Windows 和 Linux", "home.banner.downloadNow": "立即下载", "home.banner.downloadBetaNow": "立即下载桌面 Beta 版", + "home.promo.title": "桌面版标签页现已推出", + "home.promo.body": "使用标签页整理工作和活跃会话。", + "home.promo.cta": "下载最新版本即可开始使用。", + "home.promo.close": "关闭桌面应用公告", "home.hero.title": "开源 AI 编程代理", "home.hero.subtitle.a": "内置免费模型,或连接任意提供商的任意模型,", @@ -240,8 +244,9 @@ export const dict = { "zen.privacy.exceptionsLink": "以下例外情况除外", "go.title": "OpenCode Go | 人人可用的低成本编程模型", + "go.banner.text": "Kimi K3 限时享受 2 倍使用额度", "go.meta.description": - "Go 首月 $5,之后 $10/月,提供对 GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小时充裕请求额度。", + "Go 首月 $5,之后 $10/月,提供对 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小时充裕请求额度。", "go.hero.title": "人人可用的低成本编程模型", "go.hero.body": "Go 将代理编程带给全世界的程序员。提供充裕的限额和对最强大的开源模型的可靠访问,让您可以利用强大的代理进行构建,而无需担心成本或可用性。", @@ -257,7 +262,6 @@ export const dict = { "go.graph.go": "Go", "go.graph.label": "每 5 小时请求数", "go.graph.usageLimits": "使用限制", - "go.graph.tick": "{{n}}x", "go.graph.aria": "每 5 小时请求数: {{free}} vs {{go}}", "go.testimonials.brand.zen": "Zen", @@ -289,7 +293,7 @@ export const dict = { "go.problem.item2": "充裕的限额和可靠的访问", "go.problem.item3": "为尽可能多的程序员打造", "go.problem.item4": - "包含 GLM-5.2, GLM-5.1, Kimi K2.7 Code、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash", + "包含 Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash", "go.how.title": "Go 如何工作", "go.how.body": "Go 起价为首月 $5,之后 $10/月。您可以将其与 OpenCode 或任何代理搭配使用。", "go.how.step1.title": "创建账户", @@ -311,7 +315,7 @@ export const dict = { "go.faq.a2": "Go 包含下方列出的模型,提供充足的限额和可靠的访问。", "go.faq.q3": "Go 和 Zen 一样吗?", "go.faq.a3": - "不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等开源模型。", + "不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等开源模型。", "go.faq.q4": "Go 多少钱?", "go.faq.a4.p1.beforePricing": "Go 费用为", "go.faq.a4.p1.pricingLink": "首月 $5", @@ -333,7 +337,7 @@ export const dict = { "go.faq.q9": "免费模型和 Go 之间的区别是什么?", "go.faq.a9": - "免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5.2, GLM-5.1, Kimi K2.7 Code、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。", + "免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。", "zen.api.error.rateLimitExceeded": "超出速率限制。请稍后重试。", "zen.api.error.modelNotSupported": "不支持模型 {{model}}", @@ -682,11 +686,11 @@ export const dict = { "download.title": "OpenCode | 下载", "download.meta.description": "下载适用于 macOS, Windows, 和 Linux 的 OpenCode", - "download.hero.title": "下载 OpenCode", + "download.hero.title": "下载 OpenCode 桌面版", "download.hero.subtitle": "适用于 macOS, Windows, 和 Linux 的 Beta 版", "download.hero.button": "下载 {{os}} 版", "download.section.terminal": "OpenCode 终端", - "download.section.desktop": "OpenCode 桌面版 (Beta)", + "download.section.desktop": "OpenCode 桌面版", "download.section.extensions": "OpenCode 扩展", "download.section.integrations": "OpenCode 集成", "download.action.download": "下载", diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index eaa5763d46..b7096b3d05 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -103,10 +103,14 @@ export const dict = { "temp.logoDarkAlt": "opencode 深色標誌", "home.banner.badge": "新", - "home.banner.text": "桌面應用已推出 Beta", - "home.banner.platforms": "支援 macOS、Windows 與 Linux", + "home.banner.text": "桌面版分頁功能全新推出。", + "home.banner.platforms": "適用於 macOS、Windows 與 Linux", "home.banner.downloadNow": "立即下載", "home.banner.downloadBetaNow": "立即下載桌面 Beta 版", + "home.promo.title": "桌面版分頁功能全新推出", + "home.promo.body": "使用分頁整理工作和作用中的工作階段。", + "home.promo.cta": "下載最新版本即可開始使用。", + "home.promo.close": "關閉桌面應用程式公告", "home.hero.title": "開源 AI 編碼代理", "home.hero.subtitle.a": "內建免費模型,或連接任意供應商的任意模型,", @@ -240,8 +244,9 @@ export const dict = { "zen.privacy.exceptionsLink": "以下例外情況", "go.title": "OpenCode Go | 低成本全民編碼模型", + "go.banner.text": "Kimi K3 限時享有 2 倍使用額度", "go.meta.description": - "Go 首月 $5,之後 $10/月,提供對 GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小時充裕請求額度。", + "Go 首月 $5,之後 $10/月,提供對 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小時充裕請求額度。", "go.hero.title": "低成本全民編碼模型", "go.hero.body": "Go 將代理編碼帶給全世界的程式設計師。提供寬裕的限額以及對最強大開源模型的穩定存取,讓你可以使用強大的代理進行構建,而無需擔心成本或可用性。", @@ -257,7 +262,6 @@ export const dict = { "go.graph.go": "Go", "go.graph.label": "每 5 小時請求數", "go.graph.usageLimits": "使用限制", - "go.graph.tick": "{{n}}x", "go.graph.aria": "每 5 小時請求數:{{free}} vs {{go}}", "go.testimonials.brand.zen": "Zen", @@ -289,7 +293,7 @@ export const dict = { "go.problem.item2": "寬裕的限額與穩定存取", "go.problem.item3": "專為盡可能多的程式設計師打造", "go.problem.item4": - "包含 GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 與 DeepSeek V4 Flash", + "包含 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 與 DeepSeek V4 Flash", "go.how.title": "Go 如何運作", "go.how.body": "Go 起價為首月 $5,之後 $10/月。您可以將其與 OpenCode 或任何代理搭配使用。", "go.how.step1.title": "建立帳號", @@ -311,7 +315,7 @@ export const dict = { "go.faq.a2": "Go 包含下方列出的模型,提供充足的額度與穩定的存取。", "go.faq.q3": "Go 與 Zen 一樣嗎?", "go.faq.a3": - "不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等開源模型。", + "不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等開源模型。", "go.faq.q4": "Go 費用是多少?", "go.faq.a4.p1.beforePricing": "Go 費用為", "go.faq.a4.p1.pricingLink": "首月 $5", @@ -333,7 +337,7 @@ export const dict = { "go.faq.q9": "免費模型與 Go 有什麼區別?", "go.faq.a9": - "免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 與 DeepSeek V4 Flash,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。", + "免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 與 DeepSeek V4 Flash,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。", "zen.api.error.rateLimitExceeded": "超出頻率限制。請稍後再試。", "zen.api.error.modelNotSupported": "不支援模型 {{model}}", @@ -682,11 +686,11 @@ export const dict = { "download.title": "OpenCode | 下載", "download.meta.description": "下載適用於 macOS、Windows 與 Linux 的 OpenCode", - "download.hero.title": "下載 OpenCode", + "download.hero.title": "下載 OpenCode 桌面版", "download.hero.subtitle": "適用於 macOS、Windows 與 Linux 的 Beta 版現已提供", "download.hero.button": "下載 {{os}} 版", "download.section.terminal": "OpenCode 終端", - "download.section.desktop": "OpenCode 桌面版(Beta)", + "download.section.desktop": "OpenCode 桌面版", "download.section.extensions": "OpenCode 擴充功能", "download.section.integrations": "OpenCode 整合", "download.action.download": "下載", diff --git a/packages/console/app/src/lib/stats-proxy.ts b/packages/console/app/src/lib/stats-proxy.ts index d63b966d23..48e95bd74a 100644 --- a/packages/console/app/src/lib/stats-proxy.ts +++ b/packages/console/app/src/lib/stats-proxy.ts @@ -18,7 +18,8 @@ export async function statsProxy(evt: APIEvent) { if ( targetUrl.pathname.startsWith(`${dataPath}/_build/`) || targetUrl.pathname === `${dataPath}/banner.jpg` || - targetUrl.pathname === `${dataPath}/banner.png` + targetUrl.pathname === `${dataPath}/banner.png` || + targetUrl.pathname === `${dataPath}/sitemap.xml` ) { targetUrl.pathname = targetUrl.pathname.slice(dataPath.length) } @@ -97,7 +98,8 @@ function isDataBypassPath(pathname: string) { pathname.startsWith(`${dataPath}/api/`) || pathname.startsWith(`${dataPath}/_server`) || pathname === `${dataPath}/banner.jpg` || - pathname === `${dataPath}/banner.png` + pathname === `${dataPath}/banner.png` || + pathname === `${dataPath}/sitemap.xml` ) } diff --git a/packages/console/app/src/routes/download/index.css b/packages/console/app/src/routes/download/index.css index b2176c34a2..1dde6e6105 100644 --- a/packages/console/app/src/routes/download/index.css +++ b/packages/console/app/src/routes/download/index.css @@ -284,7 +284,7 @@ } [data-component="content"] { - padding: 6rem 5rem; + padding: 3rem 5rem 6rem; @media (max-width: 60rem) { padding: 4rem 1.5rem; @@ -316,67 +316,43 @@ /* Download Hero Section */ [data-component="download-hero"] { - /* display: grid; */ - display: none; - grid-template-columns: 260px 1fr; + display: grid; + grid-template-columns: 255px 1fr; gap: 4rem; - padding-bottom: 2rem; + padding: 4px; + border: 1px solid var(--color-border-weak); + border-radius: 8px; margin-bottom: 4rem; @media (max-width: 50rem) { grid-template-columns: 1fr; - gap: 1.5rem; - padding-bottom: 2rem; + gap: 0; margin-bottom: 2rem; } - [data-component="hero-icon"] { - display: flex; - justify-content: flex-end; - align-items: center; - - @media (max-width: 40rem) { - display: none; - } - - [data-slot="icon-placeholder"] { - width: 120px; - height: 120px; - background: var(--color-background-weak); - border: 1px solid var(--color-border-weak); - border-radius: 24px; + [data-component="hero-video"] { + video { + display: block; + width: 100%; + aspect-ratio: 1; + object-fit: cover; + border-radius: 4px; @media (max-width: 50rem) { - width: 80px; - height: 80px; + aspect-ratio: 16 / 9; } } - - img { - width: 120px; - height: 120px; - border-radius: 24px; - box-shadow: - 0 1.467px 2.847px 0 rgba(0, 0, 0, 0.42), - 0 0.779px 1.512px 0 rgba(0, 0, 0, 0.34), - 0 0.324px 0.629px 0 rgba(0, 0, 0, 0.24); - - @media (max-width: 50rem) { - width: 80px; - height: 80px; - border-radius: 16px; - } - } - - @media (max-width: 50rem) { - justify-content: flex-start; - } } [data-component="hero-text"] { display: flex; flex-direction: column; justify-content: center; + padding-right: 3rem; + + @media (max-width: 50rem) { + padding: 1.5rem; + } h1 { font-size: 1.5rem; diff --git a/packages/console/app/src/routes/download/index.tsx b/packages/console/app/src/routes/download/index.tsx index b5c202a5ec..c69895e4f2 100644 --- a/packages/console/app/src/routes/download/index.tsx +++ b/packages/console/app/src/routes/download/index.tsx @@ -11,7 +11,7 @@ import { LocaleLinks } from "~/component/locale-links" import { config } from "~/config" import { useI18n } from "~/context/i18n" import { useLanguage } from "~/context/language" -import desktopAppIcon from "../../asset/lander/opencode-desktop-icon.png" +import desktopTabsVideo from "../../asset/lander/desktop-tabs-landscape.mp4" import type { DownloadPlatform } from "./types" type OS = "macOS" | "Windows" | "Linux" | null @@ -93,12 +93,14 @@ export default function Download() {
-
- +
+

{i18n.t("download.hero.title")}

-

{i18n.t("download.hero.subtitle")}

+

+ {i18n.t("home.promo.body")} {i18n.t("home.promo.cta")} +

{i18n.t("enterprise.faq.a4")} +
  • + + {i18n.t("enterprise.faq.a5.before")} trust.opencode.ai{" "} + {i18n.t("enterprise.faq.a5.after")} + +
  • diff --git a/packages/console/app/src/routes/go/index.css b/packages/console/app/src/routes/go/index.css index 45b61d217e..bde8c42986 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; @@ -473,27 +504,6 @@ body { pointer-events: none; } - [data-slot="xlabels"] { - position: absolute; - inset: 0; - pointer-events: none; - } - - [data-slot="xlabels"] [data-xlabel] { - position: absolute; - left: var(--x); - top: var(--y); - transform: translate(-50%, -50%); - color: var(--color-text-weak); - font-size: 12px; - line-height: 1; - white-space: nowrap; - - @media (prefers-color-scheme: light) { - color: color-mix(in srgb, var(--color-text-weak) 82%, var(--color-text-strong)); - } - } - [data-slot="ylabels"] [data-ylabel] { position: absolute; left: var(--x); @@ -577,12 +587,6 @@ body { display: block; } - [data-grid] { - stroke: var(--color-border); - stroke-width: 1; - opacity: 0.6; - } - [data-tick] { fill: var(--color-text-weak); font-size: 12px; @@ -628,8 +632,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 +656,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); @@ -1029,26 +1028,6 @@ body { [data-slot="faq-models"] { margin-top: 16px; - overflow-x: auto; - - table { - width: 100%; - min-width: 28rem; - border-collapse: collapse; - } - - th, - td { - padding: 12px 16px; - text-align: left; - white-space: nowrap; - } - - th { - background: var(--color-background-weak); - color: var(--color-text-strong); - font-weight: 500; - } } } } diff --git a/packages/console/app/src/routes/go/index.tsx b/packages/console/app/src/routes/go/index.tsx index 960099fe49..d6fb3c732f 100644 --- a/packages/console/app/src/routes/go/index.tsx +++ b/packages/console/app/src/routes/go/index.tsx @@ -23,19 +23,21 @@ 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" }, + "Grok 4.5", + "GLM-5.2", + "GLM-5.1", + "Kimi K3", + "Kimi K2.7 Code", + "Kimi K2.6", + "MiMo-V2.5-Pro", + "MiMo-V2.5", + "Qwen3.7 Max", + "Qwen3.7 Plus", + "Qwen3.6 Plus", + "MiniMax M3", + "MiniMax M2.7", + "DeepSeek V4 Pro", + "DeepSeek V4 Flash", ] function LimitsGraph(props: { href: string }) { @@ -59,8 +61,10 @@ 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 (2x usage)", req: 280, baseReq: 140, d: "75ms" }, { 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" }, @@ -76,55 +80,31 @@ function LimitsGraph(props: { href: string }) { const left = 40 const right = 60 const top = 18 - const bottom = 44 + const bottom = 18 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 labels = (() => { - const set = new Set() - let last = -Infinity - for (const t of ticks) { - if (t === 1) { - set.add(t) - last = x(t) - continue - } - const pos = x(t) - if (pos - last < 44) continue - set.add(t) - last = pos - } - return set - })() - const shown = ticks.filter((t) => labels.has(t)) 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}%` const lx = px(left - 16) - const ty = py(h - 18) return (
    - - + + ) + return (
    - 0 || messageComments().length > 0}> -
    - - {(comment) => ( - - )} - - - {(file) => { - const type = kind(file) - const name = file.filename ?? i18n.t("ui.message.attachment.alt") - - return ( - { - if (type === "image") openImagePreview(file.url, name) - }} - > - - - {name} -
    - } - > - {name} -
    -
    - } - > - props.actions?.openAttachment?.(file)} - > - {typeLabel(name, file.mime)} - - - ) - }} - + {renderAttachments()} + 0}> + + + } + > +
    +
    0 ? "true" : undefined}> + + 0}> + + +
    - - <> -
    -
    - -
    -
    -
    - - - - - {metaHead()} - - - - - {"\u00A0\u00B7\u00A0"} - - - - - {metaTail()} - - - - - - event.preventDefault()} - onClick={(event) => { - event.stopPropagation() - revert() - }} - aria-label={i18n.t("ui.message.revertMessage")} - /> - + {renderAttachments()} + 0)}> +
    + + + + + {metaHead()} + + + + + {"\u00A0\u00B7\u00A0"} + + + + + {metaTail()} + + + + + + event.preventDefault()} + onClick={(event) => { + event.stopPropagation() + revert() + }} + aria-label={i18n.t("ui.message.revertMessage")} + /> + + -
    - +
    +
    ) diff --git a/packages/session-ui/src/pierre/index.ts b/packages/session-ui/src/pierre/index.ts index 7ef3ffb033..1e26d94417 100644 --- a/packages/session-ui/src/pierre/index.ts +++ b/packages/session-ui/src/pierre/index.ts @@ -15,6 +15,10 @@ export type DiffProps = FileDiffOptions & { } const unsafeCSS = ` +:host { + --diffs-bg: var(--opencode-diffs-bg, var(--color-background-stronger)); +} + [data-diff], [data-file] { /* Pierre 1.2 mixes these override targets at 12% in light mode and 20% in dark mode. */ @@ -129,7 +133,7 @@ const unsafeCSS = ` height: 24px; } [data-column-number] { - background-color: var(--background-stronger); + background-color: var(--diffs-bg); cursor: default !important; } diff --git a/packages/session-ui/src/v2/components/attachment-card-v2.css b/packages/session-ui/src/v2/components/attachment-card-v2.css index 1a19dc7b5e..6810406237 100644 --- a/packages/session-ui/src/v2/components/attachment-card-v2.css +++ b/packages/session-ui/src/v2/components/attachment-card-v2.css @@ -12,6 +12,16 @@ box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base); cursor: default; + &[data-wide] { + width: 100%; + min-width: 0; + max-width: none; + } + + &[data-surface="base"] { + background: var(--v2-background-bg-base); + } + &[data-active] { box-shadow: inset 0 0 0 0.5px var(--v2-border-border-strong); } @@ -32,6 +42,8 @@ } [data-slot="attachment-card-v2-title"] { + width: 100%; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/packages/session-ui/src/v2/components/attachment-card-v2.tsx b/packages/session-ui/src/v2/components/attachment-card-v2.tsx index 2e896383a5..69910671e2 100644 --- a/packages/session-ui/src/v2/components/attachment-card-v2.tsx +++ b/packages/session-ui/src/v2/components/attachment-card-v2.tsx @@ -6,8 +6,11 @@ export function AttachmentCardV2(props: { title: string active?: boolean clickable?: boolean + wide?: boolean + surface?: "base" /** native title attribute */ hover?: string + titleRef?: (element: HTMLSpanElement) => void onClick?: () => void children: JSX.Element }) { @@ -16,10 +19,14 @@ export function AttachmentCardV2(props: { data-component="attachment-card-v2" data-active={props.active ? "true" : undefined} data-clickable={props.clickable ? "true" : undefined} + data-wide={props.wide ? "true" : undefined} + data-surface={props.surface} title={props.hover} onClick={() => props.onClick?.()} > - {props.title} + props.titleRef?.(element)} data-slot="attachment-card-v2-title"> + {props.title} + {props.children}
    ) diff --git a/packages/session-ui/src/v2/components/comment-card-v2.tsx b/packages/session-ui/src/v2/components/comment-card-v2.tsx index fa2fe08c32..dbcdb24345 100644 --- a/packages/session-ui/src/v2/components/comment-card-v2.tsx +++ b/packages/session-ui/src/v2/components/comment-card-v2.tsx @@ -1,6 +1,7 @@ -import { Show } from "solid-js" +import { createSignal, onCleanup, onMount, Show } from "solid-js" import { FileIcon } from "@opencode-ai/ui/file-icon" import { getFilenameTruncated } from "@opencode-ai/core/util/path" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { AttachmentCardV2 } from "./attachment-card-v2" export function CommentCardV2(props: { @@ -9,19 +10,55 @@ export function CommentCardV2(props: { selection?: { startLine: number; endLine: number } active?: boolean title?: string + tooltip?: boolean + wide?: boolean onClick?: () => void }) { + let title: HTMLSpanElement | undefined + const [truncated, setTruncated] = createSignal(false) + + onMount(() => { + const element = title + if (!element) return + const sync = () => setTruncated(element.scrollWidth > element.clientWidth) + const measure = () => requestAnimationFrame(sync) + const observer = new ResizeObserver(sync) + observer.observe(element) + measure() + void document.fonts?.ready.then(measure) + onCleanup(() => observer.disconnect()) + }) + return ( - - - - {getFilenameTruncated(props.path, 14)} - - {(sel) => - sel().startLine === sel().endLine ? `:${sel().startLine}` : `:${sel().startLine}-${sel().endLine}` - } - - - + + { + title = element + }} + onClick={props.onClick} + > + + + {getFilenameTruncated(props.path, 14)} + + {(sel) => + sel().startLine === sel().endLine ? `:${sel().startLine}` : `:${sel().startLine}-${sel().endLine}` + } + + + + ) } 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..8c7b12e9ae --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/attachments.ts @@ -0,0 +1,266 @@ +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 + onError: (error: unknown) => void + readClipboardImage?: () => Promise + getPathForFile?: (file: File) => 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()) => { + if (!target) return false + const mime = await attachmentMime(file) + if (!mime) { + if (toast) input.warn() + return false + } + const url = await dataUrl(file, mime) + if (!url) return false + const attachment: PromptInputV2Attachment = { + type: "image", + id: globalThis.crypto?.randomUUID?.() ?? Math.random().toString(16).slice(2), + filename: file.name, + sourcePath: input.getPathForFile?.(file) || undefined, + mime, + dataUrl: url, + } + 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))) 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) + }, + } +} + +function dataUrl(file: File, mime: string) { + return new Promise((resolve) => { + const reader = new FileReader() + reader.addEventListener("error", () => resolve("")) + reader.addEventListener("load", () => { + const value = typeof reader.result === "string" ? reader.result : "" + const index = value.indexOf(",") + resolve(index === -1 ? value : `data:${mime};base64,${value.slice(index + 1)}`) + }) + reader.readAsDataURL(file) + }) +} + +const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]) +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..30206cba26 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx @@ -0,0 +1,695 @@ +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" + +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 +} + +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.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 ( + + ) +} + +function keybindTitle(label: string, keybind?: string[]) { + if (!keybind?.length) return label + return `${label} (${keybind.join("+")})` +} 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..a003c081be --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/interaction.ts @@ -0,0 +1,480 @@ +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 +} + +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 + } + const text = draft.state.prompt.map((item) => ("content" in item ? item.content : "")).join("") + const cursor = draft.state.cursor ?? text.length + draft.setText(text.slice(0, cursor) + part.content + text.slice(cursor)) + 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) + setState(reconcile(result.state)) + 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") { + const action = input.onSuggestionSelect?.(event.item) + if (!action) return result.handled + if (event.item.kind === "command") { + draft.setPrompt( + draft.state.prompt.filter((part): part is PromptInputV2Attachment => part.type === "image"), + 0, + ) + } + 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..4b75e08bcf --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/machine.test.ts @@ -0,0 +1,137 @@ +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("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..7102b79318 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/machine.ts @@ -0,0 +1,256 @@ +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) + 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): 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.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 = 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..525c264097 --- /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", + dataUrl: "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, + dataUrl: `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..99e5f6102e --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/store.test.ts @@ -0,0 +1,94 @@ +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", + dataUrl: "data:text/plain;base64,", + }, + ], + 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", + dataUrl: "data:text/plain;base64,", + }, + ]) + expect(prompt.state.cursor).toBe(7) + }) + + 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..d924c554ac --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/store.ts @@ -0,0 +1,116 @@ +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) + }) + }, + 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 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 }, + ] + }) + let offset = 0 + return parts.map((part) => { + if (part.type === "image") return part + const next = { ...part, start: offset, end: offset + part.content.length } + offset = next.end + return next + }) +} 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..a2630a5ea6 --- /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 + dataUrl: 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..9f42cd90fe 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 @@ -263,7 +263,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 9db2ba580c..364597e7a0 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.css +++ b/packages/session-ui/src/v2/components/session-review-v2.css @@ -38,7 +38,8 @@ border-right-width: 0; } -[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar"]:not([data-resizing]) { +[data-component="session-review-v2-sidebar-root"] + [data-slot="session-review-v2-sidebar"][data-transition]:not([data-resizing]) { transition: width 200ms cubic-bezier(0.22, 1, 0.36, 1), border-right-width 200ms cubic-bezier(0.22, 1, 0.36, 1); @@ -148,7 +149,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)); @@ -272,9 +273,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 6efb2b593a..abbe90d112 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-v2.tsx @@ -39,6 +39,7 @@ export type SessionReviewV2Props = { export type SessionReviewV2SidebarProps = { open: boolean + transition: boolean title?: JSX.Element stats?: JSX.Element filter: string @@ -76,6 +77,7 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) {
    @@ -162,15 +165,11 @@ export function SessionReviewV2(props: SessionReviewV2Props) { } const prev = () => { - const files = props.files - if (files.length === 0) return - return files[(fileIndex() - 1 + files.length) % files.length] + return props.files[fileIndex() - 1] } const next = () => { - const files = props.files - if (files.length === 0) return - return files[(fileIndex() + 1) % files.length] + return props.files[fileIndex() + 1] } const canCycle = () => props.files.length > 0 @@ -192,8 +191,10 @@ export function SessionReviewV2(props: SessionReviewV2Props) { 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 === "<" ? prev() : next() + if (!file) return event.preventDefault() - cycle(event.key === "<" ? prev() : next()) + cycle(file) }) const toolbarStart = () => ( @@ -214,6 +215,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
    {i18n.t("ui.sessionReviewV2.previousFile")} @@ -226,13 +228,14 @@ 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")} @@ -245,7 +248,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")} /> diff --git a/packages/slack/package.json b/packages/slack/package.json index 11bb354f0d..5f1eba75ef 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.17.18", + "version": "1.18.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index f1def44cec..ee85693737 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.17.18", + "version": "1.18.3", "private": true, "type": "module", "license": "MIT", @@ -13,6 +13,7 @@ }, "dependencies": { "@ibm/plex": "6.4.1", + "@kobalte/core": "catalog:", "@opencode-ai/stats-core": "workspace:*", "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", diff --git a/packages/stats/app/src/app.tsx b/packages/stats/app/src/app.tsx index a4627b3278..c23c7e7934 100644 --- a/packages/stats/app/src/app.tsx +++ b/packages/stats/app/src/app.tsx @@ -1,20 +1,15 @@ -import { MetaProvider, Meta, Title } from "@solidjs/meta" +import { MetaProvider, Title } from "@solidjs/meta" import { Router } from "@solidjs/router" import { FileRoutes } from "@solidjs/start/router" 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() - return ( - <> - {i18n.t("app.title")} - - - ) + return {i18n.t("app.title")} } export default function App() { diff --git a/packages/stats/app/src/component/model-compare-detail.tsx b/packages/stats/app/src/component/model-compare-detail.tsx index 68cfcfee68..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 { @@ -161,16 +160,11 @@ export default function ModelCompareDetailPage(props: ModelCompareDetailPageProp buildComparisonModel(model.lab, model.slug, model.catalog ?? null, stats()?.models[index] ?? null), ), ) - const title = createMemo(() => { - if (props.family) - return `${props.family.first.name} vs ${props.family.second.name}: ${models()[0].name} vs ${models()[1].name}` - return `${models()[0].name} vs ${models()[1].name} - Model Comparison` - }) - const description = createMemo(() => { - if (props.family) - return `Compare the latest ${props.family.first.name} and ${props.family.second.name} models: ${models()[0].name} vs ${models()[1].name}. See benchmarks, usage, price, context length, and features.` - return `Compare ${models()[0].name} and ${models()[1].name} by usage, rank, context window, output limit, cache ratio, and cost across OpenCode data.` - }) + const title = createMemo(() => `${models()[0].name} vs ${models()[1].name} - AI Model Comparison`) + const description = createMemo( + () => + `Compare ${models()[0].name} from ${models()[0].labName} and ${models()[1].name} from ${models()[1].labName} on key metrics including benchmarks, price, context length, usage, and model features.`, + ) const canonicalPath = createMemo(() => { if (props.family) return canonicalFamilyComparisonPath(props.family.first, props.family.second) const first = firstCatalog() @@ -268,9 +262,8 @@ export default function ModelCompareDetailPage(props: ModelCompareDetailPageProp }} /> -
    @@ -281,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 7d14ec3d01..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" @@ -39,6 +38,7 @@ import { uniqueComparisonPairs, type ComparisonModelRef, } from "../compare-cards" +import { BreadcrumbSelect } from "../breadcrumb-select" import { applyThemePreference, Footer, @@ -199,6 +199,7 @@ export default function StatsModel() { pairs={modelComparisonPairs(catalog(), catalogEntry() ?? null, stats() ?? null)} title="Compare This Model" description="Other models to compare with this one." + variant="featured" /> @@ -294,61 +295,46 @@ function ModelHero(props: { 0} fallback={ - + {props.labName} } > -
    - - {props.labName} - - -
    - - {(lab) => ( - - {lab.name} - - )} - -
    -
    + ({ + href: language.route(`${import.meta.env.BASE_URL}${lab.id}`), + label: lab.name, + value: lab.id, + }))} + value={providerSlug(labId())} + variant="model" + />
    / 0} fallback={ - + {modelName()} } > -
    - - {modelName()} - - -
    - - {(model) => ( - - {model.name} - - )} - -
    -
    + ({ + href: language.route(`${import.meta.env.BASE_URL}${model.id}`), + label: model.name, + value: model.id, + }))} + value={props.catalog?.id ?? ""} + variant="model" + />
    @@ -432,7 +418,7 @@ function ModelHeroSparkline(props: { data: StatsModelData }) { function ChevronDownIcon() { return ( ) } diff --git a/packages/stats/app/src/routes/[lab]/index.tsx b/packages/stats/app/src/routes/[lab]/index.tsx index 01b7a4fc2f..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 { @@ -29,6 +28,7 @@ import { SectionHeading } from "../section-heading" import { runStatsEffect } from "../../stats-runtime" import { setStatsPageCacheHeaders } from "../stats-cache" import { ComparisonCardsSection, modelRefFromCatalog, uniqueComparisonPairs } from "../compare-cards" +import { BreadcrumbSelect } from "../breadcrumb-select" import { applyThemePreference, Footer, @@ -154,6 +154,7 @@ export default function StatsLab() { pairs={labComparisonPairs(data(), stats()?.models ?? [])} title={`${data().name} Model Comparisons`} description="Model pairs from this lab." + variant="featured" /> )} @@ -205,6 +206,7 @@ function LabHero(props: { lab: ModelCatalogLab; labs: ModelCatalogLab[] }) { function LabHeroBreadcrumb(props: { label: string; labs?: ModelCatalogLab[] }) { const language = useLanguage() const labs = () => props.labs ?? [] + const current = () => labs().find((lab) => lab.name === props.label) return ( ) diff --git a/packages/stats/app/src/routes/breadcrumb-select.tsx b/packages/stats/app/src/routes/breadcrumb-select.tsx new file mode 100644 index 0000000000..6057ea2710 --- /dev/null +++ b/packages/stats/app/src/routes/breadcrumb-select.tsx @@ -0,0 +1,71 @@ +import { Select } from "@kobalte/core/select" +import { createMemo } from "solid-js" + +export type BreadcrumbSelectOption = { + href: string + label: string + value: string +} + +export function BreadcrumbSelect(props: { + ariaLabel: string + current?: boolean + label: string + options: BreadcrumbSelectOption[] + value: string + variant: "lab" | "model" +}) { + const selected = createMemo(() => props.options.find((option) => option.value === props.value) ?? null) + + return ( + + data-component="stats-breadcrumb-select" + data-variant={props.variant} + options={props.options} + optionValue="value" + optionTextValue="label" + value={selected()} + placeholder={props.label} + disallowEmptySelection + closeOnSelection + shouldFocusWrap + placement="bottom" + gutter={8} + sameWidth={false} + itemComponent={(itemProps) => ( + + {itemProps.item.rawValue.label} + + )} + onChange={(option) => { + if (!option || option.value === props.value) return + window.location.assign(option.href) + }} + > + + data-slot="stats-breadcrumb-select-value"> + {(state) => {state.selectedOption()?.label ?? props.label}} + + + + + + + + + + + ) +} 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 03b33c4129..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" @@ -74,7 +73,8 @@ export default function ModelCompareIndex() { const [themePreference, setThemePreference] = createSignal("system") const compareUrl = createMemo(() => localizedUrl(language.locale(), comparePath)) const statsUnfurlUrl = new URL(statsUnfurlPath, localizedUrl("en", "/data/")).toString() - const featuredModels = createMemo(() => (catalog()?.models ?? []).slice(0, 120)) + const models = createMemo(() => catalog()?.models ?? []) + const featuredModels = createMemo(() => models().slice(0, 120)) const categories = createMemo(() => buildComparisonCategories(featuredModels())) const compareHeaderLinks = createMemo(() => [ { href: `${import.meta.env.BASE_URL}#top-models`, label: i18n.t("nav.topModels") }, @@ -134,7 +134,7 @@ export default function ModelCompareIndex() { Data / - + Compare @@ -150,7 +150,7 @@ export default function ModelCompareIndex() {
    1} + when={models().length > 1} fallback={
    No models found @@ -158,7 +158,7 @@ export default function ModelCompareIndex() {
    } > - +
    svg { flex: 0 0 auto; color: var(--stats-muted); } @@ -6502,6 +6509,7 @@ [data-page="stats"] [data-slot="compare-detail-label"][data-heading="true"] span { display: inline-flex; align-items: center; + flex: 0 0 auto; height: 24px; padding: 0 8px; color: var(--stats-muted); @@ -6509,6 +6517,7 @@ font-size: 12px; font-weight: 500; line-height: 1; + white-space: nowrap; } [data-page="stats"] [data-slot="compare-detail-value"] { @@ -6653,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; } @@ -6744,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; } @@ -6899,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"] { @@ -6962,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"] { @@ -7417,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"] { @@ -7876,6 +7883,10 @@ max-width: min(100%, 240px); } + [data-page="stats"] [data-component="model-hero-breadcrumb"] button[data-slot="stats-breadcrumb-select-trigger"] { + max-width: min(100%, 240px); + } + [data-page="stats"] [data-slot="model-hero-title-row"] { flex-wrap: wrap; gap: 16px; @@ -7967,10 +7978,6 @@ min-width: 48px; } - [data-page="stats"] [data-slot="lab-hero-options"] { - max-width: calc(100vw - 48px); - } - [data-page="stats"] [data-component="lab-related-list"] { grid-auto-columns: minmax(320px, 392px); grid-auto-flow: column; 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/core/package.json b/packages/stats/core/package.json index 6733366530..d942879028 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.17.18", + "version": "1.18.3", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index a4d44b874d..eba0143a2b 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.17.18", + "version": "1.18.3", "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/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/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 8523c2fd68..5addcdaa0b 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.17.18", + "version": "1.18.3", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index 26ffadbcf6..1681b66fab 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.17.18", + "version": "1.18.3", "type": "module", "license": "MIT", "repository": { 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/context/marked-code-span.test.ts b/packages/ui/src/context/marked-code-span.test.ts new file mode 100644 index 0000000000..ba67b2f678 --- /dev/null +++ b/packages/ui/src/context/marked-code-span.test.ts @@ -0,0 +1,13 @@ +import { expect, test } from "bun:test" +import { Marked } from "marked" +import { markedCodeSpanBoundary } from "./marked-code-span" + +test("preserves code spans adjacent to tildes", async () => { + const marked = new Marked(markedCodeSpanBoundary) + + expect(await marked.parse("~`0.1576` to measurement-window-only `0.00092`")).toBe( + "

    ~0.1576 to measurement-window-only 0.00092

    \n", + ) + expect(await marked.parse("`before`~`after`")).toBe("

    before~after

    \n") + expect(await marked.parse("~~`deleted code`~~")).toBe("

    deleted code

    \n") +}) diff --git a/packages/ui/src/context/marked-code-span.ts b/packages/ui/src/context/marked-code-span.ts new file mode 100644 index 0000000000..895935c311 --- /dev/null +++ b/packages/ui/src/context/marked-code-span.ts @@ -0,0 +1,17 @@ +import type { MarkedExtension } from "marked" + +// Keep adjacent tilde and backtick runs separate until markedjs/marked#4011 is released. +export const markedCodeSpanBoundary = { + tokenizer: { + inlineText(src) { + const match = /^(`+(?=~)|~+(?=`))/.exec(src) + if (!match) return false + return { + type: "text", + raw: match[0], + text: match[0], + escaped: this.lexer.state.inRawBlock, + } + }, + }, +} satisfies MarkedExtension diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index a4f264ac67..cead61ba60 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -3,6 +3,7 @@ import markedShiki from "marked-shiki" import katex from "katex" import { bundledLanguages, type BundledLanguage } from "shiki" import { createSimpleContext } from "./helper" +import { markedCodeSpanBoundary } from "./marked-code-span" import { getSharedHighlighter, registerCustomTheme, ThemeRegistrationResolved } from "@pierre/diffs" export const OpenCodeTheme = { @@ -521,6 +522,7 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext( name: "Marked", init: (props: { nativeParser?: NativeMarkdownParser }) => { const jsParser = marked.use( + markedCodeSpanBoundary, { renderer: { link({ href, title, text }) { diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index 8e112e4d19..330d5d5b6f 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -149,6 +149,7 @@ export const dict = { "ui.common.close": "إغلاق", "ui.common.next": "التالي", "ui.common.submit": "إرسال", + "ui.common.showMore": "عرض المزيد", "ui.permission.deny": "رفض", "ui.permission.allowAlways": "السماح دائمًا", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index 4661ece487..14ff06f2ce 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -149,6 +149,7 @@ export const dict = { "ui.common.close": "Fechar", "ui.common.next": "Próximo", "ui.common.submit": "Enviar", + "ui.common.showMore": "Mostrar mais", "ui.permission.deny": "Negar", "ui.permission.allowAlways": "Permitir sempre", diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index eac44effd4..50c21838f6 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -153,6 +153,7 @@ export const dict = { "ui.common.close": "Zatvori", "ui.common.next": "Dalje", "ui.common.submit": "Pošalji", + "ui.common.showMore": "Prikaži više", "ui.permission.deny": "Zabrani", "ui.permission.allowAlways": "Uvijek dozvoli", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index 0057691b2c..495cd901f6 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -148,6 +148,7 @@ export const dict = { "ui.common.close": "Luk", "ui.common.next": "Næste", "ui.common.submit": "Indsend", + "ui.common.showMore": "Vis mere", "ui.permission.deny": "Afvis", "ui.permission.allowAlways": "Tillad altid", diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index 4926527428..46204ae811 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -155,6 +155,7 @@ export const dict = { "ui.common.close": "Schließen", "ui.common.next": "Weiter", "ui.common.submit": "Absenden", + "ui.common.showMore": "Mehr anzeigen", "ui.permission.deny": "Verweigern", "ui.permission.allowAlways": "Immer erlauben", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index f982766d13..7603d227d1 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -162,6 +162,7 @@ export const dict: Record = { "ui.common.close": "Close", "ui.common.next": "Next", "ui.common.submit": "Submit", + "ui.common.showMore": "Show more", "ui.permission.deny": "Deny", "ui.permission.allowAlways": "Allow always", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index 59502b8421..82ea16266c 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -149,6 +149,7 @@ export const dict = { "ui.common.close": "Cerrar", "ui.common.next": "Siguiente", "ui.common.submit": "Enviar", + "ui.common.showMore": "Mostrar más", "ui.permission.deny": "Denegar", "ui.permission.allowAlways": "Permitir siempre", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index ccf681cc84..b15e82846a 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -149,6 +149,7 @@ export const dict = { "ui.common.close": "Fermer", "ui.common.next": "Suivant", "ui.common.submit": "Soumettre", + "ui.common.showMore": "Afficher plus", "ui.permission.deny": "Refuser", "ui.permission.allowAlways": "Toujours autoriser", diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index f22c0f6d5f..24051d56c9 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -148,6 +148,7 @@ export const dict = { "ui.common.close": "閉じる", "ui.common.next": "次へ", "ui.common.submit": "送信", + "ui.common.showMore": "さらに表示", "ui.permission.deny": "拒否", "ui.permission.allowAlways": "常に許可", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index f358810fd2..bd40c1b9fb 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -125,6 +125,7 @@ export const dict = { "ui.common.close": "닫기", "ui.common.next": "다음", "ui.common.submit": "제출", + "ui.common.showMore": "더 보기", "ui.permission.deny": "거부", "ui.permission.allowAlways": "항상 허용", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index 0ee729adcb..c97b4f41e1 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -128,6 +128,7 @@ export const dict: Record = { "ui.common.close": "Lukk", "ui.common.next": "Neste", "ui.common.submit": "Send inn", + "ui.common.showMore": "Vis mer", "ui.permission.deny": "Avslå", "ui.permission.allowAlways": "Tillat alltid", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index 4dbbe03add..1aed8def1b 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -148,6 +148,7 @@ export const dict = { "ui.common.close": "Zamknij", "ui.common.next": "Dalej", "ui.common.submit": "Prześlij", + "ui.common.showMore": "Pokaż więcej", "ui.permission.deny": "Odmów", "ui.permission.allowAlways": "Zezwalaj zawsze", diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index 80694cdbe0..33a278f849 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -148,6 +148,7 @@ export const dict = { "ui.common.close": "Закрыть", "ui.common.next": "Далее", "ui.common.submit": "Отправить", + "ui.common.showMore": "Показать ещё", "ui.permission.deny": "Запретить", "ui.permission.allowAlways": "Разрешить всегда", diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index 10675a28d4..eee263c23b 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -150,6 +150,7 @@ export const dict = { "ui.common.close": "ปิด", "ui.common.next": "ถัดไป", "ui.common.submit": "ส่ง", + "ui.common.showMore": "แสดงเพิ่มเติม", "ui.permission.deny": "ปฏิเสธ", "ui.permission.allowAlways": "อนุญาตเสมอ", diff --git a/packages/ui/src/i18n/tr.ts b/packages/ui/src/i18n/tr.ts index 5cdebfd6f4..3f1f052a89 100644 --- a/packages/ui/src/i18n/tr.ts +++ b/packages/ui/src/i18n/tr.ts @@ -155,6 +155,7 @@ export const dict = { "ui.common.close": "Kapat", "ui.common.next": "İleri", "ui.common.submit": "Gönder", + "ui.common.showMore": "Daha fazla göster", "ui.permission.deny": "Reddet", "ui.permission.allowAlways": "Her zaman izin ver", diff --git a/packages/ui/src/i18n/uk.ts b/packages/ui/src/i18n/uk.ts index abb35c79f5..6096983f4b 100644 --- a/packages/ui/src/i18n/uk.ts +++ b/packages/ui/src/i18n/uk.ts @@ -162,6 +162,7 @@ export const dict: Record = { "ui.common.close": "Закрити", "ui.common.next": "Далі", "ui.common.submit": "Надіслати", + "ui.common.showMore": "Показати більше", "ui.permission.deny": "Заборонити", "ui.permission.allowAlways": "Дозволяти завжди", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 8a9e18aeea..b5c1c19462 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -152,6 +152,7 @@ export const dict = { "ui.common.close": "关闭", "ui.common.next": "下一步", "ui.common.submit": "提交", + "ui.common.showMore": "显示更多", "ui.permission.deny": "拒绝", "ui.permission.allowAlways": "始终允许", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index 19ba8e4106..4c413efc0f 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -152,6 +152,7 @@ export const dict = { "ui.common.close": "關閉", "ui.common.next": "下一步", "ui.common.submit": "提交", + "ui.common.showMore": "顯示更多", "ui.permission.deny": "拒絕", "ui.permission.allowAlways": "永遠允許", diff --git a/packages/ui/src/v2/components/badge-v2.css b/packages/ui/src/v2/components/badge-v2.css index 748c08a711..5c7a59e0aa 100644 --- a/packages/ui/src/v2/components/badge-v2.css +++ b/packages/ui/src/v2/components/badge-v2.css @@ -25,3 +25,9 @@ [data-component="tag"][data-high-contrast] { border-color: var(--v2-border-border-strong); } + +[data-component="tag"][data-variant="accent"] { + border: 0; + background: var(--v2-background-bg-accent); + color: var(--v2-text-text-contrast); +} diff --git a/packages/ui/src/v2/components/badge-v2.stories.tsx b/packages/ui/src/v2/components/badge-v2.stories.tsx index 084838c0e1..19af508bc2 100644 --- a/packages/ui/src/v2/components/badge-v2.stories.tsx +++ b/packages/ui/src/v2/components/badge-v2.stories.tsx @@ -8,10 +8,11 @@ Use alongside headings or lists for quick metadata. ### API - Accepts standard span props. +- Optional: \`variant\` is \`neutral\` (default) or \`accent\`. - Optional: \`data-high-contrast\` attribute for stronger border contrast. ### Variants and states -- Single size style. +- Neutral and accent variants. - Optional high-contrast border style. ### Behavior @@ -52,3 +53,7 @@ export const HighContrast = {
    ), } + +export const Accent = { + render: () => New, +} diff --git a/packages/ui/src/v2/components/badge-v2.tsx b/packages/ui/src/v2/components/badge-v2.tsx index 3bb06259e1..8583d46da9 100644 --- a/packages/ui/src/v2/components/badge-v2.tsx +++ b/packages/ui/src/v2/components/badge-v2.tsx @@ -1,14 +1,17 @@ import { type ComponentProps, splitProps } from "solid-js" import "./badge-v2.css" -export interface TagProps extends ComponentProps<"span"> {} +export interface TagProps extends ComponentProps<"span"> { + variant?: "neutral" | "accent" +} export function Tag(props: TagProps) { - const [split, rest] = splitProps(props, ["class", "classList", "children"]) + const [split, rest] = splitProps(props, ["class", "classList", "children", "variant"]) return ( 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