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

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

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

- June 16 -

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

+ 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/link.tsx b/packages/app/src/components/link.tsx deleted file mode 100644 index 85f7efc539..0000000000 --- a/packages/app/src/components/link.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { ComponentProps, splitProps } from "solid-js" -import { usePlatform } from "@/context/platform" - -export interface LinkProps extends Omit, "href"> { - href: string -} - -export function Link(props: LinkProps) { - const platform = usePlatform() - const [local, rest] = splitProps(props, ["href", "children", "class"]) - - return ( - { - if (!local.href) return - event.preventDefault() - platform.openLink(local.href) - }} - {...rest} - > - {local.children} - - ) -} diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx new file mode 100644 index 0000000000..96b99aaf4a --- /dev/null +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -0,0 +1,586 @@ +import { ImagePreview } from "@opencode-ai/ui/image-preview" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" +import { createEffect, createMemo, on, Show } from "solid-js" +import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model" +import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" +import type { PromptInputProps } from "@/components/prompt-input/contracts" +import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history" +import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store" +import { promptDesignPlaceholder, promptPlaceholder } from "@/components/prompt-input/placeholder" +import { createPromptSubmit } from "@/components/prompt-input/submit" +import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" +import { useComments } from "@/context/comments" +import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { usePermission } from "@/context/permission" +import { type ImageAttachmentPart, usePrompt } from "@/context/prompt" +import { usePlatform } from "@/context/platform" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { createSessionTabs } from "@/pages/session/helpers" +import { showToast } from "@/utils/toast" +import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input" +import { + createPromptInputV2Controller, + createPromptInputV2State, + type PromptInputV2Interaction, +} from "@opencode-ai/session-ui/v2/prompt-input/interaction" + +export type PromptInputV2ComposerProps = { + class?: string + controller: PromptInputV2ComposerController + borderUnderlay?: boolean +} + +export type PromptInputV2ControllerProps = Omit +export type PromptInputV2ComposerController = PromptInputV2Interaction & { + readonly model: PromptInputProps["controls"]["model"] +} + +export function PromptInputV2Composer(props: PromptInputV2ComposerProps) { + const dialog = useDialog() + const command = useCommand() + const language = useLanguage() + + return ( +
+ + dialog.show(() => ) + } + /> + } + /> +
+ ) +} + +export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController { + const sdk = useSDK() + const sync = useSync() + const files = useFile() + const layout = useLayout() + const comments = useComments() + const dialog = useDialog() + const command = useCommand() + const permission = usePermission() + const language = useLanguage() + const platform = usePlatform() + const prompt = props.state ?? usePrompt() + let editor: HTMLDivElement | undefined + + const interaction = createPromptInputV2State() + const mode = () => interaction[0].mode + const history = props.history ?? createPersistedPromptInputHistory() + const tabs = () => props.controls.session.tabs + const activeFileTab = createSessionTabs({ + tabs, + pathFromTab: files.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab), + }).activeFileTab + const recent = createMemo(() => { + const all = tabs().all() + const active = activeFileTab() + const order = active ? [active, ...all.filter((tab) => tab !== active)] : all + return order.reduce((result, tab) => { + const path = files.pathFromTab(tab) + if (!path || result.includes(path)) return result + return [...result, path] + }, []) + }) + const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined)) + const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? "")) + const attachments = createMemo(() => + prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), + ) + const commentCount = createMemo(() => { + if (mode() === "shell") return 0 + return prompt.context.items().filter((item) => !!item.comment?.trim()).length + }) + const blank = createMemo(() => { + const text = prompt + .current() + .map((part) => ("content" in part ? part.content : "")) + .join("") + return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0 + }) + const stopping = createMemo(() => working() && blank()) + const placeholder = createMemo(() => + promptPlaceholder({ + mode: mode(), + commentCount: commentCount(), + example: mode() === "shell" ? "git status" : "", + suggest: false, + t: (key, params) => language.t(key as Parameters[0], params as never), + }), + ) + const designPlaceholder = () => promptDesignPlaceholder(mode(), placeholder()) + + const historyComments = () => { + const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) + return prompt.context.items().flatMap((item) => { + const comment = item.comment?.trim() + if (!comment) return [] + const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined + const nextSelection = + selection ?? + (item.selection + ? ({ start: item.selection.startLine, end: item.selection.endLine } satisfies SelectedLineRange) + : undefined) + if (!nextSelection) return [] + return [ + { + id: item.commentID ?? item.key, + path: item.path, + selection: { ...nextSelection }, + comment, + time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(), + origin: item.commentOrigin, + preview: item.preview, + } satisfies PromptHistoryComment, + ] + }) + } + const restoreHistoryComments = (items: PromptHistoryComment[]) => { + comments.replace( + items.map((item) => ({ + id: item.id, + file: item.path, + selection: { ...item.selection }, + comment: item.comment, + time: item.time, + })), + ) + prompt.context.replaceComments( + items.map((item) => ({ + type: "file", + path: item.path, + selection: selectionFromLines(item.selection), + comment: item.comment, + commentID: item.id, + commentOrigin: item.origin, + preview: item.preview, + })), + ) + } + + const accepting = createMemo(() => { + const id = props.controls.session.id + if (!id) return permission.isAutoAcceptingDirectory(sdk().directory) + return permission.isAutoAccepting(id, sdk().directory) + }) + const submission = createPromptSubmit({ + prompt, + info, + imageAttachments: attachments, + commentCount, + autoAccept: accepting, + mode, + working, + editor: () => editor, + queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })), + promptLength, + addToHistory: (value, mode) => controller.addHistory(value, mode), + resetHistoryNavigation: () => controller.resetHistory(), + setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }), + setPopover: (popover) => { + if (!popover) controller.dispatch({ type: "popover.close" }) + }, + newSessionWorktree: () => props.newSessionWorktree, + onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, + shouldQueue: props.shouldQueue, + onQueue: props.onQueue, + onAbort: props.onAbort, + onSubmit: props.onSubmit, + model: props.controls.model.selection, + }) + + const referenceDescription = (reference: ReferenceInfo) => + reference.source.type === "git" ? reference.source.repository : reference.source.path + const references = createMemo(() => + sync() + .data.reference.filter((reference) => !reference.hidden) + .map((reference) => ({ + id: `reference:${reference.name}`, + kind: "reference" as const, + label: `@${reference.name}`, + path: reference.path, + description: reference.description ?? referenceDescription(reference), + mention: { + type: "file" as const, + path: reference.path, + content: `@${reference.name}`, + start: 0, + end: 0, + mime: "application/x-directory", + filename: reference.name, + }, + })), + ) + const resources = createMemo(() => + Object.values(sync().data.mcp_resource).map((resource) => ({ + id: `resource:${resource.server}:${resource.uri}`, + kind: "resource" as const, + label: `@${resource.name}`, + path: resource.uri, + description: resource.description, + mention: { + type: "file" as const, + path: resource.uri, + content: `@${resource.name}`, + start: 0, + end: 0, + mime: resource.mimeType ?? "text/plain", + filename: resource.name, + url: resource.uri, + source: { + type: "resource" as const, + text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 }, + clientName: resource.server, + uri: resource.uri, + }, + }, + resource, + })), + ) + const context = createMemo(() => [ + ...references(), + ...props.controls.agents.available + .filter((agent) => !agent.hidden && agent.mode !== "primary") + .map((agent) => ({ + id: `agent:${agent.name}`, + kind: "agent" as const, + label: `@${agent.name}`, + mention: { type: "agent" as const, name: agent.name, content: `@${agent.name}`, start: 0, end: 0 }, + })), + ...resources(), + ...recent().map((path) => ({ + id: `file:${path}`, + kind: "file" as const, + label: path, + path, + recent: true, + mention: { type: "file" as const, path, content: `@${path}`, start: 0, end: 0 }, + })), + ]) + const slashCommands = createMemo(() => [ + ...sync().data.command.map((item) => ({ + id: `custom.${item.name}`, + trigger: item.name, + title: item.name, + description: item.description, + type: "custom" as const, + })), + ...command.options + .filter((item) => !item.disabled && !item.id.startsWith("suggested.") && item.slash) + .map((item) => ({ + id: item.id, + trigger: item.slash!, + title: item.title, + description: item.description, + type: "builtin" as const, + })), + ]) + const commands = createMemo(() => + slashCommands().map((item) => ({ + id: item.id, + kind: "command", + label: `/${item.trigger}`, + trigger: item.trigger, + title: item.title, + description: item.description, + keybind: command.keybindParts(item.id), + })), + ) + const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()]) + const controller = createPromptInputV2Controller({ + store: () => prompt.capture().store, + state: interaction, + identity: () => prompt.capture(), + history: { + entries: (mode) => + history.entries(mode).map((value) => { + const entry = normalizePromptHistoryEntry(value) + return { prompt: entry.prompt, metadata: entry.comments } + }), + add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()), + capture: historyComments, + restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]), + }, + commands, + context, + searchContextFiles: async (query) => + (await files.searchFilesAndDirectories(query)).map((path) => ({ + id: `file:${path}`, + kind: "file", + label: path, + path, + mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 }, + })), + onContextRemove(item) { + if (item?.commentID) comments.remove(item.path, item.commentID) + }, + openAttachment: (attachment) => + dialog.show(() => ), + openContext(key) { + const item = controller.contextItem(key) + if (item) openComment(item, props, sync, layout, files, comments) + }, + onEditor(element) { + editor = element as HTMLDivElement + props.ref?.(editor) + }, + onSuggestionSelect(item) { + if (item.kind !== "command") return + const selected = slashCommands().find((entry) => entry.id === item.id) + if (!selected || selected.type === "custom") return + return () => command.trigger(selected.id, "slash") + }, + attachments: { + picker: platform.openAttachmentPickerDialog, + directory: () => sdk().directory, + isDialogActive: () => !!dialog.active, + warn: () => + showToast({ + title: language.t("prompt.toast.pasteUnsupported.title"), + description: language.t("prompt.toast.pasteUnsupported.description"), + }), + duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }), + onError: (error) => + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }), + readClipboardImage: platform.readClipboardImage, + getPathForFile: platform.getPathForFile, + store: platform.draftStore?.putBlob, + }, + view: { + placeholder: designPlaceholder, + get agent() { + return props.controls.agents.visible && props.controls.agents.options.length > 0 + ? { + options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })), + current: () => props.controls.agents.current, + onSelect: (value: string) => props.controls.agents.select(value), + keybind: () => command.keybindParts("agent.cycle"), + } + : undefined + }, + variant: { + options: () => variants().map((value) => ({ id: value, label: value })), + current: () => props.controls.model.selection.variant.current() ?? "default", + onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value), + keybind: () => command.keybindParts("model.variant.cycle"), + }, + submit: { + stopping, + working, + onSubmit: () => void submission.handleSubmit(new Event("submit")), + onStop: () => void submission.abort(), + }, + }, + }) + Object.defineProperty(controller, "model", { get: () => props.controls.model }) + + command.register("prompt-input", () => [ + { + id: "file.attach", + title: language.t("prompt.action.attachFile"), + category: language.t("command.category.file"), + keybind: "mod+u", + disabled: controller.state.mode !== "normal", + onSelect: () => controller.attach(), + }, + { + id: "prompt.mode.shell", + title: language.t("command.prompt.mode.shell"), + category: language.t("command.category.session"), + keybind: "mod+shift+x", + disabled: controller.state.mode === "shell", + onSelect: () => controller.dispatch({ type: "mode.shell" }), + }, + { + id: "prompt.mode.normal", + title: language.t("command.prompt.mode.normal"), + category: language.t("command.category.session"), + keybind: "mod+shift+e", + disabled: controller.state.mode === "normal", + onSelect: () => controller.dispatch({ type: "mode.normal" }), + }, + ]) + + createEffect( + on( + () => props.edit?.id, + (id) => { + const edit = props.edit + if (!id || !edit) return + prompt.context.items().forEach((item) => prompt.context.remove(item.key)) + edit.context.forEach((item) => + prompt.context.add({ + type: item.type, + path: item.path, + selection: item.selection, + comment: item.comment, + commentID: item.commentID, + commentOrigin: item.commentOrigin, + preview: item.preview, + }), + ) + controller.dispatch({ type: "mode.normal" }) + controller.resetHistory() + prompt.set(edit.prompt, promptLength(edit.prompt)) + controller.restoreFocus() + props.onEditLoaded?.() + }, + { defer: true }, + ), + ) + + return controller as PromptInputV2ComposerController +} + +function PromptInputV2ModelControl(props: { + loading: boolean + paid: boolean + title: string + keybind: string[] + model: PromptInputV2ComposerController["model"]["selection"] + providerID?: string + modelName: string + onClose: () => void + onUnpaidClick: () => void +}) { + const shouldAnimate = createMemo((previous) => previous ?? props.loading) + const content = () => ( + <> + + {(providerID) => ( + + )} + + {props.modelName} + + + + + ) + return ( + + + {props.title} + + + } + > + + {content()} + + } + > + ( + + {content()} + + )} + onClose={props.onClose} + /> + + + + ) +} + +function openComment( + item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }, + props: PromptInputV2ControllerProps, + sync: ReturnType, + layout: ReturnType, + files: ReturnType, + comments: ReturnType, +) { + if (!item.commentID) return + const focus = { file: item.path, id: item.commentID } + comments.setActive(focus) + const queueFocus = (attempts = 6) => { + requestAnimationFrame(() => { + comments.setFocus({ ...focus }) + if (attempts <= 0) return + requestAnimationFrame(() => { + const current = comments.focus() + if (current?.file === focus.file && current.id === focus.id) queueFocus(attempts - 1) + }) + }) + } + const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined + const review = + item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path)) + if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() + if (review) { + layout.fileTree.setTab("changes") + props.controls.session.tabs.setActive("review") + queueFocus() + return + } + layout.fileTree.setTab("all") + const tab = files.tab(item.path) + void props.controls.session.tabs.open(tab) + props.controls.session.tabs.setActive(tab) + void Promise.resolve(files.load(item.path)).finally(() => queueFocus()) +} diff --git a/packages/app/src/components/prompt-input.stories.tsx b/packages/app/src/components/prompt-input.stories.tsx index 362a741401..0b9c26ce41 100644 --- a/packages/app/src/components/prompt-input.stories.tsx +++ b/packages/app/src/components/prompt-input.stories.tsx @@ -30,8 +30,16 @@ function PromptInputExample() { activeTab: undefined as string | undefined, reviewOpen: false, }) + const storyModel = { + id: "claude-3-7-sonnet", + name: "Claude 3.7 Sonnet", + provider: { id: "anthropic", name: "Anthropic" }, + } const model = { - current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }), + current: () => storyModel, + list: () => [storyModel], + visible: () => true, + set: () => {}, variant: { list: () => ["fast", "thinking"], current: () => controls.variant, @@ -67,7 +75,6 @@ function PromptInputExample() { open: () => setControls("reviewOpen", true), }, }, - newLayoutDesigns: true, } const addReviewComment = () => { const comment = controls.comments + 1 @@ -146,7 +153,6 @@ function PromptInputWithOpenDock() { }, reviewPanel: { opened: () => false, open: () => {} }, }, - newLayoutDesigns: true, } const state = { blocked: () => false, diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index bbb71e8722..08c1ee3489 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -13,12 +13,11 @@ import { Match, type JSX, } from "solid-js" -import { createStore, type SetStoreFunction, type Store } from "solid-js/store" -import type { useLocal } from "@/context/local" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" import { ContentPart, DEFAULT_PROMPT, + isCommentItem, isPromptEqual, Prompt, usePrompt, @@ -48,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" @@ -59,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", @@ -632,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) } @@ -678,7 +591,7 @@ export const PromptInput: Component = (props) => { type: "resource", name: resource.name, uri: resource.uri, - client: resource.client, + client: resource.server, display: resource.name, description: resource.description, mime: resource.mimeType, @@ -796,7 +709,7 @@ export const PromptInput: Component = (props) => { title: cmd.name, description: cmd.description, type: "custom" as const, - source: cmd.source, + // source: cmd.source, })) return [...custom, ...builtin] @@ -1188,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()) } @@ -1536,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)} @@ -1604,503 +1456,281 @@ export const PromptInput: Component = (props) => { onSlashMenuKeyDown={handleSlashMenuKeyDown} commandKeybind={command.keybind} commandKeybindParts={command.keybindParts} - newLayoutDesigns={props.controls.newLayoutDesigns} + newLayoutDesigns={false} t={(key) => language.t(key as Parameters[0])} /> - - -
- - - { - const active = comments.active() - return !!item.commentID && item.commentID === active?.id && item.path === active?.file - }} - openComment={openComment} - remove={(item) => { - if (item.commentID) comments.remove(item.path, item.commentID) - prompt.context.remove(item.key) - }} - t={(key) => language.t(key as Parameters[0])} - /> - - dialog.show(() => ) - } - onRemove={removeAttachment} - removeLabel={language.t("prompt.attachment.remove")} - /> -
{ - const target = e.target - if (!(target instanceof HTMLElement)) return - if (target.closest('[data-action^="prompt-"]')) return - editorRef?.focus() - }} - > -
(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) - }} - t={(key) => language.t(key as Parameters[0])} - /> - - dialog.show(() => ) - } - onRemove={removeAttachment} - removeLabel={language.t("prompt.attachment.remove")} + 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()} +
+
+ + -
) @@ -2430,7 +2447,7 @@ function UpdateAvailableToast(props: { onCleanup(() => { if (toastId === undefined) return - toaster.dismiss(toastId) + dismissToast(toastId) }) return null diff --git a/packages/app/src/pages/layout/project-avatar-state.ts b/packages/app/src/pages/layout/project-avatar-state.ts index ca105af37c..236f6bd405 100644 --- a/packages/app/src/pages/layout/project-avatar-state.ts +++ b/packages/app/src/pages/layout/project-avatar-state.ts @@ -21,9 +21,10 @@ export function useSessionTabAvatarState( const hasPermissions = createMemo(() => { const serverSync = sync() if (!serverSync) return false + const permissionState = permission.ensureServerState(server()) const [store] = serverSync.child(directory(), { bootstrap: false }) return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => { - return !permission.autoResponds(item, directory()) + return !permissionState.autoResponds(item, directory()) }) }) const hasQuestions = createMemo(() => { @@ -33,9 +34,11 @@ export function useSessionTabAvatarState( return !!sessionQuestionRequest(store.session, serverSync.session.data.question, sessionId()) }) const needsAttention = createMemo(() => hasPermissions() || hasQuestions()) - const unread = createMemo( - () => needsAttention() || notification.ensureServerState(server()).session.unseenCount(sessionId()) > 0, - ) + const notificationState = createMemo(() => { + if (!connection()) return + return notification.ensureServerState(server()) + }) + const unread = createMemo(() => needsAttention() || (notificationState()?.session.unseenCount(sessionId()) ?? 0) > 0) const loading = createMemo(() => { const serverSync = sync() if (!serverSync) return false diff --git a/packages/app/src/pages/layout/session-tab-avatar.tsx b/packages/app/src/pages/layout/session-tab-avatar.tsx index 3c776c8671..0902173643 100644 --- a/packages/app/src/pages/layout/session-tab-avatar.tsx +++ b/packages/app/src/pages/layout/session-tab-avatar.tsx @@ -19,16 +19,34 @@ export function SessionTabAvatar(props: { () => props.directory, () => props.sessionId, ) + return ( + + ) +} + +export function SessionTabAvatarView(props: { + project?: LocalProject + directory: string + revealProjectOnHover?: boolean + unread: boolean + loading: boolean +}) { const projectAvatar = () => ( ) return ( - + sdk().directory) - const openProviderSettings = useSettingsDialog("providers") - const route = useSessionKey() - const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() - const local = useLocal() - const model = createPromptModelSelection({ agent: local.agent.current }) - - useComposerCommands({ model }) - - let inputRef: HTMLDivElement | undefined - - const inputController = createPromptInputController({ - sessionKey: route.sessionKey, - sessionID: () => route.params.id, - queryOptions: serverSync().queryOptions, - model, - }) - const projectControls = createPromptProjectControls() - const projectController = createPromptProjectController({ - controls: projectControls, - onDone: () => inputRef?.focus(), - }) - - command.register("new-session", () => [ - { - id: "input.focus", - title: language.t("command.input.focus"), - category: language.t("command.category.view"), - keybind: "ctrl+l", - onSelect: () => inputRef?.focus(), - }, - ]) - - const [store, setStore] = createStore<{ worktree?: string }>({}) const rightMount = useTitlebarRightMount() - - const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git") - const newSessionWorktree = createMemo(() => { - if (!showWorkspaceBar()) return "main" - if (store.worktree) return store.worktree - const project = sync().project - if (project && sdk().directory !== project.worktree) return sdk().directory - return "main" + const workspace = createNewSessionWorkspaceController() + const draft = createNewSessionDraftController({ + worktree: workspace.selection.value, + resetWorktree: workspace.selection.reset, }) - const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory) - const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch) - const selectedBranch = createMemo(() => { - const worktree = newSessionWorktree() - if (worktree === "main" || worktree === "create") return localBranch() - return serverSync().child(worktree)[0].vcs?.branch ?? localBranch() + const project = createPromptProjectController({ + controls: draft.project.controls, + onDone: draft.input.restoreFocus, + }) + useNewSessionCommands({ + restoreFocus: draft.input.restoreFocus, + project: { + empty: project.empty, + open: () => project.setOpen(true), + }, }) - createEffect(() => { - if (!prompt.ready()) return - untrack(() => { - const text = searchParams.prompt - if (!text) return - prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) - setSearchParams({ ...searchParams, prompt: undefined }) - }) - }) - - createEffect(() => { - if (!prompt.ready()) return - requestAnimationFrame(() => inputRef?.focus()) + if (!draft.prompt.ready()) return + draft.input.restoreFocus() }) const ready = Promise.resolve() - const [promptReady] = createResource( - () => prompt.ready.promise ?? ready, + const [suspendUntilPromptReady] = createResource( + () => draft.prompt.readyPromise() ?? ready, (promise) => promise.then(() => true), ) return (
- - {(mount) => ( - - - - - - - - )} - + {suspendUntilPromptReady()} +
-
-
- -
- - {language.t("prompt.loading")} -
- } - > -
- { - inputRef = el - }} - newSessionWorktree={newSessionWorktree()} - onNewSessionWorktreeReset={() => setStore("worktree", undefined)} - onSubmit={() => comments.clear()} - toolbar={ - - - - } - /> - -
- - - - setStore( - "worktree", - value === "main" && sync().project?.worktree !== sdk().directory - ? sync().project?.worktree - : value, - ) - } - onDone={() => inputRef?.focus()} - /> - -
-
-
- -
- - serverSync().child(sdk().directory)[0].provider_ready} - connected={() => providers.paid().length > 0} - openProviders={openProviderSettings} - /> -
-
+
) } - -function ProviderTip(props: { ready: () => boolean; connected: () => boolean; openProviders: () => void }) { - const language = useLanguage() - const [persistedState, setPersistedState, , persistedReady] = persisted( - Persist.global("new-session.provider-tip"), - createStore({ dismissedAt: 0 }), - ) - const visible = createMemo( - () => - props.ready() && - persistedReady() && - !props.connected() && - Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration, - ) - - function dismiss() { - setPersistedState("dismissedAt", Date.now()) - } - - const [ref, setRef] = createSignal() - const presence = createPresence({ - show: () => visible(), - element: () => ref() ?? null, - }) - - return ( - -
-
- - - - -
-
-
- ) -} diff --git a/packages/app/src/pages/new-session/new-session-draft-controller.ts b/packages/app/src/pages/new-session/new-session-draft-controller.ts new file mode 100644 index 0000000000..bf22834e48 --- /dev/null +++ b/packages/app/src/pages/new-session/new-session-draft-controller.ts @@ -0,0 +1,64 @@ +import { useSearchParams } from "@solidjs/router" +import { createEffect, untrack } from "solid-js" +import { usePromptInputV2Controller } from "@/components/prompt-input-v2" +import { useComments } from "@/context/comments" +import { useLocal } from "@/context/local" +import { usePrompt } from "@/context/prompt" +import { useServerSync } from "@/context/server-sync" +import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer" +import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection" +import { useSessionKey } from "@/pages/session/session-layout" +import { useComposerCommands } from "@/pages/session/use-composer-commands" + +export function createNewSessionDraftController(workspace: { worktree: () => string; resetWorktree: () => void }) { + const prompt = usePrompt() + const serverSync = useServerSync() + const comments = useComments() + const local = useLocal() + const route = useSessionKey() + const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() + const model = createPromptModelSelection({ agent: () => local.agent.current() }) + + useComposerCommands({ model }) + + const controls = createPromptInputController({ + sessionKey: route.sessionKey, + sessionID: () => route.params.id, + queryOptions: serverSync().queryOptions, + model, + }) + const projectControls = createPromptProjectControls() + const input = usePromptInputV2Controller({ + get controls() { + return controls() + }, + get newSessionWorktree() { + return workspace.worktree() + }, + onNewSessionWorktreeReset: workspace.resetWorktree, + onSubmit: comments.clear, + }) + + createEffect(() => { + if (!prompt.ready()) return + untrack(() => { + const text = searchParams.prompt + if (!text) return + prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) + setSearchParams({ ...searchParams, prompt: undefined }) + }) + }) + + return { + input, + prompt: { + ready: prompt.ready, + readyPromise: () => prompt.ready.promise, + }, + project: { + controls: projectControls, + }, + } +} + +export type NewSessionDraftController = ReturnType diff --git a/packages/app/src/pages/new-session/new-session-view.tsx b/packages/app/src/pages/new-session/new-session-view.tsx new file mode 100644 index 0000000000..5960e64335 --- /dev/null +++ b/packages/app/src/pages/new-session/new-session-view.tsx @@ -0,0 +1,162 @@ +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2" +import { Show, createMemo, createSignal, type Accessor } from "solid-js" +import { createStore } from "solid-js/store" +import { Portal } from "solid-js/web" +import createPresence from "solid-presence" +import { PromptInputV2Composer } from "@/components/prompt-input-v2" +import { PromptGitStatus, PromptWorkspaceSelector } from "@/components/prompt-workspace-selector" +import { + PromptProjectAddButton, + PromptProjectSelector, + type PromptProjectController, +} from "@/components/prompt-project-selector" +import { StatusPopoverV2 } from "@/components/status-popover" +import { useLanguage } from "@/context/language" +import { useSDK } from "@/context/sdk" +import { useServerSync } from "@/context/server-sync" +import { useProviders } from "@/hooks/use-providers" +import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" +import { Persist, persisted } from "@/utils/persist" +import type { NewSessionDraftController } from "./new-session-draft-controller" +import type { NewSessionWorkspaceController } from "./new-session-workspace-controller" + +const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000 + +export function NewSessionView(props: { + input: NewSessionDraftController["input"] + project: PromptProjectController + workspace: NewSessionWorkspaceController +}) { + return ( +
+
+
+
+ +
+ + + + + +
+ + + } + > + + +
+
+
+
+
+ +
+
+ ) +} + +export function NewSessionStatus(props: { mount: Accessor; visible: Accessor }) { + const language = useLanguage() + + return ( + + {(mount) => ( + + + + + + + + )} + + ) +} + +function ProviderTip() { + const language = useLanguage() + const dialog = useDialog() + const sdk = useSDK() + const serverSync = useServerSync() + const providers = useProviders(() => sdk().directory) + const [persistedState, setPersistedState, , persistedReady] = persisted( + Persist.global("new-session.provider-tip"), + createStore({ dismissedAt: 0 }), + ) + const visible = createMemo( + () => + serverSync().child(sdk().directory)[0].provider_ready && + persistedReady() && + providers.paid().length === 0 && + Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration, + ) + const [ref, setRef] = createSignal() + const presence = createPresence({ + show: visible, + element: () => ref() ?? null, + }) + const openProviders = () => { + void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => { + void dialog.show(() => sdk().directory} />) + }) + } + + return ( + +
+
+ + + + +
+
+
+ ) +} diff --git a/packages/app/src/pages/new-session/new-session-workspace-controller.test.ts b/packages/app/src/pages/new-session/new-session-workspace-controller.test.ts new file mode 100644 index 0000000000..2a79ae77fa --- /dev/null +++ b/packages/app/src/pages/new-session/new-session-workspace-controller.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import { + normalizeNewSessionWorktree, + resolveNewSessionBranch, + resolveNewSessionWorktree, +} from "./new-session-workspace-controller" + +describe("new session workspace selection", () => { + test("uses main when the workspace bar is unavailable", () => { + expect( + resolveNewSessionWorktree({ + enabled: false, + selected: "/project/feature", + directory: "/project/feature", + projectWorktree: "/project", + }), + ).toBe("main") + }) + + test("derives an existing worktree from the current directory", () => { + expect( + resolveNewSessionWorktree({ enabled: true, directory: "/project/feature", projectWorktree: "/project" }), + ).toBe("/project/feature") + expect(resolveNewSessionWorktree({ enabled: true, directory: "/project", projectWorktree: "/project" })).toBe( + "main", + ) + }) + + test("normalizes main to the project root outside the main worktree", () => { + expect(normalizeNewSessionWorktree("main", "/project/feature", "/project")).toBe("/project") + expect(normalizeNewSessionWorktree("main", "/project", "/project")).toBe("main") + }) + + test("falls back to the local branch for main, create, and unknown worktrees", () => { + const branch = (worktree: string) => (worktree === "/project/feature" ? "feature" : undefined) + expect(resolveNewSessionBranch({ worktree: "main", local: "dev", worktreeBranch: branch })).toBe("dev") + expect(resolveNewSessionBranch({ worktree: "create", local: "dev", worktreeBranch: branch })).toBe("dev") + expect(resolveNewSessionBranch({ worktree: "/project/feature", local: "dev", worktreeBranch: branch })).toBe( + "feature", + ) + expect(resolveNewSessionBranch({ worktree: "/missing", local: "dev", worktreeBranch: branch })).toBe("dev") + }) +}) diff --git a/packages/app/src/pages/new-session/new-session-workspace-controller.ts b/packages/app/src/pages/new-session/new-session-workspace-controller.ts new file mode 100644 index 0000000000..f3fc9b2708 --- /dev/null +++ b/packages/app/src/pages/new-session/new-session-workspace-controller.ts @@ -0,0 +1,77 @@ +import { createMemo, createSignal } from "solid-js" +import { useSDK } from "@/context/sdk" +import { useServerSync } from "@/context/server-sync" +import { useSync } from "@/context/sync" + +const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" + +export function resolveNewSessionWorktree(input: { + enabled: boolean + selected?: string + directory: string + projectWorktree?: string +}) { + if (!input.enabled) return "main" + if (input.selected) return input.selected + if (input.projectWorktree && input.directory !== input.projectWorktree) return input.directory + return "main" +} + +export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) { + if (value === "main" && projectWorktree !== directory) return projectWorktree + return value +} + +export function resolveNewSessionBranch(input: { + worktree: string + local?: string + worktreeBranch: (worktree: string) => string | undefined +}) { + if (input.worktree === "main" || input.worktree === "create") return input.local + return input.worktreeBranch(input.worktree) ?? input.local +} + +export function createNewSessionWorkspaceController() { + const sdk = useSDK() + const sync = useSync() + const serverSync = useServerSync() + const [worktree, setWorktree] = createSignal() + const visible = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git") + const value = createMemo(() => + resolveNewSessionWorktree({ + enabled: visible(), + selected: worktree(), + directory: sdk().directory, + projectWorktree: sync().project?.worktree, + }), + ) + const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory) + const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch) + const branch = createMemo(() => + resolveNewSessionBranch({ + worktree: value(), + local: localBranch(), + worktreeBranch: (worktree) => serverSync().child(worktree)[0].vcs?.branch, + }), + ) + + return { + selection: { + value, + reset: () => setWorktree(), + set: (worktree: string) => + setWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree)), + }, + project: { + root: projectRoot, + workspaces: () => sync().project?.sandboxes ?? [], + git: () => sync().project?.vcs === "git", + }, + bar: { + visible, + branch, + }, + } +} + +export type NewSessionWorkspaceController = ReturnType diff --git a/packages/app/src/pages/new-session/use-new-session-commands.tsx b/packages/app/src/pages/new-session/use-new-session-commands.tsx new file mode 100644 index 0000000000..a0d835f97f --- /dev/null +++ b/packages/app/src/pages/new-session/use-new-session-commands.tsx @@ -0,0 +1,44 @@ +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useSettingsCommand } from "@/components/settings-dialog" +import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" + +export function useNewSessionCommands(input: { + restoreFocus: () => void + project: { + empty: () => boolean + open: () => void + } +}) { + const command = useCommand() + const dialog = useDialog() + const language = useLanguage() + + useSettingsCommand() + command.register("new-session", () => [ + { + id: "command.palette", + title: language.t("command.palette"), + hidden: true, + onSelect: async () => { + const { DialogSelectFile } = await import("@/components/dialog-select-file") + void dialog.show(() => ) + }, + }, + { + id: "input.focus", + title: language.t("command.input.focus"), + category: language.t("command.category.view"), + keybind: "ctrl+l", + onSelect: input.restoreFocus, + }, + { + id: "project.select", + title: language.t("session.new.project.search"), + category: language.t("command.category.project"), + keybind: "mod+shift+o", + disabled: input.project.empty(), + onSelect: input.project.open, + }, + ]) +} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 66afcfa9bf..c6e3a5fcbb 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,10 +1,12 @@ -import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FilePart, Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2" +import { getFilename } from "@opencode-ai/core/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query" import { batch, ErrorBoundary, onCleanup, + Suspense, Show, Match, Switch, @@ -40,13 +42,13 @@ import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/r import { NewSessionView, SessionHeader } from "@/components/session" import { ErrorPage } from "@/pages/error" import { CommentsProvider, useComments } from "@/context/comments" +import { useCommand } from "@/context/command" import { DirectoryDataProvider } from "@/pages/directory-layout" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { ModelsProvider } from "@/context/models" import { useNotification } from "@/context/notification" -import { PermissionProvider } from "@/context/permission" import { PromptProvider, usePrompt } from "@/context/prompt" import { usePlatform } from "@/context/platform" import { SDKProvider, useSDK } from "@/context/sdk" @@ -57,6 +59,10 @@ import { useSync } from "@/context/sync" import { useTabs } from "@/context/tabs" import { TerminalProvider, useTerminal } from "@/context/terminal" import { PromptInput } from "@/components/prompt-input" +import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2" +import { useSettingsCommand } from "@/components/settings-dialog" +import { setCursorPosition } from "@/components/prompt-input/editor-dom" +import { promptLength } from "@/components/prompt-input/history" import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" import { createPromptInputController, @@ -79,6 +85,7 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel" import { sessionPanelLayout } from "@/pages/session/session-panel-layout" import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2" import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2" +import { SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2" import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2" import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds" @@ -151,13 +158,25 @@ export function SessionPage() { // workspace-scoped state (terminal, directory providers) lives below. export function TargetSessionRouteContent() { const params = useParams<{ serverKey: string; id: string }>() + const serverSync = useServerSync() + const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.directory) return ( - - - + // Settings must keep the target-server SDK, sync, and models context and remain registered + // when session content falls back to the route error boundary. + params.id}> + + + + + ) } +function TargetSessionSettingsCommand() { + useSettingsCommand() + return null +} + export function SessionRouteErrorBoundary( props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>, ) { @@ -246,19 +265,17 @@ function ResolvedTargetSessionRoute() { }) return ( - params.id}> - {/* Non-keyed: closes only while the target's directory is unknown (uncached - lineage mid-resolution), which tears down the workspace subtree including - the terminal. Same-workspace tab switches keep it open because warm - targets resolve synchronously from the sync cache. */} - - - - - - - - + // Non-keyed: closes only while the target's directory is unknown (uncached + // lineage mid-resolution), which tears down the workspace subtree including + // the terminal. Same-workspace tab switches keep it open because warm + // targets resolve synchronously from the sync cache. + + + + + + + ) } @@ -279,10 +296,10 @@ function TargetServerScopedProviders( props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>, ) { return ( - + <> {props.children} - + ) } @@ -348,6 +365,7 @@ export default function Page() { const platform = usePlatform() const prompt = usePrompt() const comments = useComments() + const command = useCommand() const terminal = useTerminal() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const location = useLocation() @@ -515,7 +533,6 @@ export default function Page() { const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) const isChildSession = createMemo(() => !!info()?.parentID) - const diffs = createMemo(() => (params.id ? list(sync().data.session_diff[params.id]) : [])) const canReview = createMemo(() => !!sync().project) const reviewTab = createMemo(() => isDesktop()) const tabState = createSessionTabs({ @@ -651,7 +668,8 @@ export default function Page() { const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") const wantsReview = createMemo(() => isDesktop() - ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") + ? desktopFileTreeOpen() || + (desktopReviewOpen() && (activeTab() === "review" || (newSessionDesign() && !!activeFileTab()))) : store.mobileTab === "changes", ) const vcsMode = createMemo(() => { @@ -672,8 +690,8 @@ export default function Page() { queryFn: mode ? () => sdk() - .client.vcs.diff({ mode }) - .then((result) => list(result.data)) + .api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode }) + .then((result) => result.data) .catch((error) => { console.debug("[session-review] failed to load vcs diff", { mode, error }) return [] @@ -720,8 +738,12 @@ export default function Page() { retry: 2, queryFn: () => sdk() - .client.vcs.diff({ mode, directory: scope, context }) - .then((result) => result.data ?? []), + .api.vcs.diff({ + location: { directory: scope }, + mode: mode === "git" ? "working" : mode, + context, + }) + .then((result) => result.data), }) .then((diffs) => diffs.find((diff) => diff.file === file)) @@ -928,10 +950,11 @@ export default function Page() { ) const stopVcs = sdk().event.listen((evt) => { - if (evt.details.type !== "file.watcher.updated") return + const details = evt.details as { type: string; properties?: unknown } + if (details.type !== "file.watcher.updated" && details.type !== "filesystem.changed") return const props = - typeof evt.details.properties === "object" && evt.details.properties - ? (evt.details.properties as Record) + typeof details.properties === "object" && details.properties + ? (details.properties as Record) : undefined const file = typeof props?.file === "string" ? props.file : undefined if (!file || file.startsWith(".git/")) return @@ -1052,7 +1075,10 @@ export default function Page() { if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { if (composer.blocked() || isChildSession()) return - inputRef?.focus() + const input = inputRef + if (!input) return + input.focus() + setCursorPosition(input, prompt.cursor() ?? promptLength(prompt.current())) } } @@ -1118,6 +1144,14 @@ export default function Page() { review: reviewTab, fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id, }) + command.register("session-palette", () => [ + { + id: "command.palette", + title: language.t("command.palette"), + hidden: true, + onSelect: () => command.trigger("file.open", "palette"), + }, + ]) const openReviewFile = createOpenReviewFile({ showAllFiles, @@ -1435,44 +1469,6 @@ export default function Page() { requestAnimationFrame(() => attempt(0)) }) - createEffect(() => { - const id = params.id - if (!id) return - - if (!wantsReview()) return - if (sync().data.session_diff[id] !== undefined) return - if (sync().status === "loading") return - - void sync().session.diff(id) - }) - - createEffect( - on( - () => [sessionKey(), wantsReview()] as const, - ([key, wants]) => { - if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) - if (diffTimer !== undefined) window.clearTimeout(diffTimer) - diffFrame = undefined - diffTimer = undefined - if (!wants) return - - const id = params.id - if (!id) return - if (!untrack(() => sync().data.session_diff[id] !== undefined)) return - - diffFrame = requestAnimationFrame(() => { - diffFrame = undefined - diffTimer = window.setTimeout(() => { - diffTimer = undefined - if (sessionKey() !== key) return - void sync().session.diff(id, { force: true }) - }, 0) - }) - }, - { defer: true }, - ), - ) - let treeDir: string | undefined createEffect(() => { const dir = sdk().directory @@ -1728,7 +1724,7 @@ export default function Page() { setFollowup("failed", input.sessionID, undefined) const ok = await sendFollowupDraft({ - client: sdk().client, + api: sdk().api.session, sync: sync(), serverSync: serverSync(), draft: item, @@ -1824,13 +1820,13 @@ export default function Page() { const halt = (sessionID: string) => busy(sessionID) ? sdk() - .client.session.abort({ sessionID }) + .api.session.interrupt({ sessionID }) .catch(() => {}) : Promise.resolve() const revertMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; messageID: string }) => { - const client = sdk().client + const session = sdk().api.session const target = sync() const last = target.session.get(input.sessionID)?.revert const value = draft(input.messageID) @@ -1840,10 +1836,8 @@ export default function Page() { roll(input.sessionID, { messageID: input.messageID }, target) prompt.set(value) }, - request: () => halt(input.sessionID).then(() => client.session.revert(input)), - complete: (result) => { - if (result.data) merge(result.data, target) - }, + request: () => halt(input.sessionID).then(() => session.revert.stage(input)), + complete: () => undefined, rollback: () => roll(input.sessionID, last, target), fail, }) @@ -1855,7 +1849,7 @@ export default function Page() { const sessionID = params.id if (!sessionID) return - const client = sdk().client + const session = sdk().api.session const target = sync() const next = userMessages().find((item) => item.id > id) const last = target.session.get(sessionID)?.revert @@ -1872,11 +1866,9 @@ export default function Page() { }, request: () => !next - ? halt(sessionID).then(() => client.session.unrevert({ sessionID })) - : halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })), - complete: (result) => { - if (result.data) merge(result.data, target) - }, + ? halt(sessionID).then(() => session.revert.clear({ sessionID })) + : halt(sessionID).then(() => session.revert.stage({ sessionID, messageID: next.id }).then(() => undefined)), + complete: () => undefined, rollback: () => roll(sessionID, last, target), fail, }) @@ -1904,7 +1896,30 @@ export default function Page() { .map((item) => ({ id: item.id, text: line(item.id) })) }) - const actions = { revert } + // attachment bytes are embedded as a data URL, so downloading always works; + // revealing requires the on-disk path captured by the client that attached the file + const openAttachment = (file: FilePart) => { + const download = () => { + const anchor = document.createElement("a") + anchor.href = file.url + anchor.download = getFilename(file.filename) || "attachment" + anchor.click() + } + const path = file.filename ?? "" + const absolute = path.startsWith("/") || path.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(path) + if (platform.revealPath && absolute) { + void platform.revealPath(path).then( + (revealed) => { + if (!revealed) download() + }, + () => download(), + ) + return + } + download() + } + + const actions = { revert, openAttachment } createEffect(() => { const sessionID = params.id @@ -1995,83 +2010,6 @@ export default function Page() { useUsageExceededDialogs() - const composerRegion = () => { - const controller = createSessionComposerRegionController({ - state: composer, - sessionKey, - sessionID: () => params.id, - prompt, - ready: () => !store.deferRender && messagesReady(), - centered, - todo: { - collapsed: () => view().todoCollapsed.get(), - onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()), - }, - followup: () => - params.id && !isChildSession() - ? { - items: followupDock(), - sending: sendingFollowup(), - onSend: (id) => void sendFollowup(params.id!, id, { manual: true }), - onEdit: editFollowup, - } - : undefined, - revert: () => - rolled().length > 0 - ? { - items: rolled(), - restoring: restoring(), - disabled: reverting(), - onRestore: restore, - } - : undefined, - onResponseSubmit: resumeScroll, - openParent: () => { - const id = info()?.parentID - if (!id) return - navigate( - params.serverKey - ? sessionHref(requireServerKey(params.serverKey), id) - : legacySessionHref(sdk().directory, id), - ) - }, - setPromptRef: (el) => { - inputRef = el - }, - setDockRef: (el) => { - promptDock = el - }, - }) - return ( - { - inputRef = el - }} - newSessionWorktree={newSessionWorktree()} - onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} - onSubmit={() => { - comments.clear() - resumeScroll() - }} - edit={editingFollowup()} - onEditLoaded={clearFollowupEdit} - shouldQueue={queueEnabled} - onQueue={queueFollowup} - onAbort={() => { - const id = params.id - if (!id) return - setFollowup("paused", id, true) - }} - /> - } - /> - ) - } - const mobileTabs = (compact = false, bottom = false) => (
- {(_) => composerRegion()} + + {(_) => { + const controller = createSessionComposerRegionController({ + state: composer, + sessionKey, + sessionID: () => params.id, + prompt, + ready: () => !store.deferRender && messagesReady(), + centered, + todo: { + collapsed: () => view().todoCollapsed.get(), + onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()), + }, + followup: () => + params.id && !isChildSession() + ? { + items: followupDock(), + sending: sendingFollowup(), + onSend: (id) => void sendFollowup(params.id!, id, { manual: true }), + onEdit: editFollowup, + } + : undefined, + revert: () => + rolled().length > 0 + ? { + items: rolled(), + restoring: restoring(), + disabled: reverting(), + onRestore: restore, + } + : undefined, + onResponseSubmit: resumeScroll, + openParent: () => { + const id = info()?.parentID + if (!id) return + navigate( + params.serverKey + ? sessionHref(requireServerKey(params.serverKey), id) + : legacySessionHref(sdk().directory, id), + ) + }, + setPromptRef: (el) => { + inputRef = el + }, + setDockRef: (el) => { + promptDock = el + }, + }) + return ( + { + inputRef = el + }} + newSessionWorktree={newSessionWorktree()} + onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} + onSubmit={() => { + comments.clear() + resumeScroll() + }} + edit={editingFollowup()} + onEditLoaded={clearFollowupEdit} + shouldQueue={queueEnabled} + onQueue={queueFollowup} + onAbort={() => { + const id = params.id + if (!id) return + setFollowup("paused", id, true) + }} + /> + } + > + {(_) => { + const controller = usePromptInputV2Controller({ + get controls() { + return inputController() + }, + ref: (el) => { + inputRef = el + }, + get newSessionWorktree() { + return newSessionWorktree() + }, + onNewSessionWorktreeReset: () => setStore("newSessionWorktree", "main"), + onSubmit: () => { + comments.clear() + resumeScroll() + }, + get edit() { + return editingFollowup() + }, + onEditLoaded: clearFollowupEdit, + shouldQueue: queueEnabled, + onQueue: queueFollowup, + onAbort: () => { + const id = params.id + if (!id) return + setFollowup("paused", id, true) + }, + }) + return + }} + + } + /> + ) + }} + {mobileTabs(true, true)} ) @@ -2247,42 +2298,53 @@ export default function Page() {
- + + +
- hasReview() || reviewV2State.sidebarOpened()} - reviewCount={reviewCount} - reviewPanel={reviewPanelV2} - fileBrowserState={reviewV2State} - activeDiff={activeReviewFile()} - focusReviewDiff={focusReviewDiff} - reviewSnap={ui.reviewSnap} - size={size} - stacked={desktopV2PanelLayout().stacked} - /> + + hasReview() || reviewV2State.sidebarOpened()} + reviewCount={reviewCount} + reviewPanel={reviewPanelV2} + reviewSidebarToggle={(disabled) => ( + + )} + fileBrowserState={reviewV2State} + activeDiff={activeReviewFile()} + focusReviewDiff={focusReviewDiff} + reviewSnap={ui.reviewSnap} + size={size} + stacked={desktopV2PanelLayout().stacked} + /> +
diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts index 8d5d96719c..a9b0070bc0 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -2,7 +2,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { createQuery } from "@tanstack/solid-query" import { useNavigate, useSearchParams } from "@solidjs/router" import { type Accessor, createMemo } from "solid-js" -import type { PromptInputControls } from "@/components/prompt-input" +import type { PromptInputControls } from "@/components/prompt-input/contracts" import type { PromptProjectControls } from "@/components/prompt-project-selector" import { useDirectoryPicker } from "@/components/directory-picker" import { useGlobal } from "@/context/global" @@ -12,7 +12,6 @@ import type { QueryOptionsApi } from "@/context/server-sync" import { useServerSDK } from "@/context/server-sdk" import { serverName, ServerConnection, useServer } from "@/context/server" import { useSDK } from "@/context/sdk" -import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTabs } from "@/context/tabs" import { useProviders } from "@/hooks/use-providers" @@ -26,36 +25,39 @@ export function createPromptInputController(input: { }) { const layout = useLayout() const local = useLocal() - const providers = useProviders() - const settings = useSettings() - const sync = useSync() const sdk = useSDK() + const sync = useSync() + const providers = useProviders(() => sdk().directory) const view = layout.view(input.sessionKey) const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory))) const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null)) const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory))) - return createMemo(() => ({ - agents: { - available: sync().data.agent, - options: local.agent.list().map((agent) => agent.name), - current: local.agent.current()?.name ?? "", - loading: agentsQuery.isLoading, - visible: settings.visibility.customAgents(), - select: local.agent.set, - }, - model: { - selection: input.model ?? local.model, - paid: providers.paid().length > 0, - loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading, - }, - session: { - id: input.sessionID(), - tabs: layout.tabs(input.sessionKey), - reviewPanel: view.reviewPanel, - }, - newLayoutDesigns: settings.general.newLayoutDesigns(), - })) + return createMemo(() => { + return { + agents: { + available: sync().data.agent, + options: local.agent.list().map((agent) => agent.name), + current: local.agent.current()?.name ?? "", + loading: agentsQuery.isLoading, + visible: local.agent.visible(), + select: local.agent.set, + }, + model: { + selection: input.model ?? local.model, + paid: providers.paid().length > 0, + loading: + (local.agent.visible() && agentsQuery.isLoading) || + providersQuery.isLoading || + globalProvidersQuery.isLoading, + }, + session: { + id: input.sessionID(), + tabs: layout.tabs(input.sessionKey), + reviewPanel: view.reviewPanel, + }, + } + }) } export function createPromptProjectControls() { diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index 45f5e4cb26..f54e0c9e4f 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -82,7 +82,7 @@ export function createSessionComposerController(options?: { closeMs?: number | ( setStore("responding", perm.id) sdk() - .client.permission.respond({ sessionID: perm.sessionID, permissionID: perm.id, response }) + .api.permission.reply({ sessionID: perm.sessionID, requestID: perm.id, reply: response }) .catch((err: unknown) => { const description = err instanceof Error ? err.message : String(err) showToast({ title: language.t("common.requestFailed"), description }) diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index 445a9f47a0..941424e247 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -223,7 +223,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } const replyMutation = useMutation(() => ({ - mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }), + mutationFn: (answers: QuestionAnswer[]) => + sdk().api.question.reply({ sessionID: props.request.sessionID, requestID: props.request.id, answers }), onMutate: () => { props.onSubmit() }, @@ -235,7 +236,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit })) const rejectMutation = useMutation(() => ({ - mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }), + mutationFn: () => sdk().api.question.reject({ sessionID: props.request.sessionID, requestID: props.request.id }), onMutate: () => { props.onSubmit() }, diff --git a/packages/app/src/pages/session/composer/session-revert-dock.stories.tsx b/packages/app/src/pages/session/composer/session-revert-dock.stories.tsx index dd030870a1..ce3fd26b02 100644 --- a/packages/app/src/pages/session/composer/session-revert-dock.stories.tsx +++ b/packages/app/src/pages/session/composer/session-revert-dock.stories.tsx @@ -1,5 +1,6 @@ import { For } from "solid-js" import { createStore } from "solid-js/store" +import { DockShell } from "@opencode-ai/ui/dock-surface" import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock" import { SettingsProvider, useSettings } from "@/context/settings" @@ -78,12 +79,17 @@ function Stage(props: { count: number }) { {/* Reproduce the real composer stack: dock + card overlapping the dock's bottom by lift() = 18px */}
-
Ask anything... -
+
diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx index f68428fcda..d44b490262 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -8,8 +8,10 @@ import { TextReveal } from "@opencode-ai/ui/text-reveal" import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough" import { createResizeObserver } from "@solid-primitives/resize-observer" import { Index, createEffect, createMemo } from "solid-js" +import { Dynamic } from "solid-js/web" import { createStore } from "solid-js/store" import { useLanguage } from "@/context/language" +import { useSettings } from "@/context/settings" const doneToken = "\u0000done\u0000" const totalToken = "\u0000total\u0000" @@ -48,6 +50,7 @@ export function SessionTodoDock(props: { dockProgress: number }) { const language = useLanguage() + const settings = useSettings() const [store, setStore] = createStore({ height: 78, }) @@ -91,8 +94,13 @@ export function SessionTodoDock(props: { }) return ( -
- + ) } diff --git a/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx b/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx index 61cfaf931b..41d1f984bd 100644 --- a/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx +++ b/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx @@ -71,7 +71,6 @@ const controls = { tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} }, reviewPanel: { opened: () => false, open: () => {} }, }, - newLayoutDesigns: true, } const css = ` diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index dd51dd0585..b67b810a4b 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js" +import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" import { makeEventListener } from "@solid-primitives/event-listener" @@ -6,9 +6,12 @@ import type { FileSearchHandle } from "@opencode-ai/session-ui/file" import { useFileComponent } from "@opencode-ai/ui/context/file" import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge" import { createLineCommentController } from "@opencode-ai/session-ui/line-comment-annotations" +import { createLineCommentControllerV2 } from "@opencode-ai/session-ui/v2/line-comment-annotations-v2" import { sampledChecksum } from "@opencode-ai/core/util/encode" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { IconButton } from "@opencode-ai/ui/icon-button" +import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { Tabs } from "@opencode-ai/ui/tabs" import { ScrollView } from "@opencode-ai/ui/scroll-view" import { showToast } from "@/utils/toast" @@ -16,10 +19,17 @@ import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange import { useComments } from "@/context/comments" import { useLanguage } from "@/context/language" import { usePrompt } from "@/context/prompt" +import { useSettings } from "@/context/settings" import { getSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" +type SessionFileViewProps = { + tab: string +} + +const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions" + function FileCommentMenu(props: { moreLabel: string editLabel: string @@ -53,6 +63,30 @@ function FileCommentMenu(props: { ) } +function FileCommentMenuV2(props: { + moreLabel: string + editLabel: string + deleteLabel: string + onEdit: VoidFunction + onDelete: VoidFunction +}) { + return ( +
event.stopPropagation()} onClick={(event) => event.stopPropagation()}> + + + + + + + {props.editLabel} + {props.deleteLabel} + + + +
+ ) +} + type ScrollPos = { x: number; y: number } function createScrollSync(input: { tab: () => string; view: ReturnType["view"] }) { @@ -179,7 +213,17 @@ export function FileTabContent(props: { tab: string }) { ) } -export function SessionFileView(props: { tab: string }) { +export function SessionFileView(props: SessionFileViewProps) { + const settings = useSettings() + + return ( + }> + + + ) +} + +function SessionFileViewV1(props: { tab: string }) { const file = useFile() const comments = useComments() const language = useLanguage() @@ -463,3 +507,294 @@ export function SessionFileView(props: { tab: string }) { return content() } + +function SessionFileViewV2(props: { tab: string }) { + const file = useFile() + const comments = useComments() + const language = useLanguage() + const prompt = usePrompt() + const fileComponent = useFileComponent() + const { sessionKey, tabs, view } = useSessionLayout() + const activeFileTab = createSessionTabs({ + tabs, + pathFromTab: file.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), + }).activeFileTab + + let find: FileSearchHandle | null = null + + const search = { + register: (handle: FileSearchHandle | null) => { + find = handle + }, + } + + const path = createMemo(() => file.pathFromTab(props.tab)) + const state = createMemo(() => { + const p = path() + if (!p) return + return file.get(p) + }) + const contents = createMemo(() => state()?.content?.content ?? "") + const cacheKey = createMemo(() => sampledChecksum(contents())) + const selectedLines = createMemo(() => { + const p = path() + if (!p) return null + if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null + return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null + }) + const scrollSync = createScrollSync({ + tab: () => props.tab, + view, + }) + + const selectionPreview = (source: string, selection: FileSelection) => { + return previewSelectedLines(source, { + start: selection.startLine, + end: selection.endLine, + }) + } + + const buildPreview = (filePath: string, lines: SelectedLineRange) => { + const source = filePath === path() ? contents() : file.get(filePath)?.content?.content + if (!source) return undefined + return selectionPreview(source, selectionFromLines(lines)) + } + + const addCommentToContext = (input: { + file: string + selection: SelectedLineRange + comment: string + preview?: string + origin?: "review" | "file" + }) => { + const selection = selectionFromLines(input.selection) + const preview = input.preview ?? buildPreview(input.file, input.selection) + + const saved = comments.add({ + file: input.file, + selection: input.selection, + comment: input.comment, + }) + prompt.context.add({ + type: "file", + path: input.file, + selection, + comment: input.comment, + commentID: saved.id, + commentOrigin: input.origin, + preview, + }) + } + + const updateCommentInContext = (input: { + id: string + file: string + selection: SelectedLineRange + comment: string + }) => { + comments.update(input.file, input.id, input.comment) + const preview = input.file === path() ? buildPreview(input.file, input.selection) : undefined + prompt.context.updateComment(input.file, input.id, { + comment: input.comment, + ...(preview ? { preview } : {}), + }) + } + + const removeCommentFromContext = (input: { id: string; file: string }) => { + comments.remove(input.file, input.id) + prompt.context.removeComment(input.file, input.id) + } + + const fileComments = createMemo(() => { + const p = path() + if (!p) return [] + return comments.list(p) + }) + + const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection)) + + const [note, setNote] = createStore({ + openedComment: null as string | null, + commenting: null as SelectedLineRange | null, + selected: null as SelectedLineRange | null, + }) + + const syncSelected = (range: SelectedLineRange | null) => { + const p = path() + if (!p) return + file.setSelectedLines(p, range ? cloneSelectedLineRange(range) : null) + } + + const activeSelection = () => note.selected ?? selectedLines() + + const commentsUi = createLineCommentControllerV2({ + comments: fileComments, + label: language.t("ui.lineComment.submit"), + draftKey: () => path() ?? props.tab, + mention: { + items: file.searchFilesAndDirectories, + }, + getSide: selectionSide, + state: { + opened: () => note.openedComment, + setOpened: (id) => setNote("openedComment", id), + selected: () => note.selected, + setSelected: (range) => setNote("selected", range), + commenting: () => note.commenting, + setCommenting: (range) => setNote("commenting", range), + syncSelected, + hoverSelected: syncSelected, + }, + onSubmit: ({ comment, selection }) => { + const p = path() + if (!p) return + addCommentToContext({ file: p, selection, comment, origin: "file" }) + }, + onUpdate: ({ id, comment, selection }) => { + const p = path() + if (!p) return + updateCommentInContext({ id, file: p, selection, comment }) + }, + onDelete: (comment) => { + const p = path() + if (!p) return + removeCommentFromContext({ id: comment.id, file: p }) + }, + editSubmitLabel: language.t("common.save"), + renderCommentActions: (_, controls) => ( + + ), + }) + + createEffect(() => { + if (typeof window === "undefined") return + + const onKeyDown = (event: KeyboardEvent) => { + if (activeFileTab() !== props.tab) return + if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return + if (event.key.toLowerCase() !== "f") return + + event.preventDefault() + event.stopPropagation() + find?.focus() + } + + makeEventListener(window, "keydown", onKeyDown, { capture: true }) + }) + + createEffect( + on( + path, + () => { + commentsUi.note.reset() + }, + { defer: true }, + ), + ) + + createEffect(() => { + const focus = comments.focus() + const p = path() + if (!focus || !p) return + if (focus.file !== p) return + if (activeFileTab() !== props.tab) return + + const target = fileComments().find((comment) => comment.id === focus.id) + if (!target) return + + commentsUi.note.openComment(target.id, target.selection, { cancelDraft: true }) + requestAnimationFrame(() => comments.clearFocus()) + }) + + let prev = { + loaded: false, + ready: false, + active: false, + } + + createEffect(() => { + const loaded = !!state()?.loaded + const ready = file.ready() + const active = activeFileTab() === props.tab + const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active) + prev = { loaded, ready, active } + if (!restore) return + scrollSync.queueRestore() + }) + + const renderFile = (source: string) => ( +
+ { + scrollSync.queueRestore() + }} + annotations={commentsUi.annotations()} + renderAnnotation={commentsUi.renderAnnotation} + renderGutterUtility={commentsUi.renderGutterUtility} + onLineSelected={(range: SelectedLineRange | null) => { + commentsUi.onLineSelected(range) + }} + onLineSelectionEnd={(range: SelectedLineRange | null) => { + if (!range) { + commentsUi.note.select(null) + commentsUi.note.cancelDraft() + return + } + commentsUi.onLineSelectionEnd(range) + }} + onLineNumberSelectionEnd={(range: SelectedLineRange | null) => { + commentsUi.onLineNumberSelectionEnd(range) + }} + search={search} + class="select-text" + media={{ + mode: "auto", + path: path(), + current: state()?.content, + onLoad: scrollSync.queueRestore, + onError: (args: { kind: "image" | "audio" | "svg" }) => { + if (args.kind !== "svg") return + showToast({ + variant: "error", + title: language.t("toast.file.loadFailed.title"), + }) + }, + }} + /> +
+ ) + + const content = () => ( +
+ + + {renderFile(contents())} + +
{language.t("common.loading")}...
+
+ {(err) =>
{err()}
}
+
+
+
+ ) + + return content() +} diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index 3854bf0276..1b65af7121 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -1,6 +1,7 @@ import { createEffect, onCleanup, type JSX } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { SessionReview } from "@opencode-ai/session-ui/session-review" import type { SessionReviewCommentActions, @@ -14,7 +15,7 @@ import type { LineComment } from "@/context/comments" export type DiffStyle = "unified" | "split" -type ReviewDiff = SnapshotFileDiff | VcsFileDiff +type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff export interface SessionReviewTabProps { title?: JSX.Element diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index e009177682..6f074fa203 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -1,28 +1,47 @@ import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { createMediaQuery } from "@solid-primitives/media" +import { DragDropProvider as DndKitProvider, PointerSensor } from "@dnd-kit/solid" +import { isSortable } from "@dnd-kit/solid/sortable" +import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom" +import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers" +import { RestrictToElement } from "@dnd-kit/dom/modifiers" +import { + DragDropProvider, + DragDropSensors, + DragOverlay, + SortableProvider, + closestCenter, + type DragEvent, +} from "@thisbeyond/solid-dnd" import { Tabs } from "@opencode-ai/ui/tabs" import { IconButton } from "@opencode-ai/ui/icon-button" import { Icon } from "@opencode-ai/ui/icon" import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Mark } from "@opencode-ai/ui/logo" -import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" -import type { DragEvent } from "@thisbeyond/solid-dnd" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { useDialog } from "@opencode-ai/ui/context/dialog" import FileTree from "@/components/file-tree" +import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" import { SessionContextUsage } from "@/components/session-context-usage" const reviewTabID = "session-side-panel-review-tab" const reviewTabPanelID = "session-side-panel-review-tabpanel" -import { SessionContextTab, SortableTab, FileVisual } from "@/components/session" +const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel" +import { SessionContextTab, SortableTab, SortableTabV2, FileVisual } from "@/components/session" +import { OpenInAppV2 } from "@/components/session/open-in-app-v2" import { useCommand } from "@/context/command" import { useFile, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" +import { useSDK } from "@/context/sdk" import { useSettings } from "@/context/settings" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" @@ -38,21 +57,24 @@ import { setSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab" -type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff +type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff +type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff +const FILE_TREE_WIDTH_MIN = 240 -function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { +function renderDiff(value: ReviewDiff): value is RenderDiff { return typeof value.file === "string" } export function SessionSidePanel(props: { canReview: () => boolean - diffs: () => (SnapshotFileDiff | VcsFileDiff)[] + diffs: () => ReviewDiff[] diffsReady: () => boolean empty: () => string hasReview: () => boolean reviewHasFocusableContent: () => boolean reviewCount: () => number reviewPanel: () => JSX.Element + reviewSidebarToggle?: (disabled: boolean) => JSX.Element fileBrowserState?: SessionFileBrowserState activeDiff?: string focusReviewDiff: (path: string) => void @@ -66,7 +88,9 @@ export function SessionSidePanel(props: { const language = useLanguage() const command = useCommand() const dialog = useDialog() + const sdk = useSDK() const { sessionKey, tabs, view, params } = useSessionLayout() + const projectDirectory = createMemo(() => sdk().directory) const isDesktop = createMediaQuery("(min-width: 768px)") const shown = settings.visibility.fileTree @@ -81,13 +105,14 @@ export function SessionSidePanel(props: { }), ) const open = createMemo(() => reviewOpen() || fileOpen()) + const fileTreeWidth = createMemo(() => Math.max(FILE_TREE_WIDTH_MIN, layout.fileTree.width())) const reviewTab = createMemo(() => isDesktop()) const panelWidth = createMemo(() => { if (!open()) return "0px" if (reviewOpen()) return "auto" - return `${layout.fileTree.width()}px` + return `${fileTreeWidth()}px` }) - const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) + const treeWidth = createMemo(() => (fileOpen() ? `${fileTreeWidth()}px` : "0px")) const diffs = createMemo(() => props.diffs().filter(renderDiff)) const diffFiles = createMemo(() => diffs().map((d) => d.file)) @@ -98,11 +123,9 @@ export function SessionSidePanel(props: { return "mix" as const } - const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "") - const out = new Map() for (const diff of diffs()) { - const file = normalize(diff.file) + const file = normalizeFileTreeV2Path(diff.file) const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" out.set(file, kind) @@ -159,6 +182,7 @@ export function SessionSidePanel(props: { fileBrowser: () => !!props.fileBrowserState, }) const contextOpen = tabState.contextOpen + const openFileOpen = tabState.openFileOpen const panelTabs = tabState.panelTabs const openedTabs = tabState.openedTabs const activeTab = tabState.activeTab @@ -176,10 +200,8 @@ export function SessionSidePanel(props: { layout.fileTree.setTab("all") } - const [store, setStore] = createStore({ - activeDraggable: undefined as string | undefined, - }) let fileFilter: HTMLInputElement | undefined + let tabList: HTMLDivElement | undefined const temporaryTab = tabs().preview const previewTab = (value: string) => { const next = normalizeTab(value) @@ -202,10 +224,27 @@ export function SessionSidePanel(props: { } const browserTab = createMemo(() => { if (!props.fileBrowserState) return undefined - if (activeTab() === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB + const active = activeTab() + if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB + if (active && file.pathFromTab(active)) return active return activeFileTab() }) - const browserKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix"))) + // Keep the file-browser shell mounted while any file tab exists. Kobalte briefly + // selects Review while the tab For replaces a preview trigger, which would + // otherwise dispose the sidebar and reset scroll. + const fileBrowserMounted = createMemo(() => { + if (!props.fileBrowserState) return false + return openedTabs().length > 0 || openFileOpen() || !!browserTab() + }) + const fileBrowserVisible = createMemo(() => { + const active = activeTab() + 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, + }) const handleDragStart = (event: unknown) => { const id = getDraggableId(event) @@ -291,72 +330,289 @@ export function SessionSidePanel(props: { "bg-background-base": !settings.general.newLayoutDesigns(), }} > - - - - -
- { - const stop = createFileTabListSync({ el, contextOpen }) - onCleanup(stop) - }} - > - - + + + +
+ { + const stop = createFileTabListSync({ el, contextOpen }) + onCleanup(stop) + }} > -
-
{language.t("session.tab.review")}
- -
{props.reviewCount()}
-
-
- - - - + +
+
{language.t("session.tab.review")}
+ +
{props.reviewCount()}
+
+
+
+
+ + + tabs().close("context")} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close("context")} + > +
+ +
{language.t("session.tab.context")}
+
+
+
+ + + {(tab) => ( + + } + > + + tabs().close(SESSION_OPEN_FILE_TAB)} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)} + > +
+ + {language.t("command.file.open")} +
+
+
+ )} +
+
+
tabs().close("context")} - aria-label={language.t("common.closeTab")} + iconSize="large" + class="!rounded-md" + onClick={() => { + void import("@/components/dialog-select-file").then((x) => { + dialog.show(() => ) + }) + }} + aria-label={language.t("command.file.open")} /> - } - hideCloseButton - onMiddleClick={() => tabs().close("context")} - > -
- -
{language.t("session.tab.context")}
- + +
+ + +
+ {props.reviewPanel()} +
- + + + +
+
+ +
+ {language.t("session.files.selectToOpen")} +
+
+
+
+
+ + + +
+ +
+
+
+ + + {(tab) => } + + + + + {(tab) => { + const path = file.pathFromTab(tab) + return ( +
+ + {(p) => } + +
+ ) + }} +
+
+ + } + > + + event.target instanceof Element && + (!!event.target.closest('[data-slot="tabs-trigger-close-button"]') || + !!event.target.closest(".session-review-v2-open-in-app-slot")), + }), + ]} + modifiers={[ + RestrictToHorizontalAxis, + RestrictToElement.configure({ element: () => tabList ?? null }), + ]} + plugins={(defaults) => [ + ...defaults.filter((plugin) => plugin !== Accessibility), + AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }), + Feedback.configure({ dropAnimation: null }), + ]} + onDragEnd={(event) => { + const source = event.operation.source + if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return + tabs().move(source.id.toString(), source.index) + }} + > + +
+ { + tabList = el + const stop = createFileTabListSync({ el, contextOpen }) + onCleanup(stop) + }} + > + + {(toggle) => ( +
+ {toggle()(activeTab() === SESSION_OPEN_FILE_TAB)} +
+ )} +
+ + + {props.hasReview() + ? language.t("session.review.filesChanged", { count: props.reviewCount() }) + : language.t("session.tab.review")} + + + + + {language.t("common.closeTab")} + 0}> + + + + } + placement="bottom" + gutter={10} + > + tabs().close("context")} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close("context")} + > +
+ +
{language.t("session.tab.context")}
+
+
+
{(tab) => ( tabs().all().indexOf(tab)} temporary={temporaryTab() === tab} onTabClose={tabs().close} onTabDoubleClick={temporaryTab() === tab ? openTab : undefined} @@ -366,9 +622,15 @@ export function SessionSidePanel(props: { + {language.t("common.closeTab")} + 0}> + + + + } placement="bottom" gutter={10} > @@ -379,7 +641,7 @@ export function SessionSidePanel(props: { onClick={() => tabs().close(SESSION_OPEN_FILE_TAB)} aria-label={language.t("common.closeTab")} /> - + } hideCloseButton onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)} @@ -392,106 +654,104 @@ export function SessionSidePanel(props: { )} - -
- - { - if (props.fileBrowserState) { - openFileBrowser() - return - } - void import("@/components/dialog-select-file").then((x) => { - dialog.show(() => ) - }) - }} - aria-label={language.t("command.file.open")} - /> - + + {language.t("command.file.open")} + 0}> + + + + } + placement="bottom" + class="flex items-center" + > + } + variant="ghost-muted" + size="large" + onClick={() => openFileBrowser()} + aria-label={language.t("command.file.open")} + /> + +
+
+
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > +
- -
- - -
- {props.reviewPanel()}
-
- - -
-
- -
- {language.t("session.files.selectToOpen")} + +
+ {props.reviewPanel()} +
+
+ + + +
+
+ +
+ {language.t("session.files.selectToOpen")} +
-
- - + + - - -
- -
-
-
- - - previewTab(file.tab(path))} - onSelectPermanent={(path) => openTab(file.tab(path))} - filterRef={(element) => (fileFilter = element)} - /> - - - - {(tab) => } - - - - - {(tab) => { - const path = file.pathFromTab(tab) - return ( -
- - {(p) => } - + + +
+
- ) - }} -
- - + + + + +
+ previewTab(file.tab(path))} + onSelectPermanent={(path) => openTab(file.tab(path))} + filterRef={(element) => (fileFilter = element)} + /> +
+
+ + +
@@ -519,10 +779,19 @@ export function SessionSidePanel(props: { > - {props.reviewCount()}{" "} - {language.t( - props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other", - )} + + {props.reviewCount()}{" "} + {language.t( + props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other", + )} + + } + > + {language.t("session.review.filesChanged", { count: props.reviewCount() })} + {language.t("session.files.all")} @@ -578,8 +847,8 @@ export function SessionSidePanel(props: { { props.size.touch() 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 a7390b6d61..eca1541cea 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" @@ -282,6 +283,14 @@ export function MessageTimeline(props: { return sync().data.session_status[id] ?? idle }) const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : [])) + const projectedMessages = createMemo(() => { + const id = sessionID() + if (!id) return [] + const visible = new Set(props.userMessages.map((message) => message.id)) + const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id + const messages = sync().data.session_message[id] ?? [] + return boundary ? messages.filter((message) => message.id < boundary) : messages + }) const info = createMemo(() => { const id = sessionID() if (!id) return @@ -324,9 +333,11 @@ export function MessageTimeline(props: { const projection = createTimelineProjection({ messages: sessionMessages, userMessages: () => props.userMessages, + sessionMessages: projectedMessages, parts: getMsgParts, status: sessionStatus, showReasoningSummaries: settings.general.showReasoningSummaries, + inlineComments: settings.general.newLayoutDesigns, }) const activeMessageID = projection.activeMessageID const assistantMessagesByParent = projection.assistantMessagesByParent @@ -407,6 +418,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, @@ -634,7 +646,7 @@ export function MessageTimeline(props: { const viewShare = () => { const url = shareUrl() if (!url) return - platform.openLink(url) + platform.openExternal(url) } const errorMessage = (err: unknown) => { @@ -662,7 +674,7 @@ export function MessageTimeline(props: { const titleMutation = useMutation(() => ({ mutationFn: (input: { id: string; title: string }) => - sdk().client.session.update({ sessionID: input.id, title: input.title }), + sdk().api.session.rename({ sessionID: input.id, title: input.title }), onSuccess: (_, input) => { sync().set( produce((draft) => { @@ -800,13 +812,14 @@ export function MessageTimeline(props: { const archiveSession = async (sessionID: string) => { const session = sync().session.get(sessionID) if (!session) return + if ((await sdk().protocol) !== "v1") return const sessions = sync().data.session ?? [] const index = sessions.findIndex((s) => s.id === sessionID) const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) await sdk() - .client.session.update({ sessionID, time: { archived: Date.now() } }) + .client.session.update({ sessionID, directory: sdk().directory, time: { archived: Date.now() } }) .then(() => { sync().set( produce((draft) => { @@ -835,8 +848,8 @@ export function MessageTimeline(props: { const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) const result = await sdk() - .client.session.delete({ sessionID }) - .then((x) => x.data) + .api.session.remove({ sessionID }) + .then(() => true) .catch((err) => { showToast({ title: language.t("session.delete.failed.title"), @@ -1135,6 +1148,10 @@ export function MessageTimeline(props: { const m = messageByID().get(userMessageRow().userMessageID) if (m?.role === "user") return m }) + const messageComments = createMemo(() => { + if (!settings.general.newLayoutDesigns()) return [] + return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []) + }) return ( @@ -1146,6 +1163,7 @@ export function MessageTimeline(props: { parts={getMsgParts(userMessageRow().userMessageID)} actions={props.actions} useV2Actions={settings.general.newLayoutDesigns()} + comments={messageComments()} />
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/projection.ts b/packages/app/src/pages/session/timeline/projection.ts index ec2a3190ba..e30c936d73 100644 --- a/packages/app/src/pages/session/timeline/projection.ts +++ b/packages/app/src/pages/session/timeline/projection.ts @@ -1,19 +1,19 @@ -import { Binary } from "@opencode-ai/core/util/binary" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2" -import { createMemo, mapArray, type Accessor } from "solid-js" +import { createMemo, type Accessor } from "solid-js" import { reuseTimelineRows } from "./row-reconciliation" import { Timeline, TimelineRow } from "./rows" export { reuseTimelineRows } from "./row-reconciliation" -const emptyAssistantMessages: AssistantMessage[] = [] - export function createTimelineProjection(input: { messages: Accessor userMessages: Accessor + sessionMessages: Accessor parts: (messageID: string) => Part[] status: Accessor showReasoningSummaries: Accessor + inlineComments: Accessor }) { const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const))) const assistantMessagesByParent = createMemo(() => { @@ -29,46 +29,20 @@ export function createTimelineProjection(input: { }) return result }) - const activeMessageID = createMemo(() => { - const parentID = input - .messages() - .findLast( - (message): message is AssistantMessage => - message.role === "assistant" && typeof message.time.completed !== "number", - )?.parentID - if (parentID) { - const messages = input.messages() - const result = Binary.search(messages, parentID, (message) => message.id) - const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID) - if (message?.role === "user") return message.id - } - - if (input.status().type === "idle") return - return input.messages().findLast((message) => message.role === "user")?.id - }) - const messageRowMemos = createMemo( - mapArray(input.userMessages, (userMessage, indexAccessor) => - createMemo((previous: TimelineRow.TimelineRow[] | undefined) => - reuseTimelineRows( - previous, - Timeline.constructMessageRows( - userMessage, - input.parts, - assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages, - indexAccessor(), - input.showReasoningSummaries(), - input.status().type, - activeMessageID() === userMessage.id, - ), - ), - ), + const projection = createMemo(() => + Timeline.constructSessionMessageRows( + input.sessionMessages(), + (messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined, + input.parts, + input.showReasoningSummaries(), + input.status().type, + input.inlineComments(), + input.userMessages(), ), ) + const activeMessageID = createMemo(() => projection().activeMessageID) const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => - reuseTimelineRows( - previous, - messageRowMemos().flatMap((memo) => memo()), - ), + reuseTimelineRows(previous, projection().rows), ) const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const))) const messageRowIndex = createMemo(() => { diff --git a/packages/app/src/pages/session/timeline/rows-current.test.ts b/packages/app/src/pages/session/timeline/rows-current.test.ts new file mode 100644 index 0000000000..a5952a5e66 --- /dev/null +++ b/packages/app/src/pages/session/timeline/rows-current.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, mock, test } from "bun:test" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" +import { normalizeSessionMessages } from "@/utils/session-message" + +mock.module("@opencode-ai/session-ui/message-part", () => ({ + renderable: () => true, + groupParts: (refs: Array<{ messageID: string; part: { id: string } }>) => + refs.map((ref) => ({ + type: "part" as const, + key: ref.part.id, + ref: { messageID: ref.messageID, partID: ref.part.id }, + })), +})) + +const { Timeline, TimelineRow } = await import("./rows") + +describe("current session timeline rows", () => { + test("derives turns and tagged rows from chronological current messages", () => { + const source = [ + { id: "msg_1", type: "user", text: "first", time: { created: 1 } }, + { + id: "msg_2", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "answer" }], + time: { created: 2, completed: 3 }, + }, + { id: "msg_3", type: "user", text: "second", time: { created: 4 } }, + { + id: "msg_4", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "reasoning", text: "working" }], + time: { created: 5 }, + }, + ] satisfies SessionMessageInfo[] + const normalized = normalizeSessionMessages("ses_1", source) + const messages = new Map(normalized.messages.map((message) => [message.id, message])) + + const result = Timeline.constructSessionMessageRows( + source, + (messageID) => messages.get(messageID), + (messageID) => normalized.parts.get(messageID) ?? [], + true, + "busy", + true, + normalized.messages.filter((message) => message.role === "user"), + ) + + expect(result.activeMessageID).toBe("msg_3") + expect(result.rows.map(TimelineRow.key)).toEqual([ + "user-message:msg_1", + "assistant-part:msg_1:msg_2:text:0", + "turn-gap:msg_3", + "user-message:msg_3", + "assistant-part:msg_3:msg_4:reasoning:0", + ]) + }) + + test("renders a current shell message as a standalone turn", () => { + const source = [ + { + id: "msg_shell", + type: "shell", + shellID: "shell_1", + command: "pwd", + status: "exited", + exit: 0, + output: { output: "/repo", cursor: 5, size: 5, truncated: false }, + time: { created: 1, completed: 2 }, + }, + ] satisfies SessionMessageInfo[] + const normalized = normalizeSessionMessages("ses_1", source) + const messages = new Map(normalized.messages.map((message) => [message.id, message])) + + const result = Timeline.constructSessionMessageRows( + source, + (messageID) => messages.get(messageID), + (messageID) => normalized.parts.get(messageID) ?? [], + true, + "idle", + true, + normalized.messages.filter((message) => message.role === "user"), + ) + + expect(result.activeMessageID).toBe("msg_shell") + expect(result.rows.map(TimelineRow.key)).toEqual([ + "user-message:msg_shell", + "assistant-part:msg_shell:msg_shell:tool", + ]) + }) + + test("keeps a projected parent missing from the source page before newer turns", () => { + const source = [ + { id: "msg_user_1", type: "user", text: "first question", time: { created: 1 } }, + { + id: "msg_assistant_1", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "first answer" }], + time: { created: 2, completed: 3 }, + }, + { id: "msg_user_2", type: "user", text: "second question", time: { created: 4 } }, + { + id: "msg_assistant_2", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "second answer" }], + time: { created: 5, completed: 6 }, + }, + ] satisfies SessionMessageInfo[] + const normalized = normalizeSessionMessages("ses_1", source) + const messages = new Map(normalized.messages.map((message) => [message.id, message])) + + const result = Timeline.constructSessionMessageRows( + source.slice(1), + (messageID) => messages.get(messageID), + (messageID) => normalized.parts.get(messageID) ?? [], + true, + "idle", + true, + normalized.messages.filter((message) => message.role === "user"), + ) + + expect(result.rows.map(TimelineRow.key)).toEqual([ + "user-message:msg_user_1", + "assistant-part:msg_user_1:msg_assistant_1:text:0", + "turn-gap:msg_user_2", + "user-message:msg_user_2", + "assistant-part:msg_user_2:msg_assistant_2:text:0", + ]) + }) + + test("renders an optimistic user turn and thinking before the protocol message arrives", () => { + const source = [ + { id: "msg_1", type: "user", text: "existing", time: { created: 1 } }, + ] satisfies SessionMessageInfo[] + const normalized = normalizeSessionMessages("ses_1", source) + const optimistic = { + id: "msg_2", + sessionID: "ses_1", + role: "user" as const, + time: { created: 2 }, + agent: "build", + model: { modelID: "model", providerID: "provider" }, + } + const result = Timeline.constructSessionMessageRows( + source, + (messageID) => + messageID === optimistic.id ? optimistic : normalized.messages.find((message) => message.id === messageID), + () => [], + true, + "busy", + true, + [...normalized.messages.filter((message) => message.role === "user"), optimistic], + ) + + expect(result.activeMessageID).toBe(optimistic.id) + expect(result.rows.map(TimelineRow.key)).toEqual([ + "user-message:msg_1", + "turn-gap:msg_2", + "user-message:msg_2", + "thinking:msg_2", + ]) + }) + + test("removes a failed assistant error when the turn continues streaming", () => { + const source = [ + { id: "msg_user", type: "user", text: "recover", time: { created: 1 } }, + { + id: "msg_failed", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [], + error: { type: "ProviderError", message: "temporary failure" }, + time: { created: 2, completed: 3 }, + }, + { + id: "msg_recovery", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "streaming again" }], + time: { created: 4 }, + }, + ] satisfies SessionMessageInfo[] + const normalized = normalizeSessionMessages("ses_1", source) + const messages = new Map(normalized.messages.map((message) => [message.id, message])) + + const result = Timeline.constructSessionMessageRows( + source, + (messageID) => messages.get(messageID), + (messageID) => normalized.parts.get(messageID) ?? [], + true, + "busy", + true, + normalized.messages.filter((message) => message.role === "user"), + ) + + expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart"]) + }) +}) diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index 6a9cb669d0..879646e86a 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -1,7 +1,9 @@ import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" -import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" +import { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2" import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part" import { TimelineRow, type SummaryDiff } from "./timeline-row" +import { uniqueSummaryDiffs } from "./summary-diffs" export { TimelineRow, type SummaryDiff } from "./timeline-row" @@ -30,6 +32,71 @@ export type TimelineRowMap = { } export namespace Timeline { + export function constructSessionMessageRows( + messages: SessionMessageInfo[], + getMessage: (messageID: string) => UserMessage | AssistantMessage | undefined, + getMessageParts: (messageID: string) => Part[], + showReasoning: boolean, + status: SessionStatus["type"], + inlineComments: boolean, + projectedUserMessages: UserMessage[], + ) { + const turns: { user: UserMessage; assistants: AssistantMessage[] }[] = [] + const turnByUserID = new Map() + messages.forEach((message) => { + const projected = getMessage(message.id) + if (message.type === "shell" && projected?.role === "user") { + const assistant = getMessage(`${message.id}:assistant`) + const turn = { user: projected, assistants: assistant?.role === "assistant" ? [assistant] : [] } + turns.push(turn) + turnByUserID.set(projected.id, turn) + return + } + if (projected?.role === "user") { + if (turnByUserID.has(projected.id)) return + const turn = { user: projected, assistants: [] } + turns.push(turn) + turnByUserID.set(projected.id, turn) + return + } + if (projected?.role !== "assistant") return + const existing = turnByUserID.get(projected.parentID) + if (existing) { + existing.assistants.push(projected) + return + } + const user = getMessage(projected.parentID) + if (user?.role !== "user") return + const turn = { user, assistants: [projected] } + turns.push(turn) + turnByUserID.set(user.id, turn) + }) + const latestUserMessageID = turns.at(-1)?.user.id + projectedUserMessages.forEach((user) => { + if (turnByUserID.has(user.id)) return + if (latestUserMessageID && user.id < latestUserMessageID) return + const turn = { user, assistants: [] } + turns.push(turn) + turnByUserID.set(user.id, turn) + }) + const activeMessageID = turns.at(-1)?.user.id + return { + activeMessageID, + rows: turns.flatMap((turn, index) => + constructMessageRows( + turn.user, + getMessageParts, + turn.assistants, + index, + showReasoning, + status, + turn.user.id === activeMessageID, + inlineComments, + ), + ), + } + } + export function constructMessageRows( userMessage: UserMessage, getMessageParts: (messageID: string) => Part[], @@ -38,6 +105,8 @@ export namespace Timeline { showReasoning: boolean, status: SessionStatus["type"], isActive: boolean, + // v2 renders comments inside the user message attachments row instead of a strip row + inlineComments: boolean, ) { const rows: TimelineRow.TimelineRow[] = [] @@ -47,7 +116,8 @@ export namespace Timeline { const compaction = userParts.some((p) => p.type === "compaction") const interruptedMessageIndex = assistantMessages.findIndex((m) => m.error?.name === "MessageAbortedError") const interrupted = interruptedMessageIndex !== -1 - const error = assistantMessages.find((m) => m.error && m.error.name !== "MessageAbortedError")?.error + const latestError = assistantMessages.at(-1)?.error + const error = latestError?.name === "MessageAbortedError" ? undefined : latestError const assistantPartRefs = assistantMessages.flatMap((message, messageIndex) => getMessageParts(message.id) @@ -74,7 +144,7 @@ export namespace Timeline { : groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group })) if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id })) - if (comments.length > 0) + if (comments.length > 0 && !inlineComments) rows.push( new TimelineRow.CommentStrip({ userMessageID: userMessage.id, @@ -84,7 +154,7 @@ export namespace Timeline { rows.push( new TimelineRow.UserMessage({ userMessageID: userMessage.id, - anchor: comments.length === 0, + anchor: inlineComments || comments.length === 0, }), ) @@ -135,14 +205,7 @@ export namespace Timeline { if (isActive && status === "retry") rows.push(new TimelineRow.Retry({ userMessageID: userMessage.id })) - const diffs = (userMessage.summary?.diffs ?? []) - .reduceRight((result, diff) => { - if (!isSummaryDiff(diff)) return result - if (result.some((item) => item.file === diff.file)) return result - result.push(diff) - return result - }, []) - .reverse() + const diffs = uniqueSummaryDiffs(userMessage.summary?.diffs) if (diffs.length > 0 && (status === "idle" || !isActive)) { rows.push( new TimelineRow.DiffSummary({ @@ -167,10 +230,6 @@ export namespace Timeline { return rows } - function isSummaryDiff(value: SnapshotFileDiff): value is SummaryDiff { - return typeof value.file === "string" - } - function reasoningHeading(text: string) { const markdown = text.replace(/\r\n?/g, "\n") const html = markdown.match(/]*>([\s\S]*?)<\/h[1-6]>/i) diff --git a/packages/app/src/pages/session/timeline/summary-diffs.test.ts b/packages/app/src/pages/session/timeline/summary-diffs.test.ts new file mode 100644 index 0000000000..9b66bf6771 --- /dev/null +++ b/packages/app/src/pages/session/timeline/summary-diffs.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test" +import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" +import { uniqueSummaryDiffs } from "./summary-diffs" + +const diff = (file: string, additions: number) => + ({ + file, + additions, + deletions: 0, + }) satisfies SnapshotFileDiff + +describe("uniqueSummaryDiffs", () => { + test("drops entries without files and preserves unique input", () => { + const alpha = diff("alpha.ts", 1) + const beta = diff("beta.ts", 1) + const invalid = { additions: 1, deletions: 0 } satisfies SnapshotFileDiff + + expect(uniqueSummaryDiffs(undefined)).toEqual([]) + expect(uniqueSummaryDiffs([])).toEqual([]) + expect(uniqueSummaryDiffs([invalid])).toEqual([]) + + const result = uniqueSummaryDiffs([alpha, invalid, beta]) + expect(result).toEqual([alpha, beta]) + expect(result[0]).toBe(alpha) + expect(result[1]).toBe(beta) + }) + + test("keeps the last diff per file in the legacy display order", () => { + const oldAlpha = diff("alpha.ts", 1) + const oldBeta = diff("beta.ts", 1) + const newAlpha = diff("alpha.ts", 2) + const charlie = diff("charlie.ts", 1) + const newBeta = diff("beta.ts", 2) + + const result = uniqueSummaryDiffs([oldAlpha, oldBeta, newAlpha, charlie, newBeta]) + + expect(result).toEqual([newAlpha, charlie, newBeta]) + expect(result[0]).toBe(newAlpha) + expect(result[1]).toBe(charlie) + expect(result[2]).toBe(newBeta) + }) +}) diff --git a/packages/app/src/pages/session/timeline/summary-diffs.ts b/packages/app/src/pages/session/timeline/summary-diffs.ts new file mode 100644 index 0000000000..2df37af5a6 --- /dev/null +++ b/packages/app/src/pages/session/timeline/summary-diffs.ts @@ -0,0 +1,20 @@ +import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" +import type { SummaryDiff } from "./timeline-row" + +export function uniqueSummaryDiffs(diffs: SnapshotFileDiff[] | undefined) { + const files = new Set() + return (diffs ?? []) + .reduceRight((result, diff) => { + if (!isSummaryDiff(diff)) return result + const file = diff.file + if (files.has(file)) return result + files.add(file) + result.push(diff) + return result + }, []) + .reverse() +} + +function isSummaryDiff(diff: SnapshotFileDiff): diff is SummaryDiff { + return typeof diff.file === "string" +} diff --git a/packages/app/src/pages/session/use-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..12dd96a5e6 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -5,7 +5,6 @@ import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-b import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" -import { useLocal } from "@/context/local" import { usePermission } from "@/context/permission" import { usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" @@ -19,6 +18,7 @@ import { extractPromptFromParts } from "@/utils/prompt" import { UserMessage } from "@opencode-ai/sdk/v2" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionOwnership } from "./session-ownership" +import { useLocal } from "@/context/local" export type SessionCommandContext = { navigateMessageByOffset: (offset: number) => void @@ -40,7 +40,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const dialog = useDialog() const file = useFile() const language = useLanguage() - const local = useLocal() const permission = usePermission() const prompt = usePrompt() const sdk = useSDK() @@ -48,6 +47,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sync = useSync() const terminal = useTerminal() const layout = useLayout() + const local = useLocal() const navigate = useNavigate() const { params, sessionKey, tabs, view } = useSessionLayout() const sessionOwnership = createSessionOwnership(sessionKey) @@ -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() } @@ -305,7 +306,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sessionID = params.id if (!sessionID) return const owner = sessionOwnership.capture() - const client = sdk().client + const session = sdk().api.session const directory = sdk().directory const promptSession = prompt.capture() const revert = info()?.revert?.messageID @@ -315,13 +316,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const parts = sync().data.part[message.id] if (sync().data.session_working(sessionID)) { - await client.session.abort({ sessionID }).catch(() => {}) + await session.interrupt({ sessionID }).catch(() => {}) } await runCommand({ owner, prompt: promptSession, - request: () => client.session.revert({ sessionID, messageID: message.id }), + request: () => session.revert.stage({ sessionID, messageID: message.id }), updatePrompt: (promptSession) => { if (parts) promptSession.set(extractPromptFromParts(parts, { directory })) }, @@ -333,7 +334,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sessionID = params.id if (!sessionID) return const owner = sessionOwnership.capture() - const client = sdk().client + const session = sdk().api.session const messages = userMessages() const promptSession = prompt.capture() @@ -345,7 +346,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { await runCommand({ owner, prompt: promptSession, - request: () => client.session.unrevert({ sessionID }), + request: () => session.revert.clear({ sessionID }), updatePrompt: (promptSession) => promptSession.reset(), updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)), }) @@ -355,7 +356,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { await runCommand({ owner, prompt: promptSession, - request: () => client.session.revert({ sessionID, messageID: next.id }), + request: () => session.revert.stage({ sessionID, messageID: next.id }), updatePrompt: () => undefined, updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)), }) @@ -374,10 +375,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => { return } - await sdk().client.session.summarize({ + await sdk().api.session.compact({ sessionID, - modelID: model.id, - providerID: model.provider.id, + model: { providerID: model.provider.id, modelID: model.id }, }) } @@ -467,7 +467,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { id: "file.open", title: language.t("command.file.open"), description: language.t("palette.search.placeholder"), - keybind: "mod+k,mod+p", + keybind: "mod+p", slash: "open", onSelect: openFile, }), @@ -498,7 +498,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-diff-kinds.ts b/packages/app/src/pages/session/v2/review-diff-kinds.ts index 49cec334bc..d3adb1f2ff 100644 --- a/packages/app/src/pages/session/v2/review-diff-kinds.ts +++ b/packages/app/src/pages/session/v2/review-diff-kinds.ts @@ -1,14 +1,15 @@ import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { Kind } from "@/components/file-tree-v2" import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" -export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff +export type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff export function normalizePath(p: string) { return normalizeFileTreeV2Path(p) } -export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { +export function filterRenderableDiff(value: FileDiffInfo | SnapshotFileDiff | VcsFileDiff): value is RenderDiff { return typeof value.file === "string" } diff --git a/packages/app/src/pages/session/v2/review-panel-v2-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 a202a1e39d..fcd6bbb79f 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -1,11 +1,11 @@ import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX, SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, SessionReviewV2, SessionReviewV2Sidebar, - SessionReviewV2SidebarToggle, } from "@opencode-ai/session-ui/v2/session-review-v2" import { SessionReviewFilePreviewV2 } from "@opencode-ai/session-ui/v2/session-review-file-preview-v2" import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-v2" @@ -31,7 +31,7 @@ import { import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2" -type ReviewDiff = SnapshotFileDiff | VcsFileDiff +type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff export type ReviewPanelV2Props = { title?: JSX.Element @@ -66,6 +66,8 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) { ) const searching = createMemo(() => props.state.filter().trim().length > 0) const kinds = createMemo(() => reviewDiffKinds(diffs())) + // Changes-only trees omit "M" — every row is already a change; A/D stay visible. + const treeKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix"))) const activeDiff = createMemo(() => { // A focused comment takes over the preview until the preview applies it and // clears the focus; the owner then persists the file as the active selection. @@ -113,9 +115,6 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) { stats={} empty={props.empty} sidebarOpen={props.state.sidebarOpened()} - sidebarToggle={ - - } sidebar={ // Always mounted: the sidebar header hosts the changes-mode dropdown, // which must stay reachable when the current mode has zero diffs. @@ -127,7 +126,7 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) { diffs={diffs} filteredFiles={filteredFiles} searching={searching} - kinds={kinds} + kinds={treeKinds} activeDiff={activeDiff} /> } @@ -202,6 +201,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 9cbdd40df1..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 @@ -1,14 +1,9 @@ import { createMemo, createSignal, createUniqueId, Show } from "solid-js" import { createQuery } from "@tanstack/solid-query" -import { Tabs } from "@opencode-ai/ui/tabs" import { Icon } from "@opencode-ai/ui/icon" -import { - SessionFilePanelV2, - SessionFilePanelV2Empty, - SessionFilePanelV2Title, -} from "@opencode-ai/session-ui/v2/session-file-panel-v2" -import { SessionReviewV2Sidebar, SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2" -import FileTree, { type Kind } from "@/components/file-tree" +import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode-ai/session-ui/v2/session-file-panel-v2" +import { SessionReviewV2Sidebar } from "@opencode-ai/session-ui/v2/session-review-v2" +import FileTreeV2, { type Kind } from "@/components/file-tree-v2" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" @@ -24,6 +19,7 @@ const emptyFiles: string[] = [] export type SessionFileBrowserState = { sidebarOpened: () => boolean sidebarWidth: () => number + sidebarTransition: () => boolean resizeSidebar: (width: number) => void toggleSidebar: () => void } @@ -93,102 +89,93 @@ export function SessionFileBrowserTab(props: { }) } + // Keep the sidebar outside Kobalte Tabs.Content: a morphing content value + // unmounts the whole panel on every file-tab switch and resets sidebar scroll. return ( - - - - - {title()} - - - } - sidebar={ - {title()}} - filter={filter()} - onFilterChange={setFilter} - onFilterKeyDown={onFilterKeyDown} - filterAutofocus={props.placeholder} - filterRef={props.filterRef} - filterControls={resultsID} - filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined} - filterExpanded={query().length > 0 && files().length > 0} - width={props.state.sidebarWidth()} - onWidthChange={props.state.resizeSidebar} + {title()}} + filter={filter()} + onFilterChange={setFilter} + onFilterKeyDown={onFilterKeyDown} + filterAutofocus={props.placeholder} + filterRef={props.filterRef} + filterControls={resultsID} + filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined} + filterExpanded={query().length > 0 && files().length > 0} + width={props.state.sidebarWidth()} + onWidthChange={props.state.resizeSidebar} + > + props.onSelect(node.path)} + onFileDoubleClick={(node) => props.onSelectPermanent(node.path)} + /> + } > props.onSelect(node.path)} - onFileDoubleClick={(node) => props.onSelectPermanent(node.path)} - /> +
+ {language.t("common.loading")} + {language.t("common.loading.ellipsis")} +
} > 0} fallback={
- {language.t("common.loading")} - {language.t("common.loading.ellipsis")} + {language.t("palette.empty")}
} > - 0} - fallback={ -
- {language.t("palette.empty")} -
- } - > - { - setExplicitHighlight(path) - props.onSelect(path) - }} - onFileDoubleClick={props.onSelectPermanent} - /> -
+ { + setExplicitHighlight(path) + props.onSelect(path) + }} + onFileDoubleClick={props.onSelectPermanent} + />
-
+
+ + } + > + +
+ +
{language.t("command.file.open")}
+
{language.t("session.files.selectToOpen")}
+
+ } > - -
- -
{language.t("command.file.open")}
-
{language.t("session.files.selectToOpen")}
-
- - } - > -
- - {(tab) => } - -
-
- - +
+ + {(tab) => } + +
+
+ ) } diff --git a/packages/app/src/utils/diffs.test.ts b/packages/app/src/utils/diffs.test.ts index 5fbca469b7..a3d25f4279 100644 --- a/packages/app/src/utils/diffs.test.ts +++ b/packages/app/src/utils/diffs.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { Message } from "@opencode-ai/sdk/v2/client" import { diffs, message } from "./diffs" @@ -9,7 +10,7 @@ const item = { additions: 1, deletions: 1, status: "modified", -} satisfies SnapshotFileDiff +} satisfies FileDiffInfo & SnapshotFileDiff describe("diffs", () => { test("keeps valid arrays", () => { diff --git a/packages/app/src/utils/diffs.ts b/packages/app/src/utils/diffs.ts index 0cb2504fbe..a8eec75a9a 100644 --- a/packages/app/src/utils/diffs.ts +++ b/packages/app/src/utils/diffs.ts @@ -1,7 +1,8 @@ import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { Message } from "@opencode-ai/sdk/v2/client" -type Diff = SnapshotFileDiff | VcsFileDiff +type Diff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff function diff(value: unknown): value is Diff { if (!value || typeof value !== "object" || Array.isArray(value)) return false diff --git a/packages/app/src/utils/draft-store.ts b/packages/app/src/utils/draft-store.ts new file mode 100644 index 0000000000..cd0895f52c --- /dev/null +++ b/packages/app/src/utils/draft-store.ts @@ -0,0 +1,171 @@ +import type { AsyncStorage } from "@solid-primitives/storage" + +export type BlobReference = { id: string; url: string } + +type Driver = { + get(key: string): Promise + set(key: string, value: string): Promise + remove(key: string): Promise + putBlob(blob: Blob): Promise + getBlob(id: string): Promise +} + +export type DraftStore = AsyncStorage & { putBlob(blob: Blob): Promise } +const urls = new Map() + +function blobUrl(id: string, blob: Blob) { + const existing = urls.get(id) + if (existing) return existing + const url = URL.createObjectURL(blob) + urls.set(id, url) + return url +} + +async function blobID(blob: Blob) { + const id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer()))) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + return id +} + +export async function createBlobReference(blob: Blob): Promise { + const id = await blobID(blob) + return { id, url: blobUrl(id, blob) } +} + +export function createDraftStore(driver: Driver): DraftStore { + const versions = new Map() + const putBlob = async (blob: Blob) => { + const id = await driver.putBlob(blob) + return { id, url: blobUrl(id, blob) } + } + const encode = async (value: unknown): Promise => { + if (Array.isArray(value)) return Promise.all(value.map(encode)) + if (!value || typeof value !== "object") return value + const item = value as Record + if (item.type === "image" && typeof item.dataUrl === "string") { + const blob = await fetch(item.dataUrl).then((response) => response.blob()) + const { dataUrl: _, ...rest } = item + return { ...rest, blob: { id: await driver.putBlob(blob) } } + } + if ("blob" in item && item.blob && typeof item.blob === "object") { + const blob = item.blob as Record + if (typeof blob.id === "string" && blob.id.startsWith("data:")) { + const data = await fetch(blob.id).then((response) => response.blob()) + return { ...item, blob: { id: await driver.putBlob(data) } } + } + return { ...item, blob: { id: blob.id } } + } + return Object.fromEntries( + await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await encode(entry)])), + ) + } + const decode = async (value: unknown): Promise => { + if (Array.isArray(value)) return Promise.all(value.map(decode)) + if (!value || typeof value !== "object") return value + const item = value as Record + if (item.blob && typeof item.blob === "object") { + const ref = item.blob as Record + if (typeof ref.id === "string") { + const blob = await driver.getBlob(ref.id) + if (blob) return { ...item, blob: { id: ref.id, url: blobUrl(ref.id, blob) } } + } + } + return Object.fromEntries( + await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await decode(entry)])), + ) + } + return { + getItem: async (key) => { + const value = await driver.get(key) + return value === null ? null : JSON.stringify(await decode(JSON.parse(value))) + }, + setItem: async (key, value) => { + const version = (versions.get(key) ?? 0) + 1 + versions.set(key, version) + const encoded = JSON.stringify(await encode(JSON.parse(value))) + if (versions.get(key) === version) await driver.set(key, encoded) + }, + removeItem: async (key) => { + versions.set(key, (versions.get(key) ?? 0) + 1) + await driver.remove(key) + }, + putBlob, + } +} + +export function createBrowserDraftStore(): DraftStore { + const request = indexedDB.open("opencode-drafts", 1) + request.addEventListener("upgradeneeded", () => { + request.result.createObjectStore("documents") + request.result.createObjectStore("blobs") + }) + const db = new Promise((resolve, reject) => { + request.addEventListener("success", () => { + const database = request.result + const transaction = database.transaction(["documents", "blobs"], "readwrite") + const documents = transaction.objectStore("documents").getAll() + documents.addEventListener("success", () => { + const used = new Set() + JSON.parse(`[${documents.result.join(",")}]`, (_key, item) => { + if (item?.blob && typeof item.blob.id === "string") used.add(item.blob.id) + return item + }) + const blobs = transaction.objectStore("blobs").openKeyCursor() + blobs.addEventListener("success", () => { + const cursor = blobs.result + if (!cursor) return + if (!used.has(String(cursor.key))) cursor.delete() + cursor.continue() + }) + }) + transaction.addEventListener("complete", () => resolve(database)) + transaction.addEventListener("abort", () => resolve(database)) + }) + request.addEventListener("error", () => reject(request.error)) + }) + const get = async (store: string, key: string) => { + const result = (await db).transaction(store).objectStore(store).get(key) + return new Promise((resolve, reject) => { + result.addEventListener("success", () => resolve(result.result)) + result.addEventListener("error", () => reject(result.error)) + }) + } + const write = async (store: string, key: string, value?: unknown) => { + const transaction = (await db).transaction(store, "readwrite") + if (value === undefined) transaction.objectStore(store).delete(key) + else transaction.objectStore(store).put(value, key) + return new Promise((resolve, reject) => { + transaction.addEventListener("complete", () => resolve()) + transaction.addEventListener("error", () => reject(transaction.error)) + }) + } + return createDraftStore({ + get: async (key) => ((await get("documents", key)) as string | undefined) ?? null, + set: (key, value) => write("documents", key, value), + remove: (key) => write("documents", key), + putBlob: async (blob) => { + const id = await blobID(blob) + await write("blobs", id, blob) + return id + }, + getBlob: async (id) => ((await get("blobs", id)) as Blob | undefined) ?? null, + }) +} + +export async function blobDataUrl(blob: BlobReference, mime: string) { + const data = await fetch(blob.url).then((response) => response.blob()) + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.addEventListener("error", () => reject(reader.error)) + reader.addEventListener("load", () => { + const value = typeof reader.result === "string" ? reader.result : "" + resolve(`data:${mime};base64,${value.slice(value.indexOf(",") + 1)}`) + }) + reader.readAsDataURL(data) + }) +} + +export function createLegacyBlobReference(dataUrl: string): BlobReference { + return { id: dataUrl, url: dataUrl } +} diff --git a/packages/app/src/utils/menu-dismiss-controller.ts b/packages/app/src/utils/menu-dismiss-controller.ts new file mode 100644 index 0000000000..0a3009eb71 --- /dev/null +++ b/packages/app/src/utils/menu-dismiss-controller.ts @@ -0,0 +1,30 @@ +/** Coordinates focus restoration and actions that must run after menu content unmounts. */ +export function createMenuDismissController(content: () => HTMLElement | undefined) { + let restoreTrigger = true + + return { + /** Allows the menu primitive to restore focus to its trigger when closing. */ + allowTriggerRestore() { + restoreTrigger = true + }, + /** Keeps focus at its current or next destination instead of returning it to the trigger. */ + preventTriggerRestore() { + restoreTrigger = false + }, + /** Applies the current restoration policy during the menu primitive's close-focus event. */ + onCloseAutoFocus(event: Event) { + if (!restoreTrigger) event.preventDefault() + }, + /** Runs an action after the menu unmounts and its focus-close work has settled. */ + afterClose(callback: () => void) { + const complete = () => { + if (content()?.isConnected) { + requestAnimationFrame(complete) + return + } + requestAnimationFrame(() => requestAnimationFrame(callback)) + } + requestAnimationFrame(complete) + }, + } +} diff --git a/packages/app/src/utils/notification-click.test.ts b/packages/app/src/utils/notification-click.test.ts deleted file mode 100644 index fa81b0e025..0000000000 --- a/packages/app/src/utils/notification-click.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { handleNotificationClick, setNavigate } from "./notification-click" - -describe("notification click", () => { - afterEach(() => { - setNavigate(undefined as any) - }) - - test("navigates via registered navigate function", () => { - const calls: string[] = [] - setNavigate((href) => calls.push(href)) - handleNotificationClick("/abc/session/123") - expect(calls).toEqual(["/abc/session/123"]) - }) - - test("does not navigate when href is missing", () => { - const calls: string[] = [] - setNavigate((href) => calls.push(href)) - handleNotificationClick(undefined) - expect(calls).toEqual([]) - }) - - test("falls back to location.assign without registered navigate", () => { - handleNotificationClick("/abc/session/123") - // falls back to window.location.assign — no error thrown - }) -}) diff --git a/packages/app/src/utils/notification-click.ts b/packages/app/src/utils/notification-click.ts deleted file mode 100644 index 316b278206..0000000000 --- a/packages/app/src/utils/notification-click.ts +++ /dev/null @@ -1,13 +0,0 @@ -let nav: ((href: string) => void) | undefined - -export const setNavigate = (fn: (href: string) => void) => { - nav = fn -} - -export const handleNotificationClick = (href?: string) => { - window.focus() - if (!href) return - if (nav) return nav(href) - console.warn("notification-click: navigate function not set, falling back to window.location.assign") - window.location.assign(href) -} diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index a2daae4866..a7742c399e 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -15,6 +15,7 @@ type PersistedWithReady = [ ] type PersistTarget = { + draft?: boolean storage?: string scope?: "window" legacyStorageNames?: string[] @@ -295,6 +296,14 @@ async function removeAsync(storage: AsyncStorage, key: string) { } catch {} } +function toAsyncStorage(storage: SyncStorage | AsyncStorage): AsyncStorage { + return { + getItem: async (key) => storage.getItem(key), + setItem: async (key, value) => storage.setItem(key, value), + removeItem: async (key) => storage.removeItem(key), + } +} + async function migrateLegacyAsync(input: { current: AsyncStorage legacyStore?: AsyncStorage @@ -513,6 +522,9 @@ export const Persist = { if (session) return Persist.serverSession(scope, dir, session, key, legacy) return Persist.serverWorkspace(scope, dir, key, legacy) }, + prompt(target: PersistTarget): PersistTarget { + return { ...target, draft: true } + }, } function resolveTarget(target: PersistTarget, platform: Platform): PersistTarget { @@ -526,9 +538,12 @@ function resolveTarget(target: PersistTarget, platform: Platform): PersistTarget } export function removePersisted( - target: { storage?: string; legacyStorageNames?: string[]; key: string }, + target: { draft?: boolean; storage?: string; legacyStorageNames?: string[]; key: string }, platform?: Platform, ) { + if (target.draft && platform?.draftStore) { + void platform.draftStore.removeItem(`${target.storage ?? "default"}:${target.key}`) + } const isDesktop = platform?.platform === "desktop" && !!platform.storage if (isDesktop) { @@ -561,8 +576,17 @@ export function persisted( const legacy = config.legacy ?? [] const isDesktop = platform.platform === "desktop" && !!platform.storage + const draft = config.draft ? platform.draftStore : undefined const currentStorage = (() => { + if (draft) { + const prefix = `${config.storage ?? "default"}:` + return { + getItem: (key: string) => draft.getItem(prefix + key), + setItem: (key: string, value: string) => draft.setItem(prefix + key, value), + removeItem: (key: string) => draft.removeItem(prefix + key), + } satisfies AsyncStorage + } if (isDesktop) return platform.storage?.(config.storage) if (!config.storage) return localStorageDirect() return localStorageWithPrefix(config.storage) @@ -577,7 +601,7 @@ export function persisted( const legacyStorageNames = config.legacyStorageNames ?? [] const storage = (() => { - if (!isDesktop) { + if (!isDesktop && !draft) { const current = currentStorage as SyncStorage const legacyStore = legacyStorage as SyncStorage const legacyStores = legacyStorageNames.map(localStorageWithPrefix) @@ -609,15 +633,26 @@ export function persisted( const current = currentStorage as AsyncStorage const legacyStore = legacyStorage as AsyncStorage | undefined - const legacyStores = legacyStorageNames - .map((name) => platform.storage?.(name) as AsyncStorage | undefined) + const oldCurrent = draft + ? isDesktop + ? platform.storage?.(config.storage) + : config.storage + ? localStorageWithPrefix(config.storage) + : localStorageDirect() + : undefined + const legacyStores = [ + oldCurrent, + ...legacyStorageNames.map((name) => (isDesktop ? platform.storage?.(name) : localStorageWithPrefix(name))), + ] .filter((x) => !!x) + .map(toAsyncStorage) + let draftLatest: string | undefined const api: AsyncStorage = { getItem: async (key) => { const value = await readCurrentAsync({ storage: current, key, defaults, migrate: config.migrate }) if (value !== undefined) return value - return migrateLegacyAsync({ + const migrated = await migrateLegacyAsync({ current, legacyStore, stores: legacyStores, @@ -626,8 +661,15 @@ export function persisted( defaults, migrate: config.migrate, }) + if (draftLatest === undefined) { + if (draft && migrated !== null) return (await current.getItem(key)) ?? migrated + return migrated + } + await current.setItem(key, draftLatest) + return draftLatest }, setItem: async (key, value) => { + if (draft) draftLatest = value await current.setItem(key, value) }, removeItem: async (key) => { diff --git a/packages/app/src/utils/prompt.test.ts b/packages/app/src/utils/prompt.test.ts index 1ecaf02c97..8b86d43b90 100644 --- a/packages/app/src/utils/prompt.test.ts +++ b/packages/app/src/utils/prompt.test.ts @@ -37,8 +37,18 @@ describe("extractPromptFromParts", () => { expect(result).toHaveLength(3) expect(result[0]).toMatchObject({ type: "text", content: "check these" }) expect(result.slice(1)).toMatchObject([ - { type: "image", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" }, - { type: "image", filename: "b.pdf", mime: "application/pdf", dataUrl: "data:application/pdf;base64,BBB" }, + { + type: "image", + filename: "a.png", + mime: "image/png", + blob: expect.objectContaining({ id: expect.any(String) }), + }, + { + type: "image", + filename: "b.pdf", + mime: "application/pdf", + blob: expect.objectContaining({ id: expect.any(String) }), + }, ]) }) }) diff --git a/packages/app/src/utils/prompt.ts b/packages/app/src/utils/prompt.ts index 35aec0071a..67d32086bb 100644 --- a/packages/app/src/utils/prompt.ts +++ b/packages/app/src/utils/prompt.ts @@ -1,5 +1,6 @@ import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@opencode-ai/sdk/v2" import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" +import { createLegacyBlobReference } from "@/utils/draft-store" type Inline = | { @@ -107,7 +108,7 @@ export function extractPromptFromParts(parts: Part[], opts?: { directory?: strin id: filePart.id, filename: filePart.filename ?? attachmentName, mime: filePart.mime, - dataUrl: filePart.url, + blob: createLegacyBlobReference(filePart.url), }) } } diff --git a/packages/app/src/utils/server-compat.test.ts b/packages/app/src/utils/server-compat.test.ts new file mode 100644 index 0000000000..52e5ec6e3b --- /dev/null +++ b/packages/app/src/utils/server-compat.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, test } from "bun:test" +import { createApiForServer, createSdkForServer } from "./server" +import { createCompatibleApi } from "./server-compat" + +function setup( + protocol: "v1" | "v2" | Promise<"v1" | "v2">, + responses?: { vcs?: { branch: string; default_branch: string } }, +) { + const requests: Request[] = [] + const fetcher = Object.assign( + async (input: string | URL | Request, init?: RequestInit) => { + const request = new Request(input, init) + requests.push(request) + if (request.method === "PATCH") { + return Response.json({ + id: "ses_1", + slug: "ses_1", + projectID: "project", + directory: "/repo", + title: "Session", + version: "1", + time: { created: 1, updated: 1 }, + }) + } + if (request.method === "POST" && request.url.endsWith("/prompt_async")) + return new Response(undefined, { status: 204 }) + if (request.method === "POST" && request.url.endsWith("/prompt")) { + return Response.json({ + admittedSeq: 1, + id: "msg_1", + sessionID: "ses_1", + timeCreated: 1, + type: "user", + data: { text: "hello" }, + delivery: "steer", + }) + } + if (request.method === "GET" && new URL(request.url).pathname === "/vcs") + return Response.json(responses?.vcs ?? {}) + if (request.method === "GET") return Response.json([]) + return new Response(undefined, { status: 204 }) + }, + { preconnect: globalThis.fetch.preconnect }, + ) + const server = { url: "http://localhost:4096" } + const api = createCompatibleApi({ + protocol: typeof protocol === "string" ? Promise.resolve(protocol) : protocol, + current: createApiForServer({ server, fetch: fetcher }), + legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }), + directory: "/repo", + }) + return { api, requests } +} + +describe("createCompatibleApi", () => { + /* + test("routes V1 archive through the legacy session update", async () => { + const { api, requests } = setup("v1") + await api.session.archive({ sessionID: "ses_1", directory: "/repo" }) + + const url = new URL(requests[0]!.url) + expect(url.pathname).toBe("/session/ses_1") + expect(requests[0]!.headers.get("x-opencode-directory")).toBe("%2Frepo") + expect(requests[0]!.method).toBe("PATCH") + expect(await requests[0]!.json()).toMatchObject({ time: { archived: expect.any(Number) } }) + }) + */ + + test("converts current prompts to the V1 prompt contract", async () => { + const { api, requests } = setup("v1") + await api.session.prompt({ + sessionID: "ses_1", + id: "msg_1", + text: "hello @src/index.ts", + agent: "build", + model: { providerID: "provider", modelID: "model" }, + files: [ + { uri: "file:///repo/src/index.ts", name: "index.ts", mention: { text: "@src/index.ts", start: 6, end: 19 } }, + { uri: "data:text/plain;base64,aGVsbG8=", name: "notes.txt" }, + ], + }) + + expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/prompt_async") + const body = await requests[0]!.json() + expect(body).toMatchObject({ + messageID: "msg_1", + agent: "build", + model: { providerID: "provider", modelID: "model" }, + parts: [ + { type: "text", text: "hello @src/index.ts" }, + { + type: "file", + mime: "text/plain", + url: "file:///repo/src/index.ts", + filename: "index.ts", + source: { + type: "file", + text: { value: "@src/index.ts", start: 6, end: 19 }, + path: "file:///repo/src/index.ts", + }, + }, + { + type: "file", + mime: "text/plain", + url: "data:text/plain;base64,aGVsbG8=", + filename: "notes.txt", + }, + ], + }) + expect(body.parts[2]).not.toHaveProperty("source") + }) + + test("preserves original parts for V1 optimistic reconciliation", async () => { + const { api, requests } = setup("v1") + await api.session.prompt({ + sessionID: "ses_1", + id: "msg_1", + text: "look", + files: [{ uri: "data:image/png;base64,AAAA", name: "image.png" }], + legacyParts: [ + { id: "prt_text", type: "text", text: "look" }, + { id: "prt_image", type: "file", mime: "image/png", url: "data:image/png;base64,AAAA", filename: "image.png" }, + ], + }) + + expect((await requests[0]!.json()).parts).toEqual([ + { id: "prt_text", type: "text", text: "look" }, + { id: "prt_image", type: "file", mime: "image/png", url: "data:image/png;base64,AAAA", filename: "image.png" }, + ]) + }) + + test("resolves protocol detection once across implementation methods", async () => { + let detections = 0 + const resolved = Promise.resolve<"v1" | "v2">("v2") + const protocol = new Proxy(resolved, { + get(target, property) { + if (property !== "then") return Reflect.get(target, property, target) + detections++ + return target.then.bind(target) + }, + }) + const { api } = setup(protocol) + + await api.session.list() + await api.session.list() + + expect(detections).toBe(1) + }) + + /* + test("keeps V2 session actions on the current API", async () => { + const { api, requests } = setup("v2") + await api.session.archive({ sessionID: "ses_1" }) + + expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive") + expect(requests[0]!.method).toBe("POST") + }) + */ + + test("uses the global V1 session search endpoint", async () => { + const { api, requests } = setup("v1") + await api.session.list({ parentID: null, search: "session", limit: 50 }) + + expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session") + }) + + /* + test("projects the V1 default branch", async () => { + const { api } = setup("v1", { vcs: { branch: "feature", default_branch: "dev" } }) + + expect(await api.vcs.get({ location: { directory: "/repo" } })).toMatchObject({ + data: { branch: "feature", defaultBranch: "dev" }, + }) + }) + */ + + test("translates current file searches to the V1 dirs parameter", async () => { + const { api, requests } = setup("v1") + await api.file.find({ location: { directory: "/repo" }, query: "src", type: "file", limit: 20 }) + + const url = new URL(requests[0]!.url) + expect(url.pathname).toBe("/find/file") + expect(url.searchParams.get("dirs")).toBe("false") + expect(url.searchParams.get("limit")).toBe("20") + }) + + test("routes V1 permission replies through the requested directory", async () => { + const { api, requests } = setup("v1") + await api.permission.reply({ + sessionID: "ses_1", + requestID: "permission_1", + reply: "once", + location: { directory: "/other" }, + }) + + expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/permissions/permission_1") + expect(new URL(requests[0]!.url).searchParams.get("directory")).toBe("/other") + }) + + test("disposes the V1 instance after connecting a provider", async () => { + const { api, requests } = setup("v1") + + await api.integration.connect.key({ + integrationID: "openrouter", + key: "secret", + location: { directory: "/repo" }, + }) + + expect(requests.map((request) => new URL(request.url).pathname)).toEqual([ + "/auth/openrouter", + "/instance/dispose", + "/instance/dispose", + ]) + expect(requests[1]!.headers.get("x-opencode-directory")).toBe("%2Frepo") + expect(requests[2]!.headers.get("x-opencode-directory")).toBeNull() + }) + + test("disposes the V1 instance after completing provider OAuth", async () => { + const { api, requests } = setup("v1") + + await api.integration.oauth.complete({ + integrationID: "openrouter", + attemptID: "openrouter:0", + code: "code", + location: { directory: "/repo" }, + }) + + expect(requests.map((request) => new URL(request.url).pathname)).toEqual([ + "/provider/openrouter/oauth/callback", + "/instance/dispose", + "/instance/dispose", + ]) + expect(requests[1]!.headers.get("x-opencode-directory")).toBe("%2Frepo") + expect(requests[2]!.headers.get("x-opencode-directory")).toBeNull() + }) +}) diff --git a/packages/app/src/utils/server-compat.ts b/packages/app/src/utils/server-compat.ts new file mode 100644 index 0000000000..1df1338b71 --- /dev/null +++ b/packages/app/src/utils/server-compat.ts @@ -0,0 +1,518 @@ +import type { ServerApi } from "./server" +import type { ServerProtocol } from "./server-protocol" +import type { AgentPartInput, FilePartInput, OpencodeClient, Session, TextPartInput } from "@opencode-ai/sdk/v2/client" +import type { + Project, + ProjectCurrent, + SessionApi, + SessionCommandInput, + SessionCommandOutput, + SessionCompactInput, + SessionCompactOutput, + SessionInfo, + SessionPromptInput, + SessionPromptOutput, + SessionShellInput, + SessionShellOutput, +} from "@opencode-ai/client/promise" + +type LegacyClient = OpencodeClient +type LegacyFor = (directory?: string) => LegacyClient +type CompatibleSessionApi = Omit< + SessionApi, + "prompt" | "command" | "shell" | "compact" | "rename" | "archive" | "remove" +> & { + prompt: (input: SessionPromptInput & LegacyPrompt) => Promise + command: (input: SessionCommandInput) => Promise + shell: (input: SessionShellInput & LegacyPrompt) => Promise + compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise + rename: (input: Parameters[0] & LegacyLocation) => ReturnType + // archive: (input: Parameters[0] & LegacyLocation) => ReturnType + remove: (input: Parameters[0] & LegacyLocation) => ReturnType +} +type CompatiblePermissionApi = Omit & { + reply: ( + input: Parameters[0] & { location?: { directory?: string } }, + ) => ReturnType +} +export type CompatibleApi = Omit & { + readonly session: CompatibleSessionApi + readonly permission: CompatiblePermissionApi +} +type LegacyPrompt = { + agent?: string + model?: { providerID: string; modelID: string } + variant?: string + legacyParts?: (TextPartInput | FilePartInput | AgentPartInput)[] +} +type LegacyLocation = { directory?: string } +type CompatibleInput = { + protocol: Promise + current: ServerApi + legacy: LegacyFor + directory?: string +} + +function mime(uri: string) { + const match = /^data:([^;,]+)/.exec(uri) + return match?.[1] ?? "application/octet-stream" +} + +function sessionInfo(session: Session): SessionInfo { + return { + id: session.id, + parentID: session.parentID, + projectID: session.projectID, + agent: session.agent, + model: session.model && { + id: session.model.id, + providerID: session.model.providerID, + variant: session.model.variant, + }, + cost: session.cost ?? 0, + tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: session.time, + title: session.title, + location: { directory: session.directory, workspaceID: session.workspaceID }, + subpath: session.path, + revert: session.revert && { + messageID: session.revert.messageID, + partID: session.revert.partID, + snapshot: session.revert.snapshot, + }, + } +} + +export function createCompatibleApi(input: CompatibleInput): CompatibleApi { + const v1 = createV1Api(input) + return lazyApi( + input.protocol.then((protocol) => (protocol === "v1" ? v1 : input.current)), + input.current, + ) +} + +function lazyApi(implementation: Promise, shape: T): T { + const cache = new Map() + return new Proxy(shape, { + get(target, property, receiver) { + const sample = Reflect.get(target, property, receiver) + if (typeof sample === "function") { + return (...args: unknown[]) => + implementation.then((value) => { + const method = Reflect.get(value, property) + if (typeof method !== "function") throw new Error(`API method unavailable: ${String(property)}`) + return Reflect.apply(method, value, args) + }) + } + if (sample === null || typeof sample !== "object") return sample + if (cache.has(property)) return cache.get(property) + const nested = lazyApi( + implementation.then((value) => { + const result = Reflect.get(value, property) + if (result === null || typeof result !== "object") { + throw new Error(`API namespace unavailable: ${String(property)}`) + } + return result + }), + sample, + ) + cache.set(property, nested) + return nested + }, + }) +} + +function createV1Api(input: CompatibleInput): CompatibleApi { + const directory = (location?: { directory?: string }) => location?.directory ?? input.directory + const legacy = (location?: { directory?: string }) => input.legacy(directory(location)) + const located = (data: T, value?: { directory?: string }) => ({ + location: { + directory: directory(value) ?? "", + project: { id: "", directory: directory(value) ?? "" }, + }, + data, + }) + + return { + ...input.current, + session: { + ...input.current.session, + async list( + value?: Parameters[0], + options?: Parameters[1], + ) { + if (!value?.directory && value?.search !== undefined) { + const result = await legacy().experimental.session.list( + { + roots: value.parentID === null ? true : undefined, + search: value.search, + limit: value.limit, + }, + options, + ) + return { data: (result.data ?? []).map(sessionInfo), cursor: {} } + } + const result = await legacy({ directory: value?.directory }).session.list({ + directory: value?.directory, + roots: value?.parentID === null ? true : undefined, + search: value?.search, + limit: value?.limit, + }) + return { data: (result.data ?? []).map(sessionInfo), cursor: {} } + }, + async create(value?: Parameters[0]) { + const result = await legacy(value?.location ?? undefined).session.create({ + directory: directory(value?.location ?? undefined), + }) + if (!result.data) throw new Error("Failed to create session") + return sessionInfo(result.data) + }, + async get(value: Parameters[0]) { + const result = await legacy().session.get(value) + if (!result.data) throw new Error(`Session not found: ${value.sessionID}`) + return sessionInfo(result.data) + }, + async active() { + const result = await legacy().session.status() + return Object.fromEntries( + Object.entries(result.data ?? {}).flatMap(([sessionID, status]) => + status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]], + ), + ) + }, + async rename(value: Parameters[0] & LegacyLocation) { + await legacy(value).session.update({ sessionID: value.sessionID, title: value.title }) + }, + // async archive(value: Parameters[0] & LegacyLocation) { + // await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } }) + // }, + async remove(value: Parameters[0] & LegacyLocation) { + await legacy(value).session.delete(value) + }, + async fork(value: Parameters[0]) { + const result = await legacy().session.fork(value) + if (!result.data) throw new Error("Failed to fork session") + return sessionInfo(result.data) + }, + async interrupt(value: Parameters[0]) { + await legacy().session.abort(value) + }, + async prompt(value: SessionPromptInput & LegacyPrompt) { + await legacy().session.promptAsync({ + sessionID: value.sessionID, + messageID: value.id ?? undefined, + agent: value.agent, + model: value.model, + variant: value.variant, + parts: value.legacyParts ?? [ + { type: "text", text: value.text }, + ...(value.files ?? []).map((file) => ({ + type: "file" as const, + mime: file.mention ? "text/plain" : mime(file.uri), + url: file.uri, + filename: file.name, + source: file.mention + ? { + type: "file" as const, + text: { value: file.mention.text, start: file.mention.start, end: file.mention.end }, + path: file.uri, + } + : undefined, + })), + ...(value.agents ?? []).map((agent) => ({ + type: "agent" as const, + name: agent.name, + source: agent.mention + ? { value: agent.mention.text, start: agent.mention.start, end: agent.mention.end } + : undefined, + })), + ], + }) + return { + admittedSeq: 0, + id: value.id ?? "", + sessionID: value.sessionID, + timeCreated: Date.now(), + type: "user", + data: { text: value.text }, + delivery: value.delivery ?? "steer", + } + }, + async command(value: SessionCommandInput) { + await legacy().session.command({ + sessionID: value.sessionID, + messageID: value.id ?? undefined, + command: value.command, + arguments: value.arguments ?? "", + agent: value.agent ?? undefined, + model: value.model ? `${value.model.providerID}/${value.model.id}` : undefined, + variant: value.model?.variant, + parts: value.files?.map((file) => ({ + type: "file" as const, + mime: mime(file.uri), + url: file.uri, + filename: file.name, + })), + }) + return { + admittedSeq: 0, + id: value.id ?? "", + sessionID: value.sessionID, + timeCreated: Date.now(), + type: "user", + data: { text: `/${value.command} ${value.arguments ?? ""}`.trim() }, + delivery: value.delivery ?? "steer", + } + }, + async shell(value: SessionShellInput & LegacyPrompt) { + await legacy().session.shell({ + sessionID: value.sessionID, + command: value.command, + agent: value.agent, + model: value.model, + }) + }, + compact: async (value: SessionCompactInput & { model?: LegacyPrompt["model"] }) => { + if (!value.model) throw new Error("A model is required to compact a V1 session") + await legacy().session.summarize({ + sessionID: value.sessionID, + providerID: value.model.providerID, + modelID: value.model.modelID, + }) + return { + admittedSeq: 0, + id: value.id ?? "", + sessionID: value.sessionID, + timeCreated: Date.now(), + type: "compaction", + } + }, + revert: { + stage: async (value: Parameters[0]) => { + await legacy().session.revert(value) + return { messageID: value.messageID } + }, + clear: async (value: Parameters[0]) => { + await legacy().session.unrevert(value) + }, + commit: input.current.session.revert.commit, + }, + }, + project: { + ...input.current.project, + async list() { + return ((await legacy().project.list()).data ?? []) as Project[] + }, + async current(value?: Parameters[0]) { + const result = await legacy(value?.location).project.current() + if (!result.data) throw new Error("Project not found") + return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent + }, + // async update(value: Parameters[0]) { + // const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID) + // const result = await legacy({ directory: project?.worktree }).project.update({ + // ...value, + // directory: project?.worktree, + // }) + // if (!result.data) throw new Error(`Project not found: ${value.projectID}`) + // return result.data as Project + // }, + async directories(value: Parameters[0]) { + const result = await legacy(value.location).worktree.list() + return (result.data ?? []).map((item) => ({ directory: item })) + }, + }, + // path: { + // ...input.current.path, + // async get(value?: Parameters[0]) { + // const result = await legacy(value?.location).path.get() + // if (!result.data) throw new Error("Path unavailable") + // return result.data + // }, + // }, + vcs: { + ...input.current.vcs, + // async get(value?: Parameters[0]) { + // const result = await legacy(value?.location).vcs.get() + // return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location) + // }, + async status(value?: Parameters[0]) { + const result = await legacy(value?.location).vcs.status() + return located(result.data ?? [], value?.location) + }, + async diff(value: Parameters[0]) { + const result = await legacy(value.location).vcs.diff({ + mode: value.mode === "working" ? "git" : value.mode, + context: value.context, + }) + return located( + (result.data ?? []).map((file) => ({ + file: file.file, + patch: file.patch ?? "", + additions: file.additions, + deletions: file.deletions, + status: file.status ?? "modified", + })), + value.location, + ) + }, + }, + file: { + ...input.current.file, + async list(value?: Parameters[0]) { + const result = await legacy(value?.location).file.list({ path: value?.path ?? "" }) + return located(result.data ?? [], value?.location) + }, + async find(value: Parameters[0]) { + const result = await legacy(value.location).find.files({ + query: value.query, + dirs: value.type === undefined ? undefined : value.type === "directory" ? "true" : "false", + limit: value.limit, + }) + return located( + (result.data ?? []).map((path) => ({ path, type: value.type ?? "file" })), + value.location, + ) + }, + }, + integration: { + ...input.current.integration, + async get(value: Parameters[0]) { + const methods = ((await legacy(value.location).provider.auth()).data?.[value.integrationID] ?? []).map( + (method, index) => + method.type === "api" + ? { type: "key" as const, label: method.label } + : { type: "oauth" as const, id: String(index), label: method.label, prompts: method.prompts }, + ) + return located( + { + id: value.integrationID, + name: value.integrationID, + methods, + connections: [], + }, + value.location, + ) + }, + connect: { + ...input.current.integration.connect, + key: async (value: Parameters[0]) => { + await legacy(value.location).auth.set({ + providerID: value.integrationID, + auth: { type: "api", key: value.key }, + }) + await legacy(value.location).instance.dispose() + await input.legacy().instance.dispose() + }, + }, + oauth: { + ...input.current.integration.oauth, + connect: async (value: Parameters[0]) => { + const method = Number(value.methodID) + const result = await legacy(value.location).provider.oauth.authorize( + { providerID: value.integrationID, method, inputs: value.inputs }, + { throwOnError: true }, + ) + if (!result.data) throw new Error("Failed to start OAuth authorization") + return located( + { + attemptID: `${value.integrationID}:${method}`, + url: result.data.url, + instructions: result.data.instructions, + mode: result.data.method, + time: { created: Date.now(), expires: Date.now() + 10 * 60 * 1000 }, + }, + value.location, + ) + }, + complete: async (value: Parameters[0]) => { + const method = Number(value.attemptID.split(":").at(-1)) + await legacy(value.location).provider.oauth.callback( + { providerID: value.integrationID, method, code: value.code }, + { throwOnError: true }, + ) + await legacy(value.location).instance.dispose() + await input.legacy().instance.dispose() + }, + status: async (value: Parameters[0]) => { + const method = Number(value.attemptID.split(":").at(-1)) + await legacy(value.location).provider.oauth.callback( + { providerID: value.integrationID, method }, + { throwOnError: true }, + ) + await legacy(value.location).instance.dispose() + await input.legacy().instance.dispose() + return located( + { status: "complete" as const, time: { created: Date.now(), expires: Date.now() } }, + value.location, + ) + }, + }, + }, + pty: { + ...input.current.pty, + // async shells(value?: Parameters[0]) { + // return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location) + // }, + async list(value?: Parameters[0]) { + return located((await legacy(value?.location).pty.list()).data ?? [], value?.location) + }, + async create(value?: Parameters[0]) { + const result = await legacy(value?.location).pty.create({ + command: value?.command, + args: value?.args ? [...value.args] : undefined, + cwd: value?.cwd, + title: value?.title, + env: value?.env, + }) + if (!result.data) throw new Error("Failed to create terminal") + return located(result.data, value?.location) + }, + async get(value: Parameters[0]) { + const result = await legacy(value.location).pty.get({ ptyID: value.ptyID }) + if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`) + return located(result.data, value.location) + }, + async update(value: Parameters[0]) { + const result = await legacy(value.location).pty.update({ + ptyID: value.ptyID, + title: value.title, + size: value.size, + }) + if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`) + return located(result.data, value.location) + }, + async remove(value: Parameters[0]) { + await legacy(value.location).pty.remove({ ptyID: value.ptyID }) + }, + // async connectToken(value: Parameters[0]) { + // const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID }) + // if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`) + // return located(result.data, value.location) + // }, + }, + permission: { + ...input.current.permission, + async reply(value: Parameters[0] & { location?: { directory?: string } }) { + await legacy(value.location).permission.respond({ + sessionID: value.sessionID, + permissionID: value.requestID, + response: value.reply, + directory: directory(value.location), + }) + }, + }, + question: { + ...input.current.question, + async reply(value: Parameters[0]) { + await legacy().question.reply({ + requestID: value.requestID, + answers: value.answers.map((answer) => [...answer]), + }) + }, + async reject(value: Parameters[0]) { + await legacy().question.reject({ requestID: value.requestID }) + }, + }, + } +} diff --git a/packages/app/src/utils/server-health.test.ts b/packages/app/src/utils/server-health.test.ts index b1c8f2c7e2..69a8c7b3be 100644 --- a/packages/app/src/utils/server-health.test.ts +++ b/packages/app/src/utils/server-health.test.ts @@ -14,15 +14,45 @@ function abortFromInput(input: RequestInfo | URL, init?: RequestInit) { describe("checkServerHealth", () => { test("returns healthy response with version", async () => { - const fetch = (async () => - new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + let request: URL | undefined + const fetch = (async (input: RequestInfo | URL) => { + request = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input) + return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { status: 200, headers: { "content-type": "application/json" }, - })) as unknown as typeof globalThis.fetch + }) + }) as unknown as typeof globalThis.fetch const result = await checkServerHealth(server, fetch) expect(result).toEqual({ healthy: true, version: "1.2.3" }) + expect(request?.pathname).toBe("/api/health") + }) + + test("falls back to the V1 health endpoint", async () => { + const paths: string[] = [] + const fetch = (async (input: RequestInfo | URL) => { + const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input) + paths.push(url.pathname) + if (url.pathname === "/api/health") return new Response(undefined, { status: 404 }) + return Response.json({ healthy: true, version: "1.18.4" }) + }) as unknown as typeof globalThis.fetch + + expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" }) + expect(paths).toEqual(["/api/health", "/global/health"]) + }) + + test("falls back when the current health response is malformed", async () => { + const paths: string[] = [] + const fetch = (async (input: RequestInfo | URL) => { + const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input) + paths.push(url.pathname) + if (url.pathname === "/api/health") return Response.json({}) + return Response.json({ healthy: true, version: "1.18.4" }) + }) as unknown as typeof globalThis.fetch + + expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" }) + expect(paths).toEqual(["/api/health", "/global/health"]) }) test("allows slow servers thirty seconds by default", async () => { @@ -142,7 +172,7 @@ describe("checkServerHealth", () => { retryDelayMs: 1, }) - expect(count).toBe(3) + expect(count).toBe(6) expect(result).toEqual({ healthy: false }) }) }) diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts index 1b684d9af7..1d7d9e4b2e 100644 --- a/packages/app/src/utils/server-health.ts +++ b/packages/app/src/utils/server-health.ts @@ -1,6 +1,7 @@ import { usePlatform } from "@/context/platform" import { ServerConnection } from "@/context/server" -import { createSdkForServer } from "./server" +import { authTokenFromCredentials, createSdkForServer } from "./server" +import { ClientError, OpenCode } from "@opencode-ai/client" import { Accessor, createEffect, onCleanup } from "solid-js" import { createStore, reconcile } from "solid-js/store" @@ -61,6 +62,7 @@ function wait(ms: number, signal?: AbortSignal) { function retryable(error: unknown, signal?: AbortSignal) { if (signal?.aborted) return false + if (error instanceof ClientError) return error.reason === "Transport" if (!(error instanceof Error)) return false if (error.name === "AbortError" || error.name === "TimeoutError") return false if (error instanceof TypeError) return true @@ -82,15 +84,31 @@ export async function checkServerHealth( .then(() => attempt(count + 1)) .catch(() => ({ healthy: false })) } - const attempt = (count: number): Promise => - createSdkForServer({ - server, + const attempt = async (count: number): Promise => { + const current = await OpenCode.make({ + baseUrl: server.url, fetch, - signal, + headers: server.password + ? { + Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`, + } + : undefined, }) + .health.get({ signal }) + .then((x) => + typeof x.healthy === "boolean" + ? { data: { healthy: x.healthy, version: x.version } } + : { error: new Error("Invalid health response") }, + ) + .catch((error) => ({ error })) + if ("data" in current && current.data) return current.data + if (signal?.aborted) return { healthy: false } + + return createSdkForServer({ server, fetch, signal }) .global.health() .then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version })) .catch((error) => next(count, error)) + } return attempt(0).finally(() => timeout?.clear?.()) } diff --git a/packages/app/src/utils/server-protocol.test.ts b/packages/app/src/utils/server-protocol.test.ts new file mode 100644 index 0000000000..2130a968c4 --- /dev/null +++ b/packages/app/src/utils/server-protocol.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" +import { detectServerProtocol } from "./server-protocol" + +const server = { url: "http://localhost:4096" } +const json = (value: unknown, status = 200) => + new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }) +const mockFetch = (run: (input: string | URL | Request) => Promise) => + Object.assign(run, { preconnect: globalThis.fetch.preconnect }) + +describe("detectServerProtocol", () => { + test("prefers the legacy health endpoint when both API generations exist", async () => { + const fetcher = mockFetch((input) => { + const path = new URL(input instanceof Request ? input.url : input).pathname + if (path === "/global/health") return Promise.resolve(json({ healthy: true, version: "1.18.4" })) + return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 })) + }) + + expect(await detectServerProtocol(server, fetcher)).toBe("v1") + }) + + test("recognizes V2 health by its process identifier", async () => { + const fetcher = mockFetch((input) => { + const path = new URL(input instanceof Request ? input.url : input).pathname + if (path === "/global/health") return Promise.resolve(json({}, 404)) + return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 })) + }) + + expect(await detectServerProtocol(server, fetcher)).toBe("v2") + }) + + test("recognizes the transitional V1 API health response", async () => { + const fetcher = mockFetch((input) => { + const path = new URL(input instanceof Request ? input.url : input).pathname + if (path === "/global/health") return Promise.resolve(json({}, 404)) + return Promise.resolve(json({ healthy: true })) + }) + + expect(await detectServerProtocol(server, fetcher)).toBe("v1") + }) +}) diff --git a/packages/app/src/utils/server-protocol.ts b/packages/app/src/utils/server-protocol.ts new file mode 100644 index 0000000000..27b8dc208e --- /dev/null +++ b/packages/app/src/utils/server-protocol.ts @@ -0,0 +1,35 @@ +import type { ServerConnection } from "@/context/server" +import { authTokenFromCredentials } from "./server" + +export type ServerProtocol = "v1" | "v2" + +function headers(server: ServerConnection.HttpBase) { + if (!server.password) return + return { + Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`, + } +} + +async function probe(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch, path: string) { + const response = await fetch(new URL(path, server.url), { + headers: headers(server), + signal: AbortSignal.timeout(5_000), + }) + if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return + const value: unknown = await response.json() + if (!value || typeof value !== "object") return + return value +} + +export async function detectServerProtocol( + server: ServerConnection.HttpBase, + fetch: typeof globalThis.fetch, +): Promise { + const legacy = await probe(server, fetch, "/global/health").catch(() => undefined) + if (legacy && "healthy" in legacy && legacy.healthy === true) return "v1" + + const current = await probe(server, fetch, "/api/health").catch(() => undefined) + if (current && "pid" in current && typeof current.pid === "number") return "v2" + if (current && "healthy" in current && current.healthy === true) return "v1" + return "v2" +} diff --git a/packages/app/src/utils/server.ts b/packages/app/src/utils/server.ts index 603784e4d4..1c8292ca9d 100644 --- a/packages/app/src/utils/server.ts +++ b/packages/app/src/utils/server.ts @@ -1,4 +1,5 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import type { ServerConnection } from "@/context/server" import { decode64 } from "@/utils/base64" @@ -39,3 +40,23 @@ export function createSdkForServer({ baseUrl: server.url, }) } + +export function createApiForServer(input: { + server: ServerConnection.HttpBase + fetch?: typeof globalThis.fetch +}): OpenCodeClient { + return OpenCode.make({ + baseUrl: input.server.url, + fetch: input.fetch, + headers: input.server.password + ? { + Authorization: `Basic ${authTokenFromCredentials({ + username: input.server.username, + password: input.server.password, + })}`, + } + : undefined, + }) +} + +export type ServerApi = OpenCodeClient diff --git a/packages/app/src/utils/session-message.test.ts b/packages/app/src/utils/session-message.test.ts new file mode 100644 index 0000000000..a69c414e16 --- /dev/null +++ b/packages/app/src/utils/session-message.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, test } from "bun:test" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" +import { normalizeSessionMessages } from "./session-message" + +describe("normalizeSessionMessages", () => { + test("projects current turns into stable legacy rendering records", () => { + const source = [ + { id: "msg_1", type: "agent-switched", agent: "build", time: { created: 1 } }, + { + id: "msg_2", + type: "model-switched", + model: { id: "claude", providerID: "anthropic", variant: "high" }, + time: { created: 2 }, + }, + { + id: "msg_3", + type: "user", + text: "inspect @src/client.ts", + files: [ + { + data: "aGVsbG8=", + mime: "text/plain", + name: "note.txt", + source: { type: "inline" }, + }, + { + data: "ZXhwb3J0IHt9", + mime: "text/plain", + name: "client.ts", + source: { type: "inline" }, + mention: { text: "@src/client.ts", start: 8, end: 22 }, + }, + ], + agents: [{ name: "review", mention: { text: "@review", start: 0, end: 7 } }], + time: { created: 3 }, + }, + { + id: "msg_4", + type: "assistant", + agent: "build", + model: { id: "claude", providerID: "anthropic", variant: "high" }, + content: [ + { type: "reasoning", text: "Thinking", time: { created: 4, completed: 5 } }, + { type: "text", text: "Result" }, + { + type: "tool", + id: "call_1", + name: "read", + state: { + status: "completed", + input: { filePath: "note.txt" }, + metadata: { title: "note.txt" }, + content: [{ type: "text", text: "hello" }], + }, + time: { created: 5, ran: 6, completed: 7 }, + }, + ], + cost: 0.1, + tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 1, write: 0 } }, + time: { created: 4, completed: 7 }, + }, + { + id: "msg_5", + type: "compaction", + status: "completed", + reason: "auto", + summary: "summary", + recent: "recent", + time: { created: 8 }, + }, + ] satisfies SessionMessageInfo[] + + const result = normalizeSessionMessages("ses_1", source) + + expect(result.messages).toHaveLength(2) + expect(result.messages[0]).toMatchObject({ + id: "msg_3", + role: "user", + agent: "build", + model: { providerID: "anthropic", modelID: "claude", variant: "high" }, + }) + expect(result.messages[1]).toMatchObject({ id: "msg_4", role: "assistant", parentID: "msg_3", cost: 0.1 }) + expect(result.parts.get("msg_3")?.map((part) => part.id)).toEqual([ + "msg_3:text:0", + "msg_3:file:0", + "msg_3:file:1", + "msg_3:agent:0", + "msg_5:compaction", + ]) + expect(result.parts.get("msg_3")?.[2]).toMatchObject({ + type: "file", + source: { + type: "file", + path: "src/client.ts", + text: { value: "@src/client.ts", start: 8, end: 22 }, + }, + }) + expect(result.parts.get("msg_4")?.map((part) => part.id)).toEqual(["msg_4:reasoning:0", "msg_4:text:0", "call_1"]) + expect(result.parts.get("msg_4")?.[2]).toMatchObject({ + type: "tool", + tool: "read", + state: { status: "completed", output: "hello" }, + }) + }) + + test("does not invent a parent for an assistant-only page", () => { + const source = [ + { + id: "msg_2", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "orphan" }], + time: { created: 2 }, + }, + ] satisfies SessionMessageInfo[] + + expect(normalizeSessionMessages("ses_1", source).messages).toEqual([]) + }) + + test("projects a current shell message into a renderable standalone turn", () => { + const source = [ + { + id: "msg_shell", + type: "shell", + shellID: "shell_1", + command: "printf hello", + status: "exited", + exit: 0, + output: { output: "hello", cursor: 5, size: 5, truncated: false }, + time: { created: 1, completed: 2 }, + }, + ] satisfies SessionMessageInfo[] + + const result = normalizeSessionMessages("ses_1", source) + + expect(result.messages).toEqual([ + expect.objectContaining({ id: "msg_shell", role: "user" }), + expect.objectContaining({ id: "msg_shell:assistant", role: "assistant", parentID: "msg_shell" }), + ]) + expect(result.parts.get("msg_shell")).toEqual([expect.objectContaining({ type: "text", text: "printf hello" })]) + expect(result.parts.get("msg_shell:assistant")).toEqual([ + expect.objectContaining({ + type: "tool", + tool: "bash", + state: expect.objectContaining({ + status: "completed", + input: { command: "printf hello" }, + output: "hello", + title: "Shell", + }), + }), + ]) + }) + + test("adapts current edit fields for the legacy edit renderer", () => { + const source = [ + { id: "msg_user", type: "user", text: "edit it", time: { created: 1 } }, + { + id: "msg_assistant", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [ + { + type: "tool", + id: "call_edit", + name: "edit", + state: { + status: "completed", + input: { path: "/repo/README.md", oldString: "old", newString: "new" }, + content: [{ type: "text", text: "Edited file successfully" }], + metadata: { + files: [ + { + file: "README.md", + patch: "@@ -1 +1 @@\n-old\n+new", + additions: 1, + deletions: 1, + status: "modified", + }, + ], + replacements: 1, + }, + }, + time: { created: 2, ran: 3, completed: 4 }, + }, + ], + time: { created: 2, completed: 4 }, + }, + ] satisfies SessionMessageInfo[] + + const result = normalizeSessionMessages("ses_1", source) + + expect(result.parts.get("msg_assistant")).toEqual([ + expect.objectContaining({ + type: "tool", + tool: "edit", + state: expect.objectContaining({ + status: "completed", + input: expect.objectContaining({ path: "/repo/README.md", filePath: "/repo/README.md" }), + metadata: expect.objectContaining({ + filediff: { + file: "README.md", + patch: "@@ -1 +1 @@\n-old\n+new", + additions: 1, + deletions: 1, + }, + }), + }), + }), + ]) + }) +}) diff --git a/packages/app/src/utils/session-message.ts b/packages/app/src/utils/session-message.ts new file mode 100644 index 0000000000..93d86a66bb --- /dev/null +++ b/packages/app/src/utils/session-message.ts @@ -0,0 +1,358 @@ +import type { + SessionMessageAssistant, + SessionMessageAssistantTool, + SessionMessageInfo, + SessionMessageShell, + SessionMessageUser, +} from "@opencode-ai/client/promise" +import type { AssistantMessage, FilePart, Message, Part, ToolPart, UserMessage } from "@opencode-ai/sdk/v2" +import { Option, Schema } from "effect" + +const emptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } +const emptyModel: { id: string; providerID: string; variant?: string } = { id: "", providerID: "" } +const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) + +function record(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function normalizeToolInput(name: string, input: Record) { + if (!["edit", "write"].includes(name) || typeof input.path !== "string" || typeof input.filePath === "string") + return input + return { ...input, filePath: input.path } +} + +function normalizeToolMetadata(name: string, metadata: Record) { + if (name !== "edit" || !Array.isArray(metadata.files)) return metadata + const file = metadata.files.find(record) + if (!file || typeof file.file !== "string") return metadata + return { + ...metadata, + filediff: { + file: file.file, + patch: typeof file.patch === "string" ? file.patch : undefined, + additions: typeof file.additions === "number" ? file.additions : 0, + deletions: typeof file.deletions === "number" ? file.deletions : 0, + }, + } +} + +export function normalizeSessionMessages(sessionID: string, source: readonly SessionMessageInfo[]) { + const messages: Message[] = [] + const parts = new Map() + let agent = "" + let model = emptyModel + let parentID: string | undefined + + source.forEach((message) => { + if (message.type === "agent-switched") { + agent = message.agent + return + } + if (message.type === "model-switched") { + model = message.model + return + } + if (message.type === "user") { + parentID = message.id + messages.push(userMessage(sessionID, message, agent, model)) + parts.set(message.id, userParts(sessionID, message)) + return + } + if (message.type === "synthetic" && message.description?.trim()) { + parentID = message.id + messages.push({ + id: message.id, + sessionID, + role: "user", + time: message.time, + agent, + model: { providerID: model.providerID, modelID: model.id, variant: model.variant }, + }) + parts.set(message.id, [textPart(sessionID, message.id, 0, message.description, true)]) + return + } + if (message.type === "shell") { + messages.push(...shellMessages(sessionID, message, agent, model)) + parts.set(message.id, [textPart(sessionID, message.id, 0, message.command)]) + parts.set(`${message.id}:assistant`, [shellPart(sessionID, message)]) + parentID = undefined + return + } + if (message.type === "assistant") { + agent = message.agent + model = message.model + if (!parentID) return + const parent = messages.findLast((item) => item.id === parentID) + if (parent?.role === "user") { + parent.agent = message.agent + parent.model = { + providerID: message.model.providerID, + modelID: message.model.id, + variant: message.model.variant, + } + } + messages.push(assistantMessage(sessionID, parentID, message)) + parts.set(message.id, assistantParts(sessionID, message)) + return + } + if (message.type !== "compaction" || !parentID) return + parts.set(parentID, [ + ...(parts.get(parentID) ?? []), + { + id: `${message.id}:compaction`, + sessionID, + messageID: parentID, + type: "compaction", + auto: message.reason === "auto", + }, + ]) + }) + + return { messages, parts } +} + +function shellMessages( + sessionID: string, + message: SessionMessageShell, + agent: string, + model: { id: string; providerID: string; variant?: string }, +): [UserMessage, AssistantMessage] { + return [ + { + id: message.id, + sessionID, + role: "user", + time: { created: message.time.created }, + agent, + model: { providerID: model.providerID, modelID: model.id, variant: model.variant }, + }, + { + id: `${message.id}:assistant`, + sessionID, + role: "assistant", + time: message.time, + parentID: message.id, + modelID: model.id, + providerID: model.providerID, + variant: model.variant, + mode: agent, + agent, + path: { cwd: "", root: "" }, + cost: 0, + tokens: emptyTokens, + }, + ] +} + +function shellPart(sessionID: string, message: SessionMessageShell): ToolPart { + const input = { command: message.command } + const start = message.time.created + const state: ToolPart["state"] = + message.status === "running" + ? { status: "running", input, time: { start } } + : { + status: "completed", + input, + output: message.output?.output ?? "", + title: "Shell", + metadata: { + status: message.status, + exit: message.exit, + truncated: message.output?.truncated, + }, + time: { start, end: message.time.completed ?? start }, + } + return { + id: `${message.id}:tool`, + sessionID, + messageID: `${message.id}:assistant`, + type: "tool", + callID: message.shellID, + tool: "bash", + state, + } +} + +export function sessionMessagePartID(messageID: string, type: "text" | "reasoning", ordinal: number) { + return `${messageID}:${type}:${ordinal}` +} + +function userMessage( + sessionID: string, + message: SessionMessageUser, + agent: string, + model: { id: string; providerID: string; variant?: string }, +): UserMessage { + return { + id: message.id, + sessionID, + role: "user", + time: message.time, + agent, + model: { providerID: model.providerID, modelID: model.id, variant: model.variant }, + } +} + +function userParts(sessionID: string, message: SessionMessageUser): Part[] { + return [ + textPart(sessionID, message.id, 0, message.text), + ...(message.files ?? []).map( + (file, index): FilePart => ({ + id: `${message.id}:file:${index}`, + sessionID, + messageID: message.id, + type: "file", + mime: file.mime, + filename: file.name, + url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`, + source: file.mention + ? { + type: "file", + text: { value: file.mention.text, start: file.mention.start, end: file.mention.end }, + path: file.mention.text.startsWith("@") ? file.mention.text.slice(1) : (file.name ?? file.mention.text), + } + : undefined, + }), + ), + ...(message.agents ?? []).map( + (item, index): Part => ({ + id: `${message.id}:agent:${index}`, + sessionID, + messageID: message.id, + type: "agent", + name: item.name, + source: item.mention + ? { value: item.mention.text, start: item.mention.start, end: item.mention.end } + : undefined, + }), + ), + ] +} + +function assistantMessage(sessionID: string, parentID: string, message: SessionMessageAssistant): AssistantMessage { + const error = message.error + ? message.error.type.toLowerCase().includes("abort") || message.error.type.toLowerCase().includes("interrupt") + ? { name: "MessageAbortedError" as const, data: { message: message.error.message } } + : { name: "UnknownError" as const, data: { message: message.error.message } } + : undefined + return { + id: message.id, + sessionID, + role: "assistant", + time: message.time, + error, + parentID, + modelID: message.model.id, + providerID: message.model.providerID, + variant: message.model.variant, + mode: message.agent, + agent: message.agent, + path: { cwd: "", root: "" }, + cost: message.cost ?? 0, + tokens: message.tokens ?? emptyTokens, + finish: message.finish, + } +} + +function assistantParts(sessionID: string, message: SessionMessageAssistant): Part[] { + const ordinals = { text: 0, reasoning: 0 } + return message.content.flatMap((content): Part[] => { + if (content.type === "text") { + const part = textPart(sessionID, message.id, ordinals.text++, content.text) + return content.text.trim() ? [part] : [] + } + if (content.type === "reasoning") { + const part: Part = { + id: sessionMessagePartID(message.id, "reasoning", ordinals.reasoning++), + sessionID, + messageID: message.id, + type: "reasoning", + text: content.text, + metadata: content.state, + time: { + start: content.time?.created ?? message.time.created, + end: content.time?.completed, + }, + } + return content.text.trim() ? [part] : [] + } + return [toolPart(sessionID, message.id, content)] + }) +} + +function textPart(sessionID: string, messageID: string, ordinal: number, text: string, synthetic?: boolean): Part { + return { + id: sessionMessagePartID(messageID, "text", ordinal), + sessionID, + messageID, + type: "text", + text, + synthetic, + } +} + +function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssistantTool): ToolPart { + const start = tool.time.ran ?? tool.time.created + const state = (() => { + if (tool.state.status === "streaming") { + const value = Option.getOrUndefined(decodeToolInput(tool.state.input)) + const input = normalizeToolInput(tool.name, record(value) ? value : {}) + return { status: "pending" as const, input, raw: tool.state.input } + } + if (tool.state.status === "running") { + return { + status: "running" as const, + input: normalizeToolInput(tool.name, tool.state.input), + // metadata: normalizeToolMetadata(tool.name, tool.state.structured), + metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}), + time: { start }, + } + } + if (tool.state.status === "error") { + return { + status: "error" as const, + input: normalizeToolInput(tool.name, tool.state.input), + error: tool.state.error.message, + // metadata: normalizeToolMetadata(tool.name, tool.state.structured), + metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}), + time: { start, end: tool.time.completed ?? start }, + } + } + const attachments = tool.state.content.flatMap((item, index): FilePart[] => + item.type === "file" + ? [ + { + id: `${tool.id}:file:${index}`, + sessionID, + messageID, + type: "file", + mime: item.mime, + filename: item.name, + url: item.uri, + }, + ] + : [], + ) + return { + status: "completed" as const, + input: normalizeToolInput(tool.name, tool.state.input), + output: tool.state.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n"), + title: tool.name, + // metadata: normalizeToolMetadata(tool.name, tool.state.structured), + metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}), + time: { start, end: tool.time.completed ?? start }, + attachments: attachments.length ? attachments : undefined, + } + })() + return { + id: tool.id, + sessionID, + messageID, + type: "tool", + callID: tool.id, + tool: tool.name, + state, + metadata: { providerState: tool.providerState, providerResultState: tool.providerResultState }, + } +} diff --git a/packages/app/src/utils/session.test.ts b/packages/app/src/utils/session.test.ts new file mode 100644 index 0000000000..b15c23b660 --- /dev/null +++ b/packages/app/src/utils/session.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test" +import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise" +import { listAllSessions, normalizeSessionInfo } from "./session" + +describe("normalizeSessionInfo", () => { + test("adapts a current session to the app session shape", () => { + const result = normalizeSessionInfo({ + id: "session-1", + projectID: "project-1", + agent: "build", + model: { id: "gpt-5", providerID: "openai", variant: "high" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + title: "New session", + location: { directory: "/repo/worktree", workspaceID: "workspace-1" }, + subpath: "worktree", + revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot", files: [] }, + } as SessionInfo) + + expect(result).toEqual({ + id: "session-1", + slug: "session-1", + projectID: "project-1", + workspaceID: "workspace-1", + directory: "/repo/worktree", + path: "worktree", + parentID: undefined, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + title: "New session", + agent: "build", + model: { id: "gpt-5", providerID: "openai", variant: "high" }, + version: "", + time: { created: 1, updated: 1 }, + revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot" }, + }) + }) +}) + +describe("listAllSessions", () => { + test("loads every page in server order and retains the query", async () => { + const calls: SessionListInput[] = [] + const pages = new Map([ + [undefined, { data: [sessionInfo("session-3"), sessionInfo("session-2")], cursor: { next: "next" } }], + ["next", { data: [sessionInfo("session-1", true)], cursor: {} }], + ]) + const api = { + list: async (query = {}) => { + calls.push(query) + return pages.get(query.cursor) ?? { data: [], cursor: {} } + }, + } satisfies Pick + + const result = await listAllSessions(api, { directory: "/repo", order: "desc" }) + + expect(result.map((session) => session.id)).toEqual(["session-3", "session-2", "session-1"]) + expect(result[2]?.time.archived).toBe(2) + expect(calls).toEqual([ + { directory: "/repo", order: "desc", limit: 100, cursor: undefined }, + { directory: "/repo", order: "desc", limit: 100, cursor: "next" }, + ]) + }) + + test("requests the terminal empty page when the server returns a next cursor", async () => { + const cursors: Array = [] + const api = { + list: async (query = {}) => { + cursors.push(query.cursor) + if (query.cursor) return { data: [], cursor: { next: "unused" } } + return { data: [sessionInfo("session-1")], cursor: { next: "terminal" } } + }, + } satisfies Pick + + const result = await listAllSessions(api, { directory: "/repo", limit: 25 }) + + expect(result.map((session) => session.id)).toEqual(["session-1"]) + expect(cursors).toEqual([undefined, "terminal"]) + }) +}) + +function sessionInfo(id: string, archived = false) { + return { + id, + projectID: "project-1", + agent: "build", + model: { id: "model-1", providerID: "provider-1" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1, archived: archived ? 2 : undefined }, + title: id, + location: { directory: "/repo" }, + } as SessionInfo +} diff --git a/packages/app/src/utils/session.ts b/packages/app/src/utils/session.ts new file mode 100644 index 0000000000..faf847967b --- /dev/null +++ b/packages/app/src/utils/session.ts @@ -0,0 +1,37 @@ +import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise" +import type { Session } from "@opencode-ai/sdk/v2/client" + +export function normalizeSessionInfo(input: SessionInfo | Session): Session { + if (!("location" in input)) return input + return { + id: input.id, + slug: input.id, + projectID: input.projectID, + workspaceID: input.location.workspaceID, + directory: input.location.directory, + path: input.subpath, + parentID: input.parentID, + cost: input.cost, + tokens: input.tokens, + title: input.title, + agent: input.agent, + model: input.model, + version: "", + time: input.time, + revert: input.revert && { + messageID: input.revert.messageID, + partID: input.revert.partID, + snapshot: input.revert.snapshot, + }, + } +} + +export async function listAllSessions(api: Pick, input: Omit) { + const load = async (cursor?: string): Promise => { + const result = await api.list({ ...input, limit: input.limit ?? 100, cursor }) + const sessions = result.data.map(normalizeSessionInfo) + if (result.data.length === 0 || !result.cursor.next) return sessions + return [...sessions, ...(await load(result.cursor.next))] + } + return load() +} diff --git a/packages/app/src/utils/terminal-websocket-url.test.ts b/packages/app/src/utils/terminal-websocket-url.test.ts index 5fa1506b1e..aac854ca82 100644 --- a/packages/app/src/utils/terminal-websocket-url.test.ts +++ b/packages/app/src/utils/terminal-websocket-url.test.ts @@ -2,8 +2,28 @@ import { describe, expect, test } from "bun:test" import { terminalWebSocketURL } from "./terminal-websocket-url" describe("terminalWebSocketURL", () => { - test("uses query auth without embedding credentials in websocket URL", () => { + test("uses the current ticketed PTY route", () => { const url = terminalWebSocketURL({ + url: "http://127.0.0.1:49365", + id: "pty_test", + directory: "/tmp/project", + cursor: 0, + ticket: "connect-ticket", + }) + + expect(url.protocol).toBe("ws:") + expect(url.username).toBe("") + expect(url.password).toBe("") + expect(url.pathname).toBe("/api/pty/pty_test/connect") + expect(url.searchParams.get("location[directory]")).toBe("/tmp/project") + expect(url.searchParams.get("cursor")).toBe("0") + expect(url.searchParams.get("ticket")).toBe("connect-ticket") + expect(url.searchParams.has("auth_token")).toBe(false) + }) + + test("uses query auth without embedding credentials in websocket URL for v1", () => { + const url = terminalWebSocketURL({ + protocol: "v1", url: "http://127.0.0.1:49365", id: "pty_test", directory: "/tmp/project", @@ -16,11 +36,14 @@ describe("terminalWebSocketURL", () => { expect(url.protocol).toBe("ws:") expect(url.username).toBe("") expect(url.password).toBe("") + expect(url.pathname).toBe("/pty/pty_test/connect") + expect(url.searchParams.get("directory")).toBe("/tmp/project") expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) }) - test("omits query auth for same-origin saved credentials", () => { + test("omits query auth for same-origin saved credentials for v1", () => { const url = terminalWebSocketURL({ + protocol: "v1", url: "https://app.example.test", id: "pty_test", directory: "/tmp/project", @@ -31,11 +54,14 @@ describe("terminalWebSocketURL", () => { }) expect(url.protocol).toBe("wss:") + expect(url.pathname).toBe("/pty/pty_test/connect") + expect(url.searchParams.get("directory")).toBe("/tmp/project") expect(url.searchParams.has("auth_token")).toBe(false) }) - test("uses query auth for same-origin credentials from auth_token", () => { + test("uses query auth for same-origin credentials from auth_token for v1", () => { const url = terminalWebSocketURL({ + protocol: "v1", url: "https://app.example.test", id: "pty_test", directory: "/tmp/project", @@ -47,6 +73,8 @@ describe("terminalWebSocketURL", () => { }) expect(url.protocol).toBe("wss:") + expect(url.pathname).toBe("/pty/pty_test/connect") + expect(url.searchParams.get("directory")).toBe("/tmp/project") expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) }) }) diff --git a/packages/app/src/utils/terminal-websocket-url.ts b/packages/app/src/utils/terminal-websocket-url.ts index 06facdc7d2..a32b239cc9 100644 --- a/packages/app/src/utils/terminal-websocket-url.ts +++ b/packages/app/src/utils/terminal-websocket-url.ts @@ -1,6 +1,7 @@ import { authTokenFromCredentials } from "@/utils/server" export function terminalWebSocketURL(input: { + protocol?: "v1" | "v2" url: string id: string directory: string @@ -11,18 +12,24 @@ export function terminalWebSocketURL(input: { password?: string authToken?: boolean }) { - const next = new URL(`${input.url}/pty/${input.id}/connect`) - next.searchParams.set("directory", input.directory) + const isV1 = input.protocol === "v1" + const next = new URL(`${input.url}${isV1 ? `/pty/${input.id}/connect` : `/api/pty/${input.id}/connect`}`) + if (isV1) { + next.searchParams.set("directory", input.directory) + } else { + next.searchParams.set("location[directory]", input.directory) + } next.searchParams.set("cursor", String(input.cursor)) next.protocol = next.protocol === "https:" ? "wss:" : "ws:" if (input.ticket) { next.searchParams.set("ticket", input.ticket) return next } - if (input.password && (!input.sameOrigin || input.authToken)) + if (isV1 && input.password && (!input.sameOrigin || input.authToken)) { next.searchParams.set( "auth_token", authTokenFromCredentials({ username: input.username, password: input.password }), ) + } return next } diff --git a/packages/app/src/utils/toast.tsx b/packages/app/src/utils/toast.tsx index e444548508..6f23b63d1e 100644 --- a/packages/app/src/utils/toast.tsx +++ b/packages/app/src/utils/toast.tsx @@ -1,6 +1,12 @@ import { Icon, type IconProps } from "@opencode-ai/ui/icon" -import { Toast, showToast as showLegacyToast, type ToastOptions, type ToastVariant } from "@opencode-ai/ui/toast" -import { ToastV2, showToastV2 } from "@opencode-ai/ui/v2/toast-v2" +import { + Toast, + showToast as showLegacyToast, + toaster as legacyToaster, + type ToastOptions, + type ToastVariant, +} from "@opencode-ai/ui/toast" +import { ToastV2, showToastV2, toasterV2 } from "@opencode-ai/ui/v2/toast-v2" let v2 = false @@ -27,6 +33,13 @@ export function showToast(options: ToastOptions | string) { }) } +// v1 and v2 ids come from separate registries, so dismissal has to use the same +// implementation that issued the id. +export function dismissToast(toastId: number) { + if (!v2) return legacyToaster.dismiss(toastId) + return toasterV2.dismiss(toastId) +} + function resolveIcon(icon: IconProps["name"] | undefined, variant: ToastVariant | undefined) { const name = icon ?? (variant === "success" ? "check" : undefined) if (!name) return diff --git a/packages/app/test-browser/command-palette.test.ts b/packages/app/test-browser/command-palette.test.ts new file mode 100644 index 0000000000..6a74834fd0 --- /dev/null +++ b/packages/app/test-browser/command-palette.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test" +import type { Project } from "@opencode-ai/sdk/v2/client" +import type { SessionInfo } from "@opencode-ai/client/promise" +import { createRoot } from "solid-js" +import { createServerSessionEntries } from "@/components/command-palette" +import type { LocalProject } from "@/context/layout" +import { ServerConnection } from "@/context/server" +import { getProjectAvatarSource } from "@/pages/layout/helpers" + +const stored: Project = { + id: "project-1", + name: "Palette project", + worktree: "/workspace/project", + sandboxes: [], + time: { created: 1, updated: 1 }, +} + +const session: SessionInfo = { + id: "session-1", + projectID: stored.id, + agent: "build", + model: { id: "model-1", providerID: "provider-1" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + location: { directory: stored.worktree }, + title: "Palette session", + time: { created: 1, updated: 2 }, +} + +describe("command palette sessions", () => { + test("uses the home project avatar and cancels superseded searches", async () => { + const server = ServerConnection.Key.make("selected-server") + const opened: LocalProject = { + ...stored, + icon: { override: "home-project-avatar" }, + expanded: true, + } + const searches: string[] = [] + const result = await new Promise>>>( + (resolve, reject) => { + createRoot((dispose) => { + const search = createServerSessionEntries({ + server, + opened: () => [opened], + stored: () => [{ ...stored, icon: { url: "stored-project-avatar" } }], + load: async (text) => { + searches.push(text) + return { + data: [session, { ...session, id: "archived-session", time: { ...session.time, archived: 3 } }], + } + }, + untitled: () => "Untitled", + category: () => "Sessions", + }) + const first = search("palette") + const second = search("palette session") + Promise.all([first, second]) + .then(([cancelled, entries]) => { + expect(cancelled).toEqual([]) + resolve(entries) + }) + .catch(reject) + .finally(dispose) + }) + }, + ) + + expect(searches).toEqual(["palette session"]) + expect(result).toHaveLength(1) + expect(getProjectAvatarSource(result[0]?.project?.id, result[0]?.project?.icon)).toBe("home-project-avatar") + expect(result[0]).toMatchObject({ + server, + sessionID: session.id, + description: stored.name, + project: { id: stored.id, icon: opened.icon }, + }) + }) +}) diff --git a/packages/app/test-browser/prompt-attachments.test.ts b/packages/app/test-browser/prompt-attachments.test.ts index ca7686598e..49dad279cd 100644 --- a/packages/app/test-browser/prompt-attachments.test.ts +++ b/packages/app/test-browser/prompt-attachments.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from "bun:test" import { createRoot } from "solid-js" +import { createStore } from "solid-js/store" import { createPromptAttachmentsCore } from "@/components/prompt-input/attachments" import { createPromptState } from "@/context/prompt" +import { createPromptInputV2Attachments } from "../../session-ui/src/v2/components/prompt-input/attachments" +import type { PromptInputV2Prompt } from "../../session-ui/src/v2/components/prompt-input/types" describe("prompt attachment session ownership", () => { test("adds an asynchronously read image to the session where the read started", async () => { @@ -85,6 +88,88 @@ describe("prompt attachment session ownership", () => { }) }) +test("rejects a duplicate native clipboard attachment in the V2 prompt store", async () => { + await createRoot(async (dispose) => { + const [state, setState] = createStore({ prompt: [] as PromptInputV2Prompt }) + const duplicate = Promise.withResolvers() + const files = [ + new File(["hello"], "clipboard-1.txt", { type: "text/plain" }), + new File(["hello"], "clipboard-2.txt", { type: "text/plain" }), + ] + const attachments = createPromptInputV2Attachments({ + capture: () => ({ + current: () => state.prompt, + cursor: () => 0, + set: (prompt) => setState("prompt", prompt), + }), + editor: () => document.createElement("div"), + focusEditor: () => undefined, + addPart: () => false, + setDraggingType: () => undefined, + directory: () => "/", + isDialogActive: () => false, + warn: () => undefined, + duplicate: duplicate.resolve, + onError: () => undefined, + readClipboardImage: async () => files.shift() ?? null, + }) + const event = { + clipboardData: { items: [], getData: () => "" }, + preventDefault: () => undefined, + stopPropagation: () => undefined, + } as unknown as ClipboardEvent + + await attachments.handlePaste(event) + await attachments.handlePaste(event) + await duplicate.promise + + expect(state.prompt).toHaveLength(1) + dispose() + }) +}) + +test("rejects desktop duplicates and keeps changed files in the V2 prompt store", async () => { + await createRoot(async (dispose) => { + const [state, setState] = createStore({ prompt: [] as PromptInputV2Prompt }) + const duplicates: string[] = [] + const attachments = createPromptInputV2Attachments({ + capture: () => ({ + current: () => state.prompt, + cursor: () => 0, + set: (prompt) => setState("prompt", prompt), + }), + editor: () => document.createElement("div"), + focusEditor: () => undefined, + addPart: () => false, + setDraggingType: () => undefined, + directory: () => "/", + isDialogActive: () => false, + warn: () => undefined, + duplicate: () => duplicates.push("duplicate"), + onError: () => undefined, + getPathForFile: (file) => (file.name === "browser.txt" ? "" : `/tmp/${file.name}`), + }) + const first = new File(["first"], "a.txt", { type: "text/plain" }) + const second = new File(["second"], "b.txt", { type: "text/plain" }) + + await attachments.addAttachments([first, second]) + await attachments.addAttachments([first, second]) + expect(state.prompt).toHaveLength(2) + expect(duplicates).toEqual(["duplicate", "duplicate"]) + + await attachments.addAttachments([new File(["edited"], "a.txt", { type: "text/plain" })]) + expect(state.prompt).toHaveLength(3) + + await attachments.addAttachments([ + new File(["same"], "browser.txt", { type: "text/plain" }), + new File(["same"], "browser.txt", { type: "text/plain" }), + ]) + expect(state.prompt).toHaveLength(4) + expect(duplicates).toHaveLength(3) + dispose() + }) +}) + function images(prompt: ReturnType) { return prompt.current().filter((part) => part.type === "image") } diff --git a/packages/app/test-browser/prompt-persistence.test.ts b/packages/app/test-browser/prompt-persistence.test.ts index a7b08078d7..f3d8f8ae4f 100644 --- a/packages/app/test-browser/prompt-persistence.test.ts +++ b/packages/app/test-browser/prompt-persistence.test.ts @@ -2,6 +2,7 @@ import { beforeAll, describe, expect, mock, test } from "bun:test" import type { AsyncStorage } from "@solid-primitives/storage" import { createEffect, createRoot } from "solid-js" import { ServerScope } from "@/utils/server-scope" +import { createDraftStore } from "@/utils/draft-store" let Prompt: typeof import("@/context/prompt") let read: ((value: string | null) => void) | undefined @@ -30,7 +31,7 @@ beforeAll(async () => { }), })) mock.module("@/context/platform", () => ({ - usePlatform: () => ({ platform: "desktop", storage: () => storage }), + usePlatform: () => ({ platform: "desktop", storage: () => storage, draftStore: storage }), })) Prompt = await import("@/context/prompt") @@ -69,3 +70,50 @@ describe("prompt persistence", () => { }) }) }) + +test("moves legacy image data URLs into blobs and hydrates object URLs", async () => { + const documents = new Map() + const blobs = new Map() + const store = createDraftStore({ + get: async (key) => documents.get(key) ?? null, + set: async (key, value) => void documents.set(key, value), + remove: async (key) => void documents.delete(key), + putBlob: async (blob) => { + const id = String(blob.size) + blobs.set(id, blob) + return id + }, + getBlob: async (id) => blobs.get(id) ?? null, + }) + + await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "image", dataUrl: "data:image/png;base64,YQ==" }] })) + expect(documents.get("prompt")).not.toContain("dataUrl") + const value = JSON.parse((await store.getItem("prompt"))!) + expect(value.prompt[0].blob.id).toBe("1") + expect(value.prompt[0].blob.url).toStartWith("blob:") +}) + +test("does not let delayed blob migration overwrite a newer draft", async () => { + const documents = new Map() + const migration = Promise.withResolvers() + const store = createDraftStore({ + get: async () => null, + set: async (key, value) => void documents.set(key, value), + remove: async () => undefined, + putBlob: async () => { + await migration.promise + return "blob" + }, + getBlob: async () => null, + }) + const older = store.setItem( + "prompt", + JSON.stringify({ prompt: [{ type: "image", dataUrl: "data:image/png;base64,YQ==" }] }), + ) + await Bun.sleep(0) + await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "text", content: "latest" }] })) + migration.resolve() + await older + + expect(documents.get("prompt")).toContain("latest") +}) diff --git a/packages/app/test-browser/prompt-transient-state.test.ts b/packages/app/test-browser/prompt-transient-state.test.ts index 61b5c6a14f..5777c9c392 100644 --- a/packages/app/test-browser/prompt-transient-state.test.ts +++ b/packages/app/test-browser/prompt-transient-state.test.ts @@ -18,7 +18,6 @@ test("resets transient prompt input state when the prompt session changes", () = draggingType: "image", mode: "shell", applyingHistory: true, - variantOpen: true, }) setIdentity("B") @@ -33,7 +32,6 @@ test("resets transient prompt input state when the prompt session changes", () = draggingType: null, mode: "normal", applyingHistory: false, - variantOpen: false, }) dispose() }) diff --git a/packages/app/test-browser/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/settings-keybinds.test.ts b/packages/app/test-browser/settings-keybinds.test.ts new file mode 100644 index 0000000000..75f0b75543 --- /dev/null +++ b/packages/app/test-browser/settings-keybinds.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { createKeybindSettingsController } from "../src/components/settings-keybinds" + +function setup(overrides: Record = {}) { + const changes: [string, string][] = [] + const suppression: boolean[] = [] + const notifications: { title: string; description?: string }[] = [] + let resets = 0 + let controller: ReturnType + + const dispose = createRoot((dispose) => { + controller = createKeybindSettingsController( + { + command: { + catalog: [ + { id: "session.alpha", title: "Alpha", keybind: "mod+a" }, + { id: "session.beta", title: "Beta", keybind: "mod+b" }, + ], + options: [], + keybinds: (enabled) => suppression.push(enabled), + }, + settings: { + current: { keybinds: overrides }, + keybinds: { + get: (id) => overrides[id], + set: (id, value) => { + overrides[id] = value + changes.push([id, value]) + }, + resetAll: () => { + resets++ + }, + }, + }, + notify: (toast) => notifications.push(toast), + }, + { + locale: () => "en", + t: (key, params) => { + if (params) return `${key}:${Object.values(params).join("|")}` + if (key === "common.key.alt") return "Alt" + return String(key) + }, + }, + ) + return dispose + }) + + return { + controller: controller!, + changes, + suppression, + notifications, + resets: () => resets, + dispose, + } +} + +function modKey(key: string) { + const mac = /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) + return new KeyboardEvent("keydown", { key, ctrlKey: !mac, metaKey: mac, bubbles: true, cancelable: true }) +} + +describe("keybind settings controller", () => { + test("derives the catalog, effective bindings, and filtered groups", () => { + const state = setup({ "session.beta": "alt+k" }) + + expect(state.controller.catalog.title("session.alpha")).toBe("Alpha") + expect(state.controller.catalog.keybind("session.beta")).toBe("Alt+K") + expect(state.controller.catalog.filtered("alt k").get("Session")).toEqual(["session.beta"]) + expect(state.controller.settings.hasOverrides()).toBe(true) + + state.dispose() + }) + + test("captures bindings, rejects conflicts, and restores command handling", () => { + const state = setup() + + state.controller.capture.toggle("session.beta") + document.dispatchEvent(modKey("a")) + expect(state.changes).toEqual([]) + expect(state.notifications).toHaveLength(1) + expect(state.controller.capture.active()).toBe("session.beta") + + document.dispatchEvent(modKey("x")) + expect(state.changes).toEqual([["session.beta", "mod+x"]]) + expect(state.suppression).toEqual([false, true]) + expect(state.controller.capture.active()).toBeNull() + + state.controller.capture.toggle("session.alpha") + state.dispose() + expect(state.suppression).toEqual([false, true, false, true]) + document.dispatchEvent(modKey("z")) + expect(state.changes).toEqual([["session.beta", "mod+x"]]) + }) + + test("resets persisted overrides and reports success", () => { + const state = setup({ "session.alpha": "none" }) + + state.controller.settings.reset() + expect(state.resets()).toBe(1) + expect(state.notifications[0]?.title).toBe("settings.shortcuts.reset.toast.title") + + state.dispose() + }) +}) diff --git a/packages/app/test-browser/solid-router-cleanup.test.ts b/packages/app/test-browser/solid-router-cleanup.test.ts new file mode 100644 index 0000000000..5795fa5648 --- /dev/null +++ b/packages/app/test-browser/solid-router-cleanup.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from "bun:test" +import { MetaProvider, Title } from "@solidjs/meta" +import { MemoryRouter, Route, createMemoryHistory, useParams } from "@solidjs/router" +import { createMemo } from "solid-js" +import { createComponent, render } from "solid-js/web" + +test("route cleanup cannot invalidate an owner list being disposed", async () => { + const host = document.createElement("div") + document.body.append(host) + const history = createMemoryHistory() + + const RepoPage = () => { + const params = useParams<{ id?: string }>() + const title = createMemo(() => params.id ?? "") + const button = document.createElement("button") + button.textContent = "Back" + button.addEventListener("click", () => history.set({ value: "/", scroll: false, replace: false })) + return [ + createComponent(Title, { + get children() { + return title() + }, + }), + button, + ] + } + + const HomePage = () => { + const button = document.createElement("button") + button.textContent = "Go" + button.addEventListener("click", () => history.set({ value: "/project", scroll: false, replace: false })) + return button + } + + const App = () => + createComponent(MetaProvider, { + get children() { + return createComponent(MemoryRouter, { + history, + get children() { + return [ + createComponent(Route, { path: "/", component: HomePage }), + createComponent(Route, { path: "/:id", component: RepoPage }), + ] + }, + }) + }, + }) + + const dispose = render(() => createComponent(App, {}), host) + const go = host.querySelector("button") + expect(go?.textContent).toBe("Go") + go?.click() + await new Promise((resolve) => setTimeout(resolve, 0)) + + const back = host.querySelector("button") + expect(back?.textContent).toBe("Back") + back?.click() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(host.querySelector("button")?.textContent).toBe("Go") + dispose() + host.remove() +}) diff --git a/packages/app/test-browser/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/app/test-browser/toast-owner.test.ts b/packages/app/test-browser/toast-owner.test.ts index 104a505851..25ba5c000e 100644 --- a/packages/app/test-browser/toast-owner.test.ts +++ b/packages/app/test-browser/toast-owner.test.ts @@ -1,8 +1,46 @@ -import { describe, expect, test } from "bun:test" +import { beforeEach, describe, expect, test } from "bun:test" import { createSignal, type JSX } from "solid-js" import { showToastV2, toasterV2 } from "@opencode-ai/ui/v2/toast-v2" describe("showToastV2", () => { + // The toast registry is module state, so each test starts from an empty stack. + beforeEach(() => { + toasterV2.dismiss() + }) + + test("coalesces exact active content", () => { + const first = showToastV2({ title: "Repeated error", description: "Try again" }) + const second = showToastV2({ title: "Repeated error", description: "Try again" }) + const different = showToastV2({ title: "Repeated error", description: "A different error" }) + + expect(second).toBe(first) + expect(different).not.toBe(first) + + toasterV2.dismiss(first) + toasterV2.dismiss(different) + }) + + test("allows dismissed content to appear again", () => { + const first = showToastV2("Dismiss and retry") + toasterV2.dismiss(first) + + const second = showToastV2("Dismiss and retry") + expect(second).not.toBe(first) + + toasterV2.dismiss(second) + }) + + test("recreates matching content when it is not the topmost toast", () => { + const first = showToastV2("First toast") + const topmost = showToastV2("Topmost toast") + const repeated = showToastV2("First toast") + + expect(repeated).not.toBe(first) + + toasterV2.dismiss(topmost) + toasterV2.dismiss(repeated) + }) + test("creates no reactive computations at call time", () => { const [tick, setTick] = createSignal(0) let reads = 0 diff --git a/packages/app/vendor/opencode-ai-client-1.17.13-v2.tgz b/packages/app/vendor/opencode-ai-client-1.17.13-v2.tgz new file mode 100644 index 0000000000..bc1b664f82 Binary files /dev/null and b/packages/app/vendor/opencode-ai-client-1.17.13-v2.tgz differ diff --git a/packages/cli/package.json b/packages/cli/package.json index dd660aa2ee..be5d853de0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/cli", - "version": "1.17.18", + "version": "1.18.11", "type": "module", "license": "MIT", "bin": { diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index 55869a6295..f42d8b07b0 100755 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -1,7 +1,6 @@ #!/usr/bin/env bun import { $ } from "bun" -import fs from "fs" import { rm } from "fs/promises" import path from "path" import { Script } from "@opencode-ai/script" @@ -51,10 +50,6 @@ const targets = singleFlag if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}` -const localParserWorker = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js") -const rootParserWorker = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js") -const parserWorker = fs.realpathSync(fs.existsSync(localParserWorker) ? localParserWorker : rootParserWorker) - for (const item of targets) { const target = [ binary, @@ -68,7 +63,7 @@ for (const item of targets) { const name = target.replace(binary, "cli") console.log(`building ${name}`) const result = await Bun.build({ - entrypoints: ["./src/index.ts", parserWorker], + entrypoints: ["./src/index.ts"], tsconfig: "./tsconfig.json", plugins: [plugin], external: ["node-gyp"], @@ -94,10 +89,6 @@ for (const item of targets) { OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined", // FFF_LIBC selects the fff native lib variant: "musl" or "gnu". FFF_LIBC: item.os === "linux" ? `'${item.abi ?? "gnu"}'` : "undefined", - OTUI_TREE_SITTER_WORKER_PATH: - (item.os === "win32" ? '"B:/~BUN/root/' : '"/$bunfs/root/') + - path.relative(dir, parserWorker).replaceAll("\\", "/") + - '"', ...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}), }, }) diff --git a/packages/cli/script/generate.ts b/packages/cli/script/generate.ts index d98565e298..e162f2ea7e 100755 --- a/packages/cli/script/generate.ts +++ b/packages/cli/script/generate.ts @@ -1,4 +1,4 @@ -const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev" +const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.opencode.ai" export const modelsData = process.env.MODELS_DEV_API_JSON ? await Bun.file(process.env.MODELS_DEV_API_JSON).text() diff --git a/packages/codemode/package.json b/packages/codemode/package.json index 263daa6a53..1641518483 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "1.17.18", + "version": "1.18.11", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 35d31f9342..5023021661 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.17.18", + "version": "1.18.11", "type": "module", "license": "MIT", "scripts": { 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..dd03a553f5 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": "يحصل GPT 5.6 Luna على حدود استخدام مضاعفة لفترة محدودة", "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/شهر، مع حدود استخدام سخية ووصول موثوق إلى نماذج البرمجة الرائدة.", "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", @@ -297,8 +301,7 @@ export const dict = { "go.problem.item1": "أسعار اشتراك منخفضة التكلفة", "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", + "go.problem.item4": "مجموعة منسقة من النماذج المختبرة للبرمجة الوكيلة", "go.how.title": "كيف يعمل Go", "go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.", "go.how.step1.title": "أنشئ حسابًا", @@ -322,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/شهر، مع حدود سخية ووصول موثوق إلى مجموعة منسقة من النماذج.", "go.faq.q4": "كم تكلفة Go؟", "go.faq.a4.p1.beforePricing": "تكلفة Go", "go.faq.a4.p1.pricingLink": "$5 للشهر الأول", @@ -331,8 +334,20 @@ export const dict = { "go.faq.a4.p2.accountLink": "حسابك", "go.faq.a4.p3": "ألغِ في أي وقت.", "go.faq.q5": "ماذا عن البيانات والخصوصية؟", - "go.faq.a5.body": - "تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر. يتبع مزودونا سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج.", + "go.faq.a5.model": "النموذج", + "go.faq.a5.training": "تدريب النموذج", + "go.faq.a5.retention": "الاحتفاظ بالبيانات", + "go.faq.a5.retention30": "30 يومًا", + "go.faq.a5.retention0": "0 أيام", + "go.faq.a5.used": "مستخدَمة", + "go.faq.a5.notUsed": "غير مستخدَمة", + "go.faq.a5.noAgreement": "لا توجد اتفاقية", + "go.faq.a5.grokRetention": + "تعطّل ZDR ميزات API مهمة تعتمد على البيانات المخزنة، بما في ذلك Responses API ذات الحالة، وFiles and Collections، وBatch API.", + "go.faq.a5.gptRetention": + "تُنشأ سجلات مراقبة إساءة الاستخدام لكل استخدام لميزات API، ويُحتفظ بها لمدة تصل إلى 30 يومًا.", + "go.faq.a5.learnMore": "اعرف المزيد", + "go.faq.a5.deepseekRetention": "تُجدَّد اتفاقية ZDR شهريًا. الاتفاقية الحالية سارية حتى 31 أغسطس 2026.", "go.faq.a5.beforeExceptions": "تتم استضافة نماذج Go في الولايات المتحدة. يتبع المزودون سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج، مع", "go.faq.a5.exceptionsLink": "الاستثناءات التالية", @@ -345,7 +360,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 مجموعة منسقة من النماذج مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، وأسبوعية، وشهرية)، تعادل تقريبًا $12 لكل 5 ساعات، و$30 في الأسبوع، و$60 في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).", "zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.", "zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم", @@ -354,6 +369,7 @@ export const dict = { "zen.api.error.providerNotSupported": "المزود {{provider}} غير مدعوم", "zen.api.error.missingApiKey": "مفتاح API مفقود.", "zen.api.error.invalidApiKey": "مفتاح API غير صالح.", + "zen.api.error.requestBlockedByUpstreamProvider": "حظر المزود الخارجي الطلب.", "zen.api.error.subscriptionQuotaExceeded": "تم تجاوز حصة الاشتراك. أعد المحاولة خلال {{retryIn}}.", "zen.api.error.goSubscriptionRollingLimitExceeded": "تم الوصول إلى حد الاستخدام لمدة 5 ساعات. تتم إعادة التعيين خلال {{retryIn}}. لمواصلة استخدام هذا النموذج الآن، فعّل الاستخدام من رصيدك المتاح: {{consoleGoUrl}}", @@ -369,7 +385,7 @@ export const dict = { "لقد وصلت إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{membersUrl}}", "zen.api.error.modelDisabled": "النموذج معطل", "zen.api.error.regionNotAllowed": - "هذا النموذج مستضاف في الصين. إذا كنت ترغب في استخدام هذا النموذج، فعّله في إعداداتك: {{consoleGoUrl}}", + "لا يتوفر أحدث إصدار من هذا النموذج إلا مستضافًا في الصين، ويتطلب تفعيلًا صريحًا: {{consoleGoUrl}}", "zen.api.error.trialEnded": "انتهى العرض المجاني لـ {{model}}. يمكنك مواصلة استخدام النموذج بالاشتراك في OpenCode Go - {{link}}", @@ -659,7 +675,7 @@ export const dict = { "workspace.lite.promo.price": "$5 للشهر الأول", "workspace.lite.promo.modelsTitle": "ما يتضمنه", "workspace.lite.promo.footer": - "تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر. قد تتغير الأسعار وحدود الاستخدام بناءً على تعلمنا من الاستخدام المبكر والملاحظات.", + "صُممت الخطة بشكل أساسي للمستخدمين الدوليين، وتوفر وصولًا عالميًا مستقرًا. قد تتغير الأسعار وحدود الاستخدام بينما نتعلم من الاستخدام المبكر والملاحظات.", "workspace.lite.promo.subscribe": "الاشتراك في Go", "workspace.lite.promo.subscribing": "جارٍ إعادة التوجيه...", "workspace.lite.promo.otherMethods": "طرق دفع أخرى", @@ -700,11 +716,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..dac06753e9 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": "GPT 5.6 Luna 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 uso e acesso confiável aos principais modelos de codificação.", "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", @@ -302,8 +306,7 @@ export const dict = { "go.problem.item1": "Preço de assinatura de baixo custo", "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", + "go.problem.item4": "Uma seleção de modelos testados para codificação com agentes", "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 +332,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 a uma seleção de modelos.", "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", @@ -338,8 +341,21 @@ export const dict = { "go.faq.a4.p2.accountLink": "conta", "go.faq.a4.p3": "Cancele a qualquer momento.", "go.faq.q5": "E sobre dados e privacidade?", - "go.faq.a5.body": - "O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável. Nossos provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelos.", + "go.faq.a5.model": "Modelo", + "go.faq.a5.training": "Treinamento de modelos", + "go.faq.a5.retention": "Retenção de dados", + "go.faq.a5.retention30": "30 dias", + "go.faq.a5.retention0": "0 dias", + "go.faq.a5.used": "Usado", + "go.faq.a5.notUsed": "Não usado", + "go.faq.a5.noAgreement": "Sem acordo", + "go.faq.a5.grokRetention": + "O ZDR desativa recursos importantes da API que dependem de dados armazenados, incluindo a Responses API com estado, Files and Collections e a Batch API.", + "go.faq.a5.gptRetention": + "Logs de monitoramento de abuso são gerados para todo uso de recursos da API e retidos por até 30 dias.", + "go.faq.a5.learnMore": "Saiba mais", + "go.faq.a5.deepseekRetention": + "O acordo de ZDR é renovado mensalmente. O acordo atual é válido até 31 de agosto de 2026.", "go.faq.a5.beforeExceptions": "Os modelos Go são hospedados nos EUA. Os provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelos, com as", "go.faq.a5.exceptionsLink": "seguintes exceções", @@ -353,7 +369,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 oferece uma seleção de modelos 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", @@ -362,6 +378,7 @@ export const dict = { "zen.api.error.providerNotSupported": "Provedor {{provider}} não suportado", "zen.api.error.missingApiKey": "Chave de API ausente.", "zen.api.error.invalidApiKey": "Chave de API inválida.", + "zen.api.error.requestBlockedByUpstreamProvider": "Solicitação bloqueada pelo provedor upstream.", "zen.api.error.subscriptionQuotaExceeded": "Cota de assinatura excedida. Tente novamente em {{retryIn}}.", "zen.api.error.goSubscriptionRollingLimitExceeded": "Limite de uso de 5 horas atingido. Será reiniciado em {{retryIn}}. Para continuar usando este modelo agora, habilite o uso a partir do seu saldo disponível: {{consoleGoUrl}}", @@ -377,7 +394,7 @@ export const dict = { "Você atingiu seu limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{membersUrl}}", "zen.api.error.modelDisabled": "O modelo está desabilitado", "zen.api.error.regionNotAllowed": - "Este modelo está hospedado na China. Se você quiser usar este modelo, ative-o nas suas configurações: {{consoleGoUrl}}", + "A versão mais recente deste modelo está disponível apenas com hospedagem na China e requer adesão explícita: {{consoleGoUrl}}", "zen.api.error.trialEnded": "A promoção gratuita do {{model}} terminou. Você pode continuar usando o modelo assinando o OpenCode Go - {{link}}", @@ -669,7 +686,7 @@ export const dict = { "workspace.lite.promo.price": "$5 no primeiro mês", "workspace.lite.promo.modelsTitle": "O que está incluído", "workspace.lite.promo.footer": - "O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável. Preços e limites de uso podem mudar conforme aprendemos com o uso inicial e feedback.", + "O plano foi desenvolvido principalmente para usuários internacionais e oferece acesso global estável. Os preços e limites de uso podem mudar à medida que aprendemos com o uso inicial e o feedback recebido.", "workspace.lite.promo.subscribe": "Assinar Go", "workspace.lite.promo.subscribing": "Redirecionando...", "workspace.lite.promo.otherMethods": "Outros métodos de pagamento", @@ -711,11 +728,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..4e3d230f90 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": "GPT 5.6 Luna 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 brugsgrænser og pålidelig adgang til førende kodningsmodeller.", "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", @@ -299,8 +303,7 @@ export const dict = { "go.problem.item1": "Lavpris abonnementspriser", "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", + "go.problem.item4": "Et kurateret modeludvalg testet til agentisk kodning", "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 +329,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 et kurateret modeludvalg.", "go.faq.q4": "Hvad koster Go?", "go.faq.a4.p1.beforePricing": "Go koster", "go.faq.a4.p1.pricingLink": "$5 første måned", @@ -335,8 +338,22 @@ export const dict = { "go.faq.a4.p2.accountLink": "konto", "go.faq.a4.p3": "Annuller til enhver tid.", "go.faq.q5": "Hvad med data og privatliv?", - "go.faq.a5.body": - "Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang. Vores udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning.", + "go.faq.a5.model": "Model", + "go.faq.a5.training": "Modeltræning", + "go.faq.a5.retention": "Dataopbevaring", + "go.faq.a5.retention30": "30 dage", + "go.faq.a5.retention0": "0 dage", + "go.faq.a5.used": "Brugt", + "go.faq.a5.notUsed": "Ikke brugt", + "go.faq.a5.noAgreement": "Ingen aftale", + "go.faq.a5.grokRetention": + "ZDR deaktiverer vigtige API-funktioner, der afhænger af lagrede data, herunder den tilstandsbevarende Responses API, Files and Collections og Batch API.", + "go.faq.a5.gptRetention": + "Logfiler til overvågning af misbrug genereres ved al brug af API-funktioner og opbevares i op til 30 dage.", + "go.faq.a5.learnMore": "Læs mere", + "go.faq.a5.deepseekRetention": + "ZDR-aftalen fornyes månedligt. Den nuværende aftale er gyldig til og med 31. august 2026.", + "go.faq.a5.beforeExceptions": "Go-modeller hostes i USA. Udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning, med de", "go.faq.a5.exceptionsLink": "følgende undtagelser", @@ -349,7 +366,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 kampagnemodeller, der er tilgængelige på det pågældende tidspunkt, med en kvote på 200 forespørgsler/dag. Go tilbyder et kurateret modeludvalg med højere forespørgselskvoter håndhævet over rullende perioder (5 timer, ugentligt og månedligt), omtrent svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (det faktiske antal forespørgsler 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", @@ -358,6 +375,7 @@ export const dict = { "zen.api.error.providerNotSupported": "Udbyder {{provider}} understøttes ikke", "zen.api.error.missingApiKey": "Manglende API-nøgle.", "zen.api.error.invalidApiKey": "Ugyldig API-nøgle.", + "zen.api.error.requestBlockedByUpstreamProvider": "Anmodningen blev blokeret af upstream-udbyderen.", "zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igen om {{retryIn}}.", "zen.api.error.goSubscriptionRollingLimitExceeded": "Forbrugsgrænsen for 5 timer er nået. Nulstilles om {{retryIn}}. For at fortsætte med at bruge denne model nu, aktivér forbrug fra din tilgængelige saldo: {{consoleGoUrl}}", @@ -373,7 +391,7 @@ export const dict = { "Du har nået din månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{membersUrl}}", "zen.api.error.modelDisabled": "Modellen er deaktiveret", "zen.api.error.regionNotAllowed": - "Denne model hostes i Kina. Hvis du vil bruge denne model, skal du aktivere den i dine indstillinger: {{consoleGoUrl}}", + "Den nyeste version af denne model er kun tilgængelig som hostet i Kina og kræver, at du aktivt tilvælger den: {{consoleGoUrl}}", "zen.api.error.trialEnded": "Den gratis kampagne for {{model}} er afsluttet. Du kan fortsætte med at bruge modellen ved at abonnere på OpenCode Go - {{link}}", @@ -665,7 +683,7 @@ export const dict = { "workspace.lite.promo.price": "$5 for den første måned", "workspace.lite.promo.modelsTitle": "Hvad er inkluderet", "workspace.lite.promo.footer": - "Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang. Priser og forbrugsgrænser kan ændre sig, efterhånden som vi lærer af tidlig brug og feedback.", + "Planen er primært udviklet til internationale brugere og giver stabil adgang i hele verden. Priser og forbrugsgrænser kan ændre sig, efterhånden som vi lærer af de første brugserfaringer og tilbagemeldinger.", "workspace.lite.promo.subscribe": "Abonner på Go", "workspace.lite.promo.subscribing": "Omdirigerer...", "workspace.lite.promo.otherMethods": "Andre betalingsmetoder", @@ -706,11 +724,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..61184358da 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": "GPT 5.6 Luna 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 deinen ersten Monat, danach $10/Monat, mit großzügigen Nutzungslimits und zuverlässigem Zugang zu führenden Coding-Modellen.", "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", @@ -301,8 +305,7 @@ export const dict = { "go.problem.item1": "Kostengünstiges Abonnement", "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", + "go.problem.item4": "Eine kuratierte, für Agentic Coding getestete Modellauswahl", "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 +331,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 deinen ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu einer kuratierten Modellauswahl.", "go.faq.q4": "Wie viel kostet Go?", "go.faq.a4.p1.beforePricing": "Go kostet", "go.faq.a4.p1.pricingLink": "$5 im ersten Monat", @@ -337,8 +340,21 @@ export const dict = { "go.faq.a4.p2.accountLink": "Konto verwalten", "go.faq.a4.p3": "Jederzeit kündbar.", "go.faq.q5": "Was ist mit Daten und Privatsphäre?", - "go.faq.a5.body": - "Der Plan ist primär für internationale Nutzer konzipiert, mit Modellen gehostet in den USA, der EU und Singapur für stabilen globalen Zugang. Unsere Anbieter verfolgen eine Zero-Retention-Politik und nutzen deine Daten nicht für das Training von Modellen.", + "go.faq.a5.model": "Modell", + "go.faq.a5.training": "Modelltraining", + "go.faq.a5.retention": "Datenaufbewahrung", + "go.faq.a5.retention30": "30 Tage", + "go.faq.a5.retention0": "0 Tage", + "go.faq.a5.used": "Verwendet", + "go.faq.a5.notUsed": "Nicht verwendet", + "go.faq.a5.noAgreement": "Keine Vereinbarung", + "go.faq.a5.grokRetention": + "ZDR deaktiviert wichtige API-Funktionen, die von gespeicherten Daten abhängen, einschließlich der zustandsbehafteten Responses API, Files and Collections und der Batch API.", + "go.faq.a5.gptRetention": + "Für die Nutzung aller API-Funktionen werden Protokolle zur Missbrauchsüberwachung erstellt und bis zu 30 Tage lang aufbewahrt.", + "go.faq.a5.learnMore": "Mehr erfahren", + "go.faq.a5.deepseekRetention": + "Die ZDR-Vereinbarung wird monatlich erneuert. Die aktuelle Vereinbarung gilt bis einschließlich 31. August 2026.", "go.faq.a5.beforeExceptions": "Go-Modelle werden in den USA gehostet. Anbieter verfolgen eine Zero-Retention-Politik und nutzen deine Daten nicht für das Training von Modellen, mit den", "go.faq.a5.exceptionsLink": "folgenden Ausnahmen", @@ -352,7 +368,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 bietet eine kuratierte Modellauswahl 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", @@ -361,6 +377,7 @@ export const dict = { "zen.api.error.providerNotSupported": "Anbieter {{provider}} wird nicht unterstützt", "zen.api.error.missingApiKey": "Fehlender API-Key.", "zen.api.error.invalidApiKey": "Ungültiger API-Key.", + "zen.api.error.requestBlockedByUpstreamProvider": "Anfrage vom vorgelagerten Anbieter blockiert.", "zen.api.error.subscriptionQuotaExceeded": "Abonnement-Quote überschritten. Erneuter Versuch in {{retryIn}}.", "zen.api.error.goSubscriptionRollingLimitExceeded": "5-Stunden-Nutzungslimit erreicht. Wird in {{retryIn}} zurückgesetzt. Um dieses Modell jetzt weiter zu nutzen, aktiviere die Nutzung über dein verfügbares Guthaben: {{consoleGoUrl}}", @@ -376,7 +393,7 @@ export const dict = { "Du hast dein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{membersUrl}}", "zen.api.error.modelDisabled": "Modell ist deaktiviert", "zen.api.error.regionNotAllowed": - "Dieses Modell wird in China gehostet. Wenn du dieses Modell verwenden möchtest, aktiviere es in deinen Einstellungen: {{consoleGoUrl}}", + "Die neueste Version dieses Modells ist nur verfügbar, wenn sie in China gehostet wird, und muss ausdrücklich aktiviert werden: {{consoleGoUrl}}", "zen.api.error.trialEnded": "Die kostenlose Aktion für {{model}} ist beendet. Du kannst das Modell weiterhin nutzen, indem du OpenCode Go abonnierst - {{link}}", @@ -668,7 +685,7 @@ export const dict = { "workspace.lite.promo.price": "$5 im ersten Monat", "workspace.lite.promo.modelsTitle": "Was enthalten ist", "workspace.lite.promo.footer": - "Der Plan wurde hauptsächlich für internationale Nutzer entwickelt, wobei die Modelle in den USA, der EU und Singapur gehostet werden, um einen stabilen weltweiten Zugriff zu gewährleisten. Preise und Nutzungslimits können sich ändern, während wir aus der frühen Nutzung und dem Feedback lernen.", + "Der Plan richtet sich in erster Linie an internationale Nutzer und bietet stabilen weltweiten Zugriff. Preise und Nutzungslimits können sich ändern, wenn wir Erkenntnisse aus der ersten Nutzung und dem Feedback gewinnen.", "workspace.lite.promo.subscribe": "Go abonnieren", "workspace.lite.promo.subscribing": "Leite weiter...", "workspace.lite.promo.otherMethods": "Andere Zahlungsmethoden", @@ -711,11 +728,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..813473c367 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": "GPT 5.6 Luna 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 usage limits and reliable access to leading coding models.", "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.", @@ -264,8 +270,8 @@ export const dict = { "go.graph.freePill": "Big Pickle and free models", "go.graph.go": "Go", "go.graph.label": "Requests per 5 hour", - "go.graph.usageLimits": "Usage limits", "go.graph.tick": "{{n}}x", + "go.graph.usageLimits": "Usage limits", "go.graph.aria": "Requests per 5h: {{free}} vs {{go}}", "go.testimonials.brand.zen": "Zen", @@ -296,8 +302,7 @@ export const dict = { "go.problem.item1": "Low cost subscription pricing", "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", + "go.problem.item4": "A curated model lineup tested for agentic coding", "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 a curated model lineup.", "go.faq.q4": "How much does Go cost?", "go.faq.a4.p1.beforePricing": "Go costs", "go.faq.a4.p1.pricingLink": "$5 first month", @@ -331,8 +336,21 @@ export const dict = { "go.faq.a4.p2.accountLink": "account", "go.faq.a4.p3": "Cancel any time.", "go.faq.q5": "What about data and privacy?", - "go.faq.a5.body": - "The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access. Our providers follow a zero-retention policy and do not use your data for model training.", + "go.faq.a5.model": "Model", + "go.faq.a5.training": "Model training", + "go.faq.a5.retention": "Data retention", + "go.faq.a5.retention30": "30 days", + "go.faq.a5.retention0": "0 days", + "go.faq.a5.used": "Used", + "go.faq.a5.notUsed": "Not used", + "go.faq.a5.noAgreement": "No agreement", + "go.faq.a5.grokRetention": + "ZDR disables important API features that depend on stored data, including the stateful Responses API, Files and Collections, and the Batch API.", + "go.faq.a5.gptRetention": + "Abuse monitoring logs are generated for all API feature usage and retained for up to 30 days.", + "go.faq.a5.deepseekRetention": + "ZDR agreement is renewed monthly. The current agreement is valid through August 31, 2026.", + "go.faq.a5.learnMore": "Learn more", "go.faq.a5.beforeExceptions": "Go models are hosted in the US. Providers follow a zero-retention policy and do not use your data for model training, with the", @@ -346,7 +364,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 offers a curated model lineup 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", @@ -355,6 +373,7 @@ export const dict = { "zen.api.error.providerNotSupported": "Provider {{provider}} not supported", "zen.api.error.missingApiKey": "Missing API key.", "zen.api.error.invalidApiKey": "Invalid API key.", + "zen.api.error.requestBlockedByUpstreamProvider": "Request blocked by upstream provider.", "zen.api.error.subscriptionQuotaExceeded": "Subscription quota exceeded. Retry in {{retryIn}}.", "zen.api.error.goSubscriptionRollingLimitExceeded": "5-hour usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}", @@ -370,7 +389,7 @@ export const dict = { "You have reached your monthly spending limit of ${{amount}}. Manage your limits here: {{membersUrl}}", "zen.api.error.modelDisabled": "Model is disabled", "zen.api.error.regionNotAllowed": - "This model is hosted in China. If you would like to use this model, enable it in your settings: {{consoleGoUrl}}", + "The latest version of this model is only available hosted in China and requires explicit opt in: {{consoleGoUrl}}", "zen.api.error.trialEnded": "Free promotion has ended for {{model}}. You can continue using the model by subscribing to OpenCode Go - {{link}}", @@ -662,7 +681,7 @@ export const dict = { "workspace.lite.promo.price": "$5 for your first month", "workspace.lite.promo.modelsTitle": "What's Included", "workspace.lite.promo.footer": - "The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access. Pricing and usage limits may change as we learn from early usage and feedback.", + "The plan is designed primarily for international users and provides stable global access. Pricing and usage limits may change as we learn from early usage and feedback.", "workspace.lite.promo.subscribe": "Subscribe to Go", "workspace.lite.promo.subscribing": "Redirecting...", "workspace.lite.promo.otherMethods": "Other payment methods", @@ -703,11 +722,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 +791,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..be5c24685c 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": "GPT 5.6 Luna 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 límites de uso generosos y acceso fiable a modelos de programación líderes.", "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", @@ -303,8 +307,7 @@ export const dict = { "go.problem.item1": "Precios de suscripción de bajo coste", "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", + "go.problem.item4": "Una selección de modelos probados para programación agéntica", "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 +332,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 de pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a una selección de modelos.", "go.faq.q4": "¿Cuánto cuesta Go?", "go.faq.a4.p1.beforePricing": "Go cuesta", "go.faq.a4.p1.pricingLink": "$5 el primer mes", @@ -338,8 +341,21 @@ export const dict = { "go.faq.a4.p2.accountLink": "cuenta", "go.faq.a4.p3": "Cancela en cualquier momento.", "go.faq.q5": "¿Qué pasa con los datos y la privacidad?", - "go.faq.a5.body": - "El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., UE y Singapur para un acceso global estable. Nuestros proveedores siguen una política de retención cero y no utilizan tus datos para el entrenamiento de modelos.", + "go.faq.a5.model": "Modelo", + "go.faq.a5.training": "Entrenamiento del modelo", + "go.faq.a5.retention": "Retención de datos", + "go.faq.a5.retention30": "30 días", + "go.faq.a5.retention0": "0 días", + "go.faq.a5.used": "Utilizado", + "go.faq.a5.notUsed": "No utilizado", + "go.faq.a5.noAgreement": "Sin acuerdo", + "go.faq.a5.grokRetention": + "ZDR deshabilita funciones importantes de la API que dependen de datos almacenados, incluidas la Responses API con estado, Files and Collections y la Batch API.", + "go.faq.a5.gptRetention": + "Se generan registros de supervisión de abusos para todo el uso de funciones de la API y se conservan durante un máximo de 30 días.", + "go.faq.a5.learnMore": "Más información", + "go.faq.a5.deepseekRetention": + "El acuerdo de ZDR se renueva mensualmente. El acuerdo actual es válido hasta el 31 de agosto de 2026.", "go.faq.a5.beforeExceptions": "Los modelos de Go están alojados en EE. UU. Los proveedores siguen una política de retención cero y no utilizan tus datos para el entrenamiento de modelos, con las", "go.faq.a5.exceptionsLink": "siguientes excepciones", @@ -353,7 +369,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 y los modelos promocionales disponibles en ese momento, con una cuota de 200 solicitudes/día. Go ofrece una selección de modelos con cuotas de solicitudes más altas aplicadas en ventanas móviles (de 5 horas, semanales y mensuales), aproximadamente equivalentes a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (la cantidad real de solicitudes varía 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", @@ -362,6 +378,7 @@ export const dict = { "zen.api.error.providerNotSupported": "Proveedor {{provider}} no soportado", "zen.api.error.missingApiKey": "Falta la clave API.", "zen.api.error.invalidApiKey": "Clave API inválida.", + "zen.api.error.requestBlockedByUpstreamProvider": "El proveedor externo bloqueó la solicitud.", "zen.api.error.subscriptionQuotaExceeded": "Cuota de suscripción excedida. Reintenta en {{retryIn}}.", "zen.api.error.goSubscriptionRollingLimitExceeded": "Límite de uso de 5 horas alcanzado. Se restablece en {{retryIn}}. Para seguir usando este modelo ahora, habilita el uso desde tu saldo disponible: {{consoleGoUrl}}", @@ -377,7 +394,7 @@ export const dict = { "Has alcanzado tu límite de gasto mensual de ${{amount}}. Gestiona tus límites aquí: {{membersUrl}}", "zen.api.error.modelDisabled": "El modelo está deshabilitado", "zen.api.error.regionNotAllowed": - "Este modelo está alojado en China. Si quieres usar este modelo, actívalo en tu configuración: {{consoleGoUrl}}", + "La versión más reciente de este modelo solo está disponible alojada en China y requiere una aceptación explícita: {{consoleGoUrl}}", "zen.api.error.trialEnded": "La promoción gratuita de {{model}} ha finalizado. Puedes seguir usando el modelo suscribiéndote a OpenCode Go - {{link}}", @@ -669,7 +686,7 @@ export const dict = { "workspace.lite.promo.price": "$5 el primer mes", "workspace.lite.promo.modelsTitle": "Qué incluye", "workspace.lite.promo.footer": - "El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., la UE y Singapur para un acceso global estable. Los precios y los límites de uso pueden cambiar a medida que aprendemos del uso inicial y los comentarios.", + "El plan está diseñado principalmente para usuarios internacionales y ofrece un acceso global estable. Los precios y los límites de uso pueden cambiar a medida que aprendemos del uso inicial y de los comentarios recibidos.", "workspace.lite.promo.subscribe": "Suscribirse a Go", "workspace.lite.promo.subscribing": "Redirigiendo...", "workspace.lite.promo.otherMethods": "Otros métodos de pago", @@ -711,11 +728,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..37bf2d5eb3 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": "GPT 5.6 Luna 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 d'utilisation généreuses et un accès fiable aux principaux modèles de codage.", "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", @@ -303,8 +307,7 @@ export const dict = { "go.problem.item1": "Prix d'abonnement bas", "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", + "go.problem.item4": "Une sélection de modèles testés pour le codage agentique", "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 +333,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 à une sélection de modèles.", "go.faq.q4": "Combien coûte Go ?", "go.faq.a4.p1.beforePricing": "Go coûte", "go.faq.a4.p1.pricingLink": "$5 le premier mois", @@ -339,8 +342,22 @@ export const dict = { "go.faq.a4.p2.accountLink": "compte", "go.faq.a4.p3": "Annulez à tout moment.", "go.faq.q5": "Et pour les données et la confidentialité ?", - "go.faq.a5.body": - "Le plan est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable. Nos fournisseurs suivent une politique de rétention zéro et n'utilisent pas vos données pour l'entraînement des modèles.", + "go.faq.a5.model": "Modèle", + "go.faq.a5.training": "Entraînement des modèles", + "go.faq.a5.retention": "Conservation des données", + "go.faq.a5.retention30": "30 jours", + "go.faq.a5.retention0": "0 jour", + "go.faq.a5.used": "Utilisé", + "go.faq.a5.notUsed": "Non utilisé", + "go.faq.a5.noAgreement": "Aucun accord", + "go.faq.a5.grokRetention": + "Le ZDR désactive d'importantes fonctionnalités API qui dépendent des données stockées, notamment Responses API avec état, Files and Collections et Batch API.", + "go.faq.a5.gptRetention": + "Des journaux de surveillance des abus sont générés pour toute utilisation des fonctionnalités API et conservés pendant un maximum de 30 jours.", + "go.faq.a5.learnMore": "En savoir plus", + "go.faq.a5.deepseekRetention": + "L’accord ZDR est renouvelé chaque mois. L’accord actuel est valable jusqu’au 31 août 2026.", + "go.faq.a5.beforeExceptions": "Les modèles Go sont hébergés aux États-Unis. Les fournisseurs suivent une politique de rétention zéro et n'utilisent pas vos données pour l'entraînement des modèles, avec les", "go.faq.a5.exceptionsLink": "exceptions suivantes", @@ -353,7 +370,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 les modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go propose une sélection de modèles avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalents à 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", @@ -362,6 +379,7 @@ export const dict = { "zen.api.error.providerNotSupported": "Fournisseur {{provider}} non pris en charge", "zen.api.error.missingApiKey": "Clé API manquante.", "zen.api.error.invalidApiKey": "Clé API invalide.", + "zen.api.error.requestBlockedByUpstreamProvider": "Requête bloquée par le fournisseur en amont.", "zen.api.error.subscriptionQuotaExceeded": "Quota d'abonnement dépassé. Réessayez dans {{retryIn}}.", "zen.api.error.goSubscriptionRollingLimitExceeded": "Limite d'utilisation sur 5 heures atteinte. Réinitialisation dans {{retryIn}}. Pour continuer à utiliser ce modèle dès maintenant, activez l'utilisation depuis votre solde disponible : {{consoleGoUrl}}", @@ -377,7 +395,7 @@ export const dict = { "Vous avez atteint votre limite de dépense mensuelle de {{amount}} $. Gérez vos limites ici : {{membersUrl}}", "zen.api.error.modelDisabled": "Le modèle est désactivé", "zen.api.error.regionNotAllowed": - "Ce modèle est hébergé en Chine. Si vous souhaitez utiliser ce modèle, activez-le dans vos paramètres : {{consoleGoUrl}}", + "La dernière version de ce modèle est uniquement disponible avec un hébergement en Chine et nécessite votre consentement explicite : {{consoleGoUrl}}", "zen.api.error.trialEnded": "La promotion gratuite de {{model}} est terminée. Vous pouvez continuer à utiliser le modèle en vous abonnant à OpenCode Go - {{link}}", @@ -675,7 +693,7 @@ export const dict = { "workspace.lite.promo.price": "$5 le premier mois", "workspace.lite.promo.modelsTitle": "Ce qui est inclus", "workspace.lite.promo.footer": - "Le plan est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable. Les tarifs et les limites d'utilisation peuvent changer à mesure que nous apprenons des premières utilisations et des commentaires.", + "Ce forfait est principalement conçu pour les utilisateurs internationaux et offre un accès mondial stable. Les tarifs et les limites d'utilisation peuvent évoluer à mesure que nous tirons les enseignements des premières utilisations et des retours reçus.", "workspace.lite.promo.subscribe": "S'abonner à Go", "workspace.lite.promo.subscribing": "Redirection...", "workspace.lite.promo.otherMethods": "Autres méthodes de paiement", @@ -718,11 +736,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..501d012333 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": "GPT 5.6 Luna 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 limiti di utilizzo generosi e un accesso affidabile ai principali modelli di coding.", "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", @@ -299,8 +303,7 @@ export const dict = { "go.problem.item1": "Prezzo di abbonamento a basso costo", "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", + "go.problem.item4": "Una selezione curata di modelli testati per il coding agentico", "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 +328,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 un accesso affidabile a una selezione curata di modelli.", "go.faq.q4": "Quanto costa Go?", "go.faq.a4.p1.beforePricing": "Go costa", "go.faq.a4.p1.pricingLink": "$5 il primo mese", @@ -334,8 +337,21 @@ export const dict = { "go.faq.a4.p2.accountLink": "account", "go.faq.a4.p3": "Annulla in qualsiasi momento.", "go.faq.q5": "E per quanto riguarda dati e privacy?", - "go.faq.a5.body": - "Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati negli Stati Uniti, UE e Singapore per un accesso globale stabile. I nostri provider seguono una policy di zero-retention e non usano i tuoi dati per l'addestramento dei modelli.", + "go.faq.a5.model": "Modello", + "go.faq.a5.training": "Addestramento del modello", + "go.faq.a5.retention": "Conservazione dei dati", + "go.faq.a5.retention30": "30 giorni", + "go.faq.a5.retention0": "0 giorni", + "go.faq.a5.used": "Utilizzato", + "go.faq.a5.notUsed": "Non utilizzato", + "go.faq.a5.noAgreement": "Nessun accordo", + "go.faq.a5.grokRetention": + "ZDR disabilita importanti funzionalità API che dipendono dai dati archiviati, tra cui la Responses API con stato, Files and Collections e Batch API.", + "go.faq.a5.gptRetention": + "I log di monitoraggio degli abusi vengono generati per l'utilizzo di tutte le funzionalità API e conservati per un massimo di 30 giorni.", + "go.faq.a5.learnMore": "Scopri di più", + "go.faq.a5.deepseekRetention": + "L'accordo ZDR viene rinnovato mensilmente. L'accordo attuale è valido fino al 31 agosto 2026.", "go.faq.a5.beforeExceptions": "I modelli Go sono ospitati negli Stati Uniti. I provider seguono una policy di zero-retention e non usano i tuoi dati per l'addestramento dei modelli, con le", "go.faq.a5.exceptionsLink": "seguenti eccezioni", @@ -349,7 +365,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ù i modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go offre una selezione curata di modelli 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", @@ -358,6 +374,7 @@ export const dict = { "zen.api.error.providerNotSupported": "Provider {{provider}} non supportato", "zen.api.error.missingApiKey": "Chiave API mancante.", "zen.api.error.invalidApiKey": "Chiave API non valida.", + "zen.api.error.requestBlockedByUpstreamProvider": "Richiesta bloccata dal provider upstream.", "zen.api.error.subscriptionQuotaExceeded": "Quota dell'abbonamento superata. Riprova tra {{retryIn}}.", "zen.api.error.goSubscriptionRollingLimitExceeded": "Limite di utilizzo di 5 ore raggiunto. Si reimposta tra {{retryIn}}. Per continuare a usare questo modello ora, abilita l'utilizzo dal tuo saldo disponibile: {{consoleGoUrl}}", @@ -373,7 +390,7 @@ export const dict = { "Hai raggiunto il tuo limite di spesa mensile di ${{amount}}. Gestisci i tuoi limiti qui: {{membersUrl}}", "zen.api.error.modelDisabled": "Il modello è disabilitato", "zen.api.error.regionNotAllowed": - "Questo modello è ospitato in Cina. Se vuoi usare questo modello, abilitalo nelle tue impostazioni: {{consoleGoUrl}}", + "La versione più recente di questo modello è disponibile solo con hosting in Cina e richiede un consenso esplicito: {{consoleGoUrl}}", "zen.api.error.trialEnded": "La promozione gratuita di {{model}} è terminata. Puoi continuare a usare il modello abbonandoti a OpenCode Go - {{link}}", @@ -667,7 +684,7 @@ export const dict = { "workspace.lite.promo.price": "$5 il primo mese", "workspace.lite.promo.modelsTitle": "Cosa è incluso", "workspace.lite.promo.footer": - "Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati in US, EU e Singapore per un accesso globale stabile. I prezzi e i limiti di utilizzo potrebbero cambiare man mano che impariamo dall'utilizzo iniziale e dal feedback.", + "Il piano è pensato principalmente per gli utenti internazionali e offre un accesso globale stabile. I prezzi e i limiti di utilizzo potrebbero cambiare in base a quanto apprenderemo dall'utilizzo iniziale e dai feedback.", "workspace.lite.promo.subscribe": "Abbonati a Go", "workspace.lite.promo.subscribing": "Reindirizzamento...", "workspace.lite.promo.otherMethods": "Altri metodi di pagamento", @@ -709,11 +726,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..e8f8fa5661 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": "GPT 5.6 Lunaの利用上限が期間限定で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/月で、主要なコーディングモデルへのゆとりある利用上限と安定したアクセスを提供します。", "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", @@ -299,8 +303,7 @@ export const dict = { "go.problem.item1": "低価格なサブスクリプション料金", "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を含む", + "go.problem.item4": "エージェント型コーディング向けにテストされた厳選モデルラインナップ", "go.how.title": "Goの仕組み", "go.how.body": "Goは最初の月$5、その後$10/月で始まります。OpenCodeまたは任意のエージェントで使えます。", "go.how.step1.title": "アカウントを作成", @@ -325,7 +328,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/月で、厳選されたモデルラインナップへのゆとりある上限と安定したアクセスを提供します。", "go.faq.q4": "Goの料金は?", "go.faq.a4.p1.beforePricing": "Goは", "go.faq.a4.p1.pricingLink": "最初の月$5", @@ -334,8 +337,19 @@ export const dict = { "go.faq.a4.p2.accountLink": "アカウント", "go.faq.a4.p3": "いつでもキャンセル可能です。", "go.faq.q5": "データとプライバシーは?", - "go.faq.a5.body": - "このプランは主に海外ユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。プロバイダーはゼロ保持ポリシーに従い、お客様のデータをモデルのトレーニングに使用しません。", + "go.faq.a5.model": "モデル", + "go.faq.a5.training": "モデルのトレーニング", + "go.faq.a5.retention": "データ保持", + "go.faq.a5.retention30": "30日", + "go.faq.a5.retention0": "0日", + "go.faq.a5.used": "使用あり", + "go.faq.a5.notUsed": "使用なし", + "go.faq.a5.noAgreement": "契約なし", + "go.faq.a5.grokRetention": + "ZDRでは、保存データに依存する重要なAPI機能(ステートフルなResponses API、Files and Collections、Batch APIなど)が無効になります。", + "go.faq.a5.gptRetention": "不正使用監視ログはすべてのAPI機能の使用時に生成され、最大30日間保持されます。", + "go.faq.a5.learnMore": "詳しく見る", + "go.faq.a5.deepseekRetention": "ZDR契約は毎月更新されます。現在の契約は2026年8月31日まで有効です。", "go.faq.a5.beforeExceptions": "Goのモデルは米国でホストされています。プロバイダーはゼロ保持ポリシーに従い、モデルのトレーニングにデータを使用しません(", "go.faq.a5.exceptionsLink": "以下の例外", @@ -349,7 +363,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では厳選されたモデルラインナップを利用でき、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。", "zen.api.error.rateLimitExceeded": "レート制限を超えました。後でもう一度お試しください。", "zen.api.error.modelNotSupported": "モデル {{model}} はサポートされていません", @@ -358,6 +372,7 @@ export const dict = { "zen.api.error.providerNotSupported": "プロバイダー {{provider}} はサポートされていません", "zen.api.error.missingApiKey": "APIキーがありません。", "zen.api.error.invalidApiKey": "無効なAPIキーです。", + "zen.api.error.requestBlockedByUpstreamProvider": "上流プロバイダーによりリクエストがブロックされました。", "zen.api.error.subscriptionQuotaExceeded": "サブスクリプションの制限を超えました。{{retryIn}} 後に再試行してください。", "zen.api.error.goSubscriptionRollingLimitExceeded": @@ -374,7 +389,7 @@ export const dict = { "月額の利用上限 ${{amount}} に達しました。こちらから上限を管理してください: {{membersUrl}}", "zen.api.error.modelDisabled": "モデルが無効です", "zen.api.error.regionNotAllowed": - "このモデルは中国でホストされています。このモデルを使用したい場合は、設定で有効にしてください: {{consoleGoUrl}}", + "このモデルの最新バージョンは中国でのみホスト提供されており、利用するには明示的なオプトインが必要です: {{consoleGoUrl}}", "zen.api.error.trialEnded": "{{model}} の無料プロモーションは終了しました。OpenCode Go を購読するとモデルを引き続き使用できます - {{link}}", @@ -667,7 +682,7 @@ export const dict = { "workspace.lite.promo.price": "初月$5", "workspace.lite.promo.modelsTitle": "含まれるもの", "workspace.lite.promo.footer": - "このプランは主にグローバルユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。料金と利用制限は、初期の利用状況やフィードバックに基づいて変更される可能性があります。", + "このプランは主に海外のユーザー向けに設計されており、世界中から安定してご利用いただけます。料金と利用上限は、初期の利用状況やフィードバックを踏まえて変更される場合があります。", "workspace.lite.promo.subscribe": "Goを購読する", "workspace.lite.promo.subscribing": "リダイレクト中...", "workspace.lite.promo.otherMethods": "その他の支払い方法", @@ -708,11 +723,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..ab76958985 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": "GPT 5.6 Luna 사용 한도가 한시적으로 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/월로 시작하며, 넉넉한 사용 한도와 주요 코딩 모델에 대한 안정적인 액세스를 제공합니다.", "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", @@ -296,8 +300,7 @@ export const dict = { "go.problem.item1": "저렴한 구독 가격", "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 포함", + "go.problem.item4": "에이전트 코딩용으로 테스트된 엄선된 모델 라인업", "go.how.title": "Go 작동 방식", "go.how.body": "Go는 첫 달 $5, 이후 $10/월로 시작합니다. OpenCode 또는 어떤 에이전트와도 함께 사용할 수 있습니다.", "go.how.step1.title": "계정 생성", @@ -321,7 +324,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/월로 시작하며, 엄선된 모델 라인업에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.", "go.faq.q4": "Go 비용은 얼마인가요?", "go.faq.a4.p1.beforePricing": "Go 비용은", "go.faq.a4.p1.pricingLink": "첫 달 $5", @@ -330,8 +333,19 @@ export const dict = { "go.faq.a4.p2.accountLink": "계정", "go.faq.a4.p3": "언제든지 취소할 수 있습니다.", "go.faq.q5": "데이터와 프라이버시는 어떤가요?", - "go.faq.a5.body": - "이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU, 싱가포르에 모델이 호스팅되어 있습니다. 당사의 제공자들은 데이터 보존 금지 정책을 따르며 모델 학습에 데이터를 사용하지 않습니다.", + "go.faq.a5.model": "모델", + "go.faq.a5.training": "모델 학습", + "go.faq.a5.retention": "데이터 보존", + "go.faq.a5.retention30": "30일", + "go.faq.a5.retention0": "0일", + "go.faq.a5.used": "사용됨", + "go.faq.a5.notUsed": "사용되지 않음", + "go.faq.a5.noAgreement": "합의 없음", + "go.faq.a5.grokRetention": + "ZDR은 저장된 데이터에 의존하는 중요한 API 기능(상태 저장형 Responses API, Files and Collections, Batch API 포함)을 비활성화합니다.", + "go.faq.a5.gptRetention": "모든 API 기능 사용에 대해 악용 모니터링 로그가 생성되며 최대 30일 동안 보존됩니다.", + "go.faq.a5.learnMore": "자세히 알아보기", + "go.faq.a5.deepseekRetention": "ZDR 계약은 매월 갱신됩니다. 현재 계약은 2026년 8월 31일까지 유효합니다.", "go.faq.a5.beforeExceptions": "Go 모델은 미국에서 호스팅됩니다. 제공자들은 데이터 보존 금지 정책을 따르며 모델 학습에 데이터를 사용하지 않습니다. 단,", "go.faq.a5.exceptionsLink": "다음 예외", @@ -344,7 +358,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는 엄선된 모델 라인업을 제공하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).", "zen.api.error.rateLimitExceeded": "속도 제한을 초과했습니다. 나중에 다시 시도해 주세요.", "zen.api.error.modelNotSupported": "{{model}} 모델은 지원되지 않습니다", @@ -353,6 +367,7 @@ export const dict = { "zen.api.error.providerNotSupported": "{{provider}} 제공자는 지원되지 않습니다", "zen.api.error.missingApiKey": "API 키가 누락되었습니다.", "zen.api.error.invalidApiKey": "유효하지 않은 API 키입니다.", + "zen.api.error.requestBlockedByUpstreamProvider": "상위 제공자가 요청을 차단했습니다.", "zen.api.error.subscriptionQuotaExceeded": "구독 할당량을 초과했습니다. {{retryIn}} 후 다시 시도해 주세요.", "zen.api.error.goSubscriptionRollingLimitExceeded": "5시간 사용 한도에 도달했습니다. {{retryIn}} 후 초기화됩니다. 이 모델을 지금 계속 사용하려면 사용 가능한 잔액에서 사용을 활성화하세요: {{consoleGoUrl}}", @@ -368,7 +383,7 @@ export const dict = { "월간 지출 한도인 ${{amount}}에 도달했습니다. 한도 관리를 여기서 하세요: {{membersUrl}}", "zen.api.error.modelDisabled": "모델이 비활성화되었습니다", "zen.api.error.regionNotAllowed": - "이 모델은 중국에서 호스팅됩니다. 이 모델을 사용하려면 설정에서 활성화하세요: {{consoleGoUrl}}", + "이 모델의 최신 버전은 중국에서 호스팅되는 경우에만 사용할 수 있으며, 명시적으로 사용에 동의해야 합니다: {{consoleGoUrl}}", "zen.api.error.trialEnded": "{{model}}의 무료 프로모션이 종료되었습니다. OpenCode Go를 구독하면 모델을 계속 사용할 수 있습니다 - {{link}}", @@ -659,7 +674,7 @@ export const dict = { "workspace.lite.promo.price": "첫 달 $5", "workspace.lite.promo.modelsTitle": "포함 내역", "workspace.lite.promo.footer": - "이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU 및 싱가포르에 모델이 호스팅되어 있습니다. 가격 및 사용 한도는 초기 사용을 통해 학습하고 피드백을 수집함에 따라 변경될 수 있습니다.", + "이 플랜은 주로 해외 사용자를 위해 설계되었으며, 전 세계에서 안정적으로 이용할 수 있습니다. 초기 이용 현황과 피드백을 반영하는 과정에서 가격과 사용 한도가 변경될 수 있습니다.", "workspace.lite.promo.subscribe": "Go 구독하기", "workspace.lite.promo.subscribing": "리디렉션 중...", "workspace.lite.promo.otherMethods": "기타 결제 수단", @@ -700,11 +715,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..5e7e025032 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": "GPT 5.6 Luna 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 bruksgrenser og pålitelig tilgang til ledende kodemodeller.", "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", @@ -299,8 +303,7 @@ export const dict = { "go.problem.item1": "Rimelig abonnementspris", "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", + "go.problem.item4": "Et kuratert modellutvalg testet for agent-koding", "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 +329,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 et kuratert modellutvalg.", "go.faq.q4": "Hva koster Go?", "go.faq.a4.p1.beforePricing": "Go koster", "go.faq.a4.p1.pricingLink": "$5 første måned", @@ -335,8 +338,22 @@ export const dict = { "go.faq.a4.p2.accountLink": "konto", "go.faq.a4.p3": "Avslutt når som helst.", "go.faq.q5": "Hva med data og personvern?", - "go.faq.a5.body": - "Planen er primært designet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang. Våre leverandører følger en policy om null oppbevaring og bruker ikke dataene dine til modelltrening.", + "go.faq.a5.model": "Modell", + "go.faq.a5.training": "Modelltrening", + "go.faq.a5.retention": "Dataoppbevaring", + "go.faq.a5.retention30": "30 dager", + "go.faq.a5.retention0": "0 dager", + "go.faq.a5.used": "Brukes", + "go.faq.a5.notUsed": "Brukes ikke", + "go.faq.a5.noAgreement": "Ingen avtale", + "go.faq.a5.grokRetention": + "ZDR deaktiverer viktige API-funksjoner som er avhengige av lagrede data, inkludert den tilstandsbaserte Responses API, Files and Collections og Batch API.", + "go.faq.a5.gptRetention": + "Logger for overvåking av misbruk genereres for all bruk av API-funksjoner og oppbevares i opptil 30 dager.", + "go.faq.a5.learnMore": "Les mer", + "go.faq.a5.deepseekRetention": + "ZDR-avtalen fornyes månedlig. Den gjeldende avtalen er gyldig til og med 31. august 2026.", + "go.faq.a5.beforeExceptions": "Go-modeller hostes i USA. Leverandører følger en policy om null oppbevaring og bruker ikke dataene dine til modelltrening, med", "go.faq.a5.exceptionsLink": "følgende unntak", @@ -350,7 +367,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 som er tilgjengelige på det tidspunktet, med en kvote på 200 forespørsler/dag. Go tilbyr et kuratert modellutvalg med høyere forespørselskvoter som håndheves 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", @@ -359,6 +376,7 @@ export const dict = { "zen.api.error.providerNotSupported": "Leverandør {{provider}} støttes ikke", "zen.api.error.missingApiKey": "Mangler API-nøkkel.", "zen.api.error.invalidApiKey": "Ugyldig API-nøkkel.", + "zen.api.error.requestBlockedByUpstreamProvider": "Forespørselen ble blokkert av leverandøren.", "zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igjen om {{retryIn}}.", "zen.api.error.goSubscriptionRollingLimitExceeded": "5-timers bruksgrense nådd. Tilbakestilles om {{retryIn}}. For å fortsette å bruke denne modellen nå, aktiver bruk fra din tilgjengelige saldo: {{consoleGoUrl}}", @@ -374,7 +392,7 @@ export const dict = { "Du har nådd din månedlige utgiftsgrense på ${{amount}}. Administrer grensene dine her: {{membersUrl}}", "zen.api.error.modelDisabled": "Modellen er deaktivert", "zen.api.error.regionNotAllowed": - "Denne modellen hostes i Kina. Hvis du vil bruke denne modellen, aktiver den i innstillingene dine: {{consoleGoUrl}}", + "Den nyeste versjonen av denne modellen er bare tilgjengelig som en tjeneste driftet i Kina og krever at du uttrykkelig samtykker: {{consoleGoUrl}}", "zen.api.error.trialEnded": "Den gratis kampanjen for {{model}} er avsluttet. Du kan fortsette å bruke modellen ved å abonnere på OpenCode Go - {{link}}", @@ -666,7 +684,7 @@ export const dict = { "workspace.lite.promo.price": "$5 for den første måneden", "workspace.lite.promo.modelsTitle": "Hva som er inkludert", "workspace.lite.promo.footer": - "Planen er primært designet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang. Priser og bruksgrenser kan endres etter hvert som vi lærer fra tidlig bruk og tilbakemeldinger.", + "Planen er primært utviklet for internasjonale brukere og gir stabil global tilgang. Priser og bruksgrenser kan endres etter hvert som vi lærer av tidlig bruk og tilbakemeldinger.", "workspace.lite.promo.subscribe": "Abonner på Go", "workspace.lite.promo.subscribing": "Omdirigerer...", "workspace.lite.promo.otherMethods": "Andre betalingsmetoder", @@ -707,11 +725,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..2aa536cd82 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": "GPT 5.6 Luna 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 kosztuje $5 za pierwszy miesiąc, a następnie $10/miesiąc, oferując hojne limity użycia i niezawodny dostęp do wiodących modeli do kodowania.", "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", @@ -300,8 +304,7 @@ export const dict = { "go.problem.item1": "Niskokosztowa cena subskrypcji", "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", + "go.problem.item4": "Starannie dobrany zestaw modeli przetestowanych pod kątem kodowania z agentami", "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 +330,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 działa w modelu płatności za użycie, natomiast Go kosztuje $5 za pierwszy miesiąc, a następnie $10/miesiąc, oferując hojne limity i niezawodny dostęp do starannie dobranego zestawu modeli.", "go.faq.q4": "Ile kosztuje Go?", "go.faq.a4.p1.beforePricing": "Go kosztuje", "go.faq.a4.p1.pricingLink": "$5 za pierwszy miesiąc", @@ -336,8 +339,21 @@ export const dict = { "go.faq.a4.p2.accountLink": "koncie", "go.faq.a4.p3": "Anuluj w dowolnym momencie.", "go.faq.q5": "A co z danymi i prywatnością?", - "go.faq.a5.body": - "Plan został zaprojektowany głównie dla użytkowników międzynarodowych, z modelami hostowanymi w USA, UE i Singapurze, aby zapewnić stabilny globalny dostęp. Nasi dostawcy stosują politykę zerowej retencji i nie używają Twoich danych do trenowania modeli.", + "go.faq.a5.model": "Model", + "go.faq.a5.training": "Trenowanie modelu", + "go.faq.a5.retention": "Retencja danych", + "go.faq.a5.retention30": "30 dni", + "go.faq.a5.retention0": "0 dni", + "go.faq.a5.used": "Wykorzystywane", + "go.faq.a5.notUsed": "Niewykorzystywane", + "go.faq.a5.noAgreement": "Brak umowy", + "go.faq.a5.grokRetention": + "ZDR wyłącza ważne funkcje API zależne od przechowywanych danych, w tym stanowy Responses API, Files and Collections oraz Batch API.", + "go.faq.a5.gptRetention": + "Dzienniki monitorowania nadużyć są generowane dla każdego użycia funkcji API i przechowywane przez maksymalnie 30 dni.", + "go.faq.a5.learnMore": "Dowiedz się więcej", + "go.faq.a5.deepseekRetention": "Umowa ZDR jest odnawiana co miesiąc. Obecna umowa obowiązuje do 31 sierpnia 2026 r.", + "go.faq.a5.beforeExceptions": "Modele Go są hostowane w USA. Dostawcy stosują politykę zerowej retencji i nie używają Twoich danych do trenowania modeli, z", "go.faq.a5.exceptionsLink": "następującymi wyjątkami", @@ -351,7 +367,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 oferuje starannie dobrany zestaw modeli z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), odpowiadającymi w przybliżeniu $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", @@ -360,6 +376,7 @@ export const dict = { "zen.api.error.providerNotSupported": "Dostawca {{provider}} nie jest obsługiwany", "zen.api.error.missingApiKey": "Brak klucza API.", "zen.api.error.invalidApiKey": "Nieprawidłowy klucz API.", + "zen.api.error.requestBlockedByUpstreamProvider": "Żądanie zablokowane przez dostawcę zewnętrznego.", "zen.api.error.subscriptionQuotaExceeded": "Przekroczono limit subskrypcji. Spróbuj ponownie za {{retryIn}}.", "zen.api.error.goSubscriptionRollingLimitExceeded": "Osiągnięto 5-godzinny limit użycia. Resetuje się za {{retryIn}}. Aby nadal korzystać z tego modelu, włącz użycie z dostępnego salda: {{consoleGoUrl}}", @@ -375,7 +392,7 @@ export const dict = { "Osiągnąłeś swój miesięczny limit wydatków w wysokości ${{amount}}. Zarządzaj swoimi limitami tutaj: {{membersUrl}}", "zen.api.error.modelDisabled": "Model jest wyłączony", "zen.api.error.regionNotAllowed": - "Ten model jest hostowany w Chinach. Jeśli chcesz korzystać z tego modelu, włącz go w swoich ustawieniach: {{consoleGoUrl}}", + "Najnowsza wersja tego modelu jest dostępna wyłącznie jako usługa hostowana w Chinach i wymaga jawnej zgody użytkownika: {{consoleGoUrl}}", "zen.api.error.trialEnded": "Bezpłatna promocja {{model}} dobiegła końca. Możesz dalej korzystać z modelu, subskrybując OpenCode Go - {{link}}", @@ -667,7 +684,7 @@ export const dict = { "workspace.lite.promo.price": "$5 za pierwszy miesiąc", "workspace.lite.promo.modelsTitle": "Co zawiera", "workspace.lite.promo.footer": - "Plan został zaprojektowany głównie dla użytkowników międzynarodowych, z modelami hostowanymi w USA, UE i Singapurze, aby zapewnić stabilny globalny dostęp. Ceny i limity użycia mogą ulec zmianie w miarę analizy wczesnego użycia i zbierania opinii.", + "Plan został opracowany przede wszystkim z myślą o użytkownikach z całego świata i zapewnia stabilny globalny dostęp. Ceny i limity użycia mogą ulec zmianie w miarę zdobywania doświadczeń na podstawie początkowego korzystania z usługi i otrzymywanych opinii.", "workspace.lite.promo.subscribe": "Subskrybuj Go", "workspace.lite.promo.subscribing": "Przekierowywanie...", "workspace.lite.promo.otherMethods": "Inne metody płatności", @@ -708,11 +725,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..330713cd19 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": "GPT 5.6 Luna получает 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/месяц и предлагает щедрые лимиты использования и надежный доступ к ведущим моделям для кодинга.", "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", @@ -304,8 +308,7 @@ export const dict = { "go.problem.item1": "Недорогая подписка", "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", + "go.problem.item4": "Отобранные модели, протестированные для агентного программирования", "go.how.title": "Как работает Go", "go.how.body": "Go начинается с $5 за первый месяц, затем $10/месяц. Вы можете использовать его с OpenCode или любым агентом.", @@ -331,7 +334,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/месяц и предлагает щедрые лимиты и надежный доступ к отобранным моделям.", "go.faq.q4": "Сколько стоит Go?", "go.faq.a4.p1.beforePricing": "Go стоит", "go.faq.a4.p1.pricingLink": "$5 за первый месяц", @@ -340,8 +343,22 @@ export const dict = { "go.faq.a4.p2.accountLink": "аккаунте", "go.faq.a4.p3": "Отмена в любое время.", "go.faq.q5": "Как насчет данных и приватности?", - "go.faq.a5.body": - "План разработан в первую очередь для международных пользователей, с моделями, размещенными в США, ЕС и Сингапуре для стабильного глобального доступа. Наши провайдеры следуют политике нулевого хранения и не используют ваши данные для обучения моделей.", + "go.faq.a5.model": "Модель", + "go.faq.a5.training": "Обучение моделей", + "go.faq.a5.retention": "Хранение данных", + "go.faq.a5.retention30": "30 дней", + "go.faq.a5.retention0": "0 дней", + "go.faq.a5.used": "Используется", + "go.faq.a5.notUsed": "Не используется", + "go.faq.a5.noAgreement": "Нет соглашения", + "go.faq.a5.grokRetention": + "ZDR отключает важные функции API, зависящие от сохраненных данных, включая Responses API с сохранением состояния, Files and Collections и Batch API.", + "go.faq.a5.gptRetention": + "Журналы мониторинга злоупотреблений создаются при любом использовании функций API и хранятся до 30 дней.", + "go.faq.a5.learnMore": "Подробнее", + "go.faq.a5.deepseekRetention": + "Соглашение ZDR продлевается ежемесячно. Текущее соглашение действует до 31 августа 2026 года.", + "go.faq.a5.beforeExceptions": "Модели Go размещены в США. Провайдеры следуют политике нулевого хранения и не используют ваши данные для обучения моделей, за", "go.faq.a5.exceptionsLink": "следующими исключениями", @@ -355,7 +372,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 предлагает набор отобранных моделей с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).", "zen.api.error.rateLimitExceeded": "Превышен лимит запросов. Пожалуйста, попробуйте позже.", "zen.api.error.modelNotSupported": "Модель {{model}} не поддерживается", @@ -364,6 +381,7 @@ export const dict = { "zen.api.error.providerNotSupported": "Провайдер {{provider}} не поддерживается", "zen.api.error.missingApiKey": "Отсутствует API ключ.", "zen.api.error.invalidApiKey": "Неверный API ключ.", + "zen.api.error.requestBlockedByUpstreamProvider": "Запрос заблокирован вышестоящим провайдером.", "zen.api.error.subscriptionQuotaExceeded": "Квота подписки превышена. Повторите попытку через {{retryIn}}.", "zen.api.error.goSubscriptionRollingLimitExceeded": "Достигнут лимит использования за 5 часов. Сбросится через {{retryIn}}. Чтобы продолжить использовать эту модель сейчас, включите оплату с доступного баланса: {{consoleGoUrl}}", @@ -379,7 +397,7 @@ export const dict = { "Вы достигли ежемесячного лимита расходов в ${{amount}}. Управляйте лимитами здесь: {{membersUrl}}", "zen.api.error.modelDisabled": "Модель отключена", "zen.api.error.regionNotAllowed": - "Эта модель размещена в Китае. Если вы хотите использовать эту модель, включите её в настройках: {{consoleGoUrl}}", + "Последняя версия этой модели размещена только в Китае. Чтобы использовать её, необходимо явно подтвердить согласие: {{consoleGoUrl}}", "zen.api.error.trialEnded": "Бесплатная акция для {{model}} завершена. Вы можете продолжить использование модели, подписавшись на OpenCode Go - {{link}}", @@ -673,7 +691,7 @@ export const dict = { "workspace.lite.promo.price": "$5 за первый месяц", "workspace.lite.promo.modelsTitle": "Что включено", "workspace.lite.promo.footer": - "План предназначен в первую очередь для международных пользователей. Модели размещены в США, ЕС и Сингапуре для стабильного глобального доступа. Цены и лимиты использования могут меняться по мере того, как мы изучаем раннее использование и собираем отзывы.", + "План предназначен в первую очередь для пользователей по всему миру и обеспечивает стабильный глобальный доступ. Цены и лимиты использования могут меняться по мере изучения первых результатов использования и отзывов.", "workspace.lite.promo.subscribe": "Подписаться на Go", "workspace.lite.promo.subscribing": "Перенаправление...", "workspace.lite.promo.otherMethods": "Другие способы оплаты", @@ -716,11 +734,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..d3ff91e97a 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": "GPT 5.6 Luna เพิ่มโควตาการใช้งานเป็น 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/เดือน พร้อมขีดจำกัดการใช้งานที่เอื้อเฟื้อและการเข้าถึงโมเดลเขียนโค้ดชั้นนำอย่างเชื่อถือได้", "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", @@ -297,8 +301,7 @@ export const dict = { "go.problem.item1": "ราคาการสมัครสมาชิกที่ต่ำ", "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", + "go.problem.item4": "ชุดโมเดลที่คัดสรรและผ่านการทดสอบสำหรับการเขียนโค้ดแบบเอเจนต์", "go.how.title": "Go ทำงานอย่างไร", "go.how.body": "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน คุณสามารถใช้กับ OpenCode หรือเอเจนต์ใดก็ได้", "go.how.step1.title": "สร้างบัญชี", @@ -323,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/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงชุดโมเดลที่คัดสรรอย่างเชื่อถือได้", "go.faq.q4": "Go ราคาเท่าไหร่?", "go.faq.a4.p1.beforePricing": "Go ราคา", "go.faq.a4.p1.pricingLink": "$5 เดือนแรก", @@ -332,8 +335,21 @@ export const dict = { "go.faq.a4.p2.accountLink": "บัญชีของคุณ", "go.faq.a4.p3": "ยกเลิกได้ตลอดเวลา", "go.faq.q5": "แล้วเรื่องข้อมูลและความเป็นส่วนตัวล่ะ?", - "go.faq.a5.body": - "แผนนี้ออกแบบมาเพื่อผู้ใช้งานระหว่างประเทศเป็นหลัก โดยมีโมเดลโฮสต์ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงทั่วโลกที่เสถียร ผู้ให้บริการของเราปฏิบัติตามนโยบายไม่เก็บรักษาข้อมูลและไม่ใช้ข้อมูลของคุณสำหรับการฝึกโมเดล", + "go.faq.a5.model": "โมเดล", + "go.faq.a5.training": "การฝึกโมเดล", + "go.faq.a5.retention": "การเก็บรักษาข้อมูล", + "go.faq.a5.retention30": "30 วัน", + "go.faq.a5.retention0": "0 วัน", + "go.faq.a5.used": "นำไปใช้", + "go.faq.a5.notUsed": "ไม่นำไปใช้", + "go.faq.a5.noAgreement": "ไม่มีข้อตกลง", + "go.faq.a5.grokRetention": + "ZDR ปิดใช้งานฟีเจอร์ API สำคัญที่ต้องอาศัยข้อมูลที่จัดเก็บไว้ ซึ่งรวมถึง Responses API แบบมีสถานะ, Files and Collections และ Batch API", + "go.faq.a5.gptRetention": + "ระบบจะสร้างบันทึกการตรวจสอบการใช้งานในทางที่ผิดสำหรับการใช้งานฟีเจอร์ API ทั้งหมด และเก็บรักษาไว้นานสูงสุด 30 วัน", + "go.faq.a5.learnMore": "ดูข้อมูลเพิ่มเติม", + "go.faq.a5.deepseekRetention": "ข้อตกลง ZDR จะต่ออายุทุกเดือน ข้อตกลงปัจจุบันมีผลใช้ถึงวันที่ 31 สิงหาคม 2026", + "go.faq.a5.beforeExceptions": "โมเดล Go โฮสต์ในสหรัฐอเมริกา ผู้ให้บริการปฏิบัติตามนโยบายไม่เก็บรักษาข้อมูล (zero-retention policy) และไม่ใช้ข้อมูลของคุณสำหรับการฝึกโมเดล โดยมี", "go.faq.a5.exceptionsLink": "ข้อยกเว้นดังนี้", @@ -346,7 +362,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 นำเสนอชุดโมเดลที่คัดสรร พร้อมโควตาคำขอที่สูงกว่าซึ่งบังคับใช้ตามกรอบเวลาแบบต่อเนื่อง (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงแตกต่างกันไปตามโมเดลและการใช้งาน)", "zen.api.error.rateLimitExceeded": "เกินขีดจำกัดอัตราการใช้งาน กรุณาลองใหม่ในภายหลัง", "zen.api.error.modelNotSupported": "ไม่รองรับโมเดล {{model}}", @@ -355,6 +371,7 @@ export const dict = { "zen.api.error.providerNotSupported": "ไม่รองรับผู้ให้บริการ {{provider}}", "zen.api.error.missingApiKey": "ไม่มี API key", "zen.api.error.invalidApiKey": "API key ไม่ถูกต้อง", + "zen.api.error.requestBlockedByUpstreamProvider": "คำขอถูกบล็อกโดยผู้ให้บริการต้นทาง", "zen.api.error.subscriptionQuotaExceeded": "โควต้าการสมัครสมาชิกเกินขีดจำกัด ลองใหม่ในอีก {{retryIn}}", "zen.api.error.goSubscriptionRollingLimitExceeded": "ถึงขีดจำกัดการใช้งานในรอบ 5 ชั่วโมงแล้ว จะรีเซ็ตในอีก {{retryIn}} หากต้องการใช้โมเดลนี้ต่อทันที ให้เปิดใช้งานจากยอดเงินคงเหลือของคุณ: {{consoleGoUrl}}", @@ -370,7 +387,7 @@ export const dict = { "คุณถึงขีดจำกัดการใช้จ่ายรายเดือนที่ ${{amount}} แล้ว จัดการขีดจำกัดของคุณที่นี่: {{membersUrl}}", "zen.api.error.modelDisabled": "โมเดลถูกปิดใช้งาน", "zen.api.error.regionNotAllowed": - "โมเดลนี้โฮสต์อยู่ในประเทศจีน หากคุณต้องการใช้โมเดลนี้ ให้เปิดใช้งานในการตั้งค่าของคุณ: {{consoleGoUrl}}", + "โมเดลเวอร์ชันล่าสุดนี้ให้บริการเฉพาะผ่านระบบที่โฮสต์ในประเทศจีน และต้องให้ความยินยอมอย่างชัดแจ้งก่อนใช้งาน: {{consoleGoUrl}}", "zen.api.error.trialEnded": "โปรโมชันฟรีสำหรับ {{model}} สิ้นสุดแล้ว คุณสามารถใช้โมเดลต่อได้โดยสมัครสมาชิก OpenCode Go - {{link}}", @@ -662,7 +679,7 @@ export const dict = { "workspace.lite.promo.price": "$5 สำหรับเดือนแรก", "workspace.lite.promo.modelsTitle": "สิ่งที่รวมอยู่ด้วย", "workspace.lite.promo.footer": - "แผนนี้ออกแบบมาสำหรับผู้ใช้งานต่างประเทศเป็นหลัก โดยมีโมเดลโฮสต์อยู่ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงที่เสถียรทั่วโลก ราคาและขีดจำกัดการใช้งานอาจมีการเปลี่ยนแปลงตามที่เราได้เรียนรู้จากการใช้งานในช่วงแรกและข้อเสนอแนะ", + "แผนนี้ออกแบบมาสำหรับผู้ใช้งานต่างประเทศเป็นหลักและให้การเข้าถึงที่เสถียรทั่วโลก ราคาและขีดจำกัดการใช้งานอาจเปลี่ยนแปลงได้ตามสิ่งที่เราเรียนรู้จากการใช้งานและข้อเสนอแนะในช่วงแรก", "workspace.lite.promo.subscribe": "สมัครสมาชิก Go", "workspace.lite.promo.subscribing": "กำลังเปลี่ยนเส้นทาง...", "workspace.lite.promo.otherMethods": "วิธีการชำระเงินอื่นๆ", @@ -703,11 +720,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..5d0d2819dd 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": "GPT 5.6 Luna 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; cömert kullanım limitleri ve önde gelen kodlama modellerine güvenilir erişim 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", @@ -302,8 +306,7 @@ export const dict = { "go.problem.item1": "Düşük maliyetli abonelik fiyatlandırması", "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", + "go.problem.item4": "Ajan tabanlı kodlama için test edilmiş, özenle seçilmiş model seçenekleri", "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 +332,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 ve özenle seçilmiş model seçeneklerine cömert limitlerle 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", @@ -338,8 +341,22 @@ export const dict = { "go.faq.a4.p2.accountLink": "hesabınızdan", "go.faq.a4.p3": "yönetebilirsiniz. İstediğiniz zaman iptal edin.", "go.faq.q5": "Veri ve gizlilik ne olacak?", - "go.faq.a5.body": - "Bu plan öncelikle uluslararası kullanıcılar için tasarlanmış olup, istikrarlı küresel erişim için modeller ABD, AB ve Singapur'da barındırılmaktadır. Sağlayıcılarımız sıfır saklama politikası izler ve verilerinizi model eğitimi için kullanmaz.", + "go.faq.a5.model": "Model", + "go.faq.a5.training": "Model eğitimi", + "go.faq.a5.retention": "Veri saklama", + "go.faq.a5.retention30": "30 gün", + "go.faq.a5.retention0": "0 gün", + "go.faq.a5.used": "Kullanılır", + "go.faq.a5.notUsed": "Kullanılmaz", + "go.faq.a5.noAgreement": "Anlaşma yok", + "go.faq.a5.grokRetention": + "ZDR, durum bilgisi tutan Responses API, Files and Collections ve Batch API dahil olmak üzere saklanan verilere bağlı önemli API özelliklerini devre dışı bırakır.", + "go.faq.a5.gptRetention": + "Tüm API özelliklerinin kullanımı için kötüye kullanım izleme günlükleri oluşturulur ve 30 güne kadar saklanır.", + "go.faq.a5.learnMore": "Daha fazla bilgi", + "go.faq.a5.deepseekRetention": + "ZDR anlaşması aylık olarak yenilenir. Mevcut anlaşma 31 Ağustos 2026 tarihine kadar geçerlidir.", + "go.faq.a5.beforeExceptions": "Go modelleri ABD'de barındırılmaktadır. Sağlayıcılar sıfır saklama politikası izler ve verilerinizi model eğitimi için kullanmaz; şu", "go.faq.a5.exceptionsLink": "aşağıdaki istisnalar", @@ -353,7 +370,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ıyla Big Pickle'ı ve o sırada mevcut olan promosyonel modelleri içerir. Go ise kayan zaman aralıklarında (5 saatlik, haftalık ve aylık) uygulanan daha yüksek istek kotalarıyla özenle seçilmiş model seçenekleri sunar. 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", @@ -362,6 +379,7 @@ export const dict = { "zen.api.error.providerNotSupported": "{{provider}} sağlayıcısı desteklenmiyor", "zen.api.error.missingApiKey": "API anahtarı eksik.", "zen.api.error.invalidApiKey": "Geçersiz API anahtarı.", + "zen.api.error.requestBlockedByUpstreamProvider": "İstek üst sağlayıcı tarafından engellendi.", "zen.api.error.subscriptionQuotaExceeded": "Abonelik kotası aşıldı. {{retryIn}} içinde tekrar deneyin.", "zen.api.error.goSubscriptionRollingLimitExceeded": "5 saatlik kullanım limitine ulaşıldı. {{retryIn}} içinde sıfırlanır. Bu modeli şimdi kullanmaya devam etmek için kullanılabilir bakiyenizden kullanımı etkinleştirin: {{consoleGoUrl}}", @@ -377,7 +395,7 @@ export const dict = { "Aylık ${{amount}} harcama limitinize ulaştınız. Limitlerinizi buradan yönetin: {{membersUrl}}", "zen.api.error.modelDisabled": "Model devre dışı", "zen.api.error.regionNotAllowed": - "Bu model Çin'de barındırılıyor. Bu modeli kullanmak istiyorsanız ayarlarınızdan etkinleştirin: {{consoleGoUrl}}", + "Bu modelin en son sürümü yalnızca Çin'de barındırılıyor ve kullanabilmek için açıkça onay vermeniz gerekiyor: {{consoleGoUrl}}", "zen.api.error.trialEnded": "{{model}} için ücretsiz promosyon sona erdi. OpenCode Go'ya abone olarak modeli kullanmaya devam edebilirsiniz - {{link}}", @@ -669,7 +687,7 @@ export const dict = { "workspace.lite.promo.price": "İlk ay $5", "workspace.lite.promo.modelsTitle": "Neler Dahil", "workspace.lite.promo.footer": - "Plan öncelikle uluslararası kullanıcılar için tasarlanmıştır; modeller istikrarlı küresel erişim için ABD, AB ve Singapur'da barındırılmaktadır. Erken kullanımdan öğrendikçe ve geri bildirim topladıkça fiyatlandırma ve kullanım limitleri değişebilir.", + "Plan öncelikle uluslararası kullanıcılar için tasarlanmıştır ve istikrarlı küresel erişim sağlar. Erken kullanım ve geri bildirimlerden öğrendiklerimiz doğrultusunda fiyatlandırma ve kullanım limitleri değişebilir.", "workspace.lite.promo.subscribe": "Go'ya Abone Ol", "workspace.lite.promo.subscribing": "Yönlendiriliyor...", "workspace.lite.promo.otherMethods": "Diğer ödeme yöntemleri", @@ -711,11 +729,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..48a0dbe787 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": "GPT 5.6 Luna отримує 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/місяць, зі щедрими лімітами використання та надійним доступом до провідних моделей для кодування.", "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", @@ -300,8 +304,7 @@ export const dict = { "go.problem.item1": "Недорога підписка", "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", + "go.problem.item4": "Добірка моделей, протестованих для агентного кодування", "go.how.title": "Як працює Go", "go.how.body": "Go починається від $5 за перший місяць, потім $10/місяць. Використовуйте з OpenCode або будь-яким агентом.", @@ -327,7 +330,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/місяць, із щедрими лімітами та надійним доступом до добірки моделей.", "go.faq.q4": "Скільки коштує Go?", "go.faq.a4.p1.beforePricing": "Go коштує", "go.faq.a4.p1.pricingLink": "$5 за перший місяць", @@ -336,8 +339,21 @@ export const dict = { "go.faq.a4.p2.accountLink": "обліковому записі", "go.faq.a4.p3": "Скасуйте в будь-який час.", "go.faq.q5": "А як щодо даних та конфіденційності?", - "go.faq.a5.body": - "План розроблений переважно для міжнародних користувачів, з моделями в США, ЄС та Сінгапурі. Провайдери дотримуються політики нульового зберігання.", + "go.faq.a5.model": "Модель", + "go.faq.a5.training": "Навчання моделей", + "go.faq.a5.retention": "Зберігання даних", + "go.faq.a5.retention30": "30 днів", + "go.faq.a5.retention0": "0 днів", + "go.faq.a5.used": "Використовується", + "go.faq.a5.notUsed": "Не використовується", + "go.faq.a5.noAgreement": "Немає угоди", + "go.faq.a5.grokRetention": + "ZDR вимикає важливі функції API, які залежать від збережених даних, зокрема Responses API зі збереженням стану, Files and Collections та Batch API.", + "go.faq.a5.gptRetention": + "Журнали моніторингу зловживань створюються для всіх випадків використання функцій API та зберігаються до 30 днів.", + "go.faq.a5.learnMore": "Докладніше", + "go.faq.a5.deepseekRetention": "Угода ZDR поновлюється щомісяця. Поточна угода дійсна до 31 серпня 2026 року.", + "go.faq.a5.beforeExceptions": "Моделі Go розміщені в США. Провайдери дотримуються політики нульового зберігання та не використовують ваші дані для навчання моделей, за", "go.faq.a5.exceptionsLink": "такими винятками", @@ -350,7 +366,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 пропонує добірку моделей із вищими квотами запитів, що застосовуються протягом ковзних періодів (5 годин, тижня та місяця), приблизно еквівалентними $12 за 5 годин, $30 на тиждень і $60 на місяць (фактична кількість запитів залежить від моделі та використання).", "zen.api.error.rateLimitExceeded": "Перевищено ліміт запитів. Спробуйте пізніше.", "zen.api.error.modelNotSupported": "Модель {{model}} не підтримується", @@ -359,6 +375,7 @@ export const dict = { "zen.api.error.providerNotSupported": "Провайдер {{provider}} не підтримується", "zen.api.error.missingApiKey": "Відсутній ключ API.", "zen.api.error.invalidApiKey": "Недійсний ключ API.", + "zen.api.error.requestBlockedByUpstreamProvider": "Запит заблоковано зовнішнім провайдером.", "zen.api.error.subscriptionQuotaExceeded": "Перевищено квоту підписки. Повторіть через {{retryIn}}.", "zen.api.error.goSubscriptionRollingLimitExceeded": "Досягнуто 5-годинного ліміту використання. Скидається через {{retryIn}}. Щоб продовжити, увімкніть використання з доступного балансу: {{consoleGoUrl}}", @@ -374,7 +391,7 @@ export const dict = { "Ви досягли місячного ліміту витрат ${{amount}}. Керуйте лімітами: {{membersUrl}}", "zen.api.error.modelDisabled": "Модель вимкнено", "zen.api.error.regionNotAllowed": - "Ця модель розміщена в Китаї. Якщо ви хочете використовувати цю модель, увімкніть її в налаштуваннях: {{consoleGoUrl}}", + "Остання версія цієї моделі доступна лише на серверах у Китаї, і для її використання потрібно надати явну згоду: {{consoleGoUrl}}", "zen.api.error.trialEnded": "Безкоштовна акція для {{model}} закінчилася. Ви можете продовжити використання, підписавшись на OpenCode Go — {{link}}", @@ -662,7 +679,8 @@ export const dict = { "workspace.lite.promo.description": "OpenCode Go починається від {{price}}, потім $10/місяць, із щедрими лімітами.", "workspace.lite.promo.price": "$5 за перший місяць", "workspace.lite.promo.modelsTitle": "Що включено", - "workspace.lite.promo.footer": "План призначений для міжнародних користувачів. Ціни можуть змінюватися.", + "workspace.lite.promo.footer": + "План призначений насамперед для міжнародних користувачів і забезпечує стабільний глобальний доступ. Ціни та ліміти використання можуть змінюватися з урахуванням перших даних про використання та відгуків.", "workspace.lite.promo.subscribe": "Підписатися на Go", "workspace.lite.promo.subscribing": "Перенаправлення...", "workspace.lite.promo.otherMethods": "Інші способи оплати", @@ -670,11 +688,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..78f2081aeb 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,8 @@ export const dict = { "zen.privacy.exceptionsLink": "以下例外情况除外", "go.title": "OpenCode Go | 人人可用的低成本编程模型", - "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.banner.text": "GPT 5.6 Luna 限时享受 2 倍使用额度", + "go.meta.description": "Go 首月 $5,之后 $10/月,提供充裕的使用限额,并可可靠访问领先的编程模型。", "go.hero.title": "人人可用的低成本编程模型", "go.hero.body": "Go 将代理编程带给全世界的程序员。提供充裕的限额和对最强大的开源模型的可靠访问,让您可以利用强大的代理进行构建,而无需担心成本或可用性。", @@ -257,7 +261,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", @@ -288,8 +291,7 @@ export const dict = { "go.problem.item1": "低成本订阅定价", "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", + "go.problem.item4": "经过代理编程测试的精选模型阵容", "go.how.title": "Go 如何工作", "go.how.body": "Go 起价为首月 $5,之后 $10/月。您可以将其与 OpenCode 或任何代理搭配使用。", "go.how.step1.title": "创建账户", @@ -310,8 +312,7 @@ export const dict = { "go.faq.q2": "Go 包含哪些模型?", "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 等开源模型。", + "go.faq.a3": "不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的限额,并可可靠访问精选模型阵容。", "go.faq.q4": "Go 多少钱?", "go.faq.a4.p1.beforePricing": "Go 费用为", "go.faq.a4.p1.pricingLink": "首月 $5", @@ -320,8 +321,19 @@ export const dict = { "go.faq.a4.p2.accountLink": "账户", "go.faq.a4.p3": "中管理订阅。随时取消。", "go.faq.q5": "数据和隐私如何?", - "go.faq.a5.body": - "该计划主要面向国际用户设计,模型部署在美国、欧盟和新加坡,以确保稳定的全球访问。我们的提供商遵循零留存政策,不使用您的数据进行模型训练。", + "go.faq.a5.model": "模型", + "go.faq.a5.training": "模型训练", + "go.faq.a5.retention": "数据留存", + "go.faq.a5.retention30": "30 天", + "go.faq.a5.retention0": "0 天", + "go.faq.a5.used": "使用", + "go.faq.a5.notUsed": "不使用", + "go.faq.a5.noAgreement": "无协议", + "go.faq.a5.grokRetention": + "ZDR 会禁用依赖所存储数据的重要 API 功能,包括有状态的 Responses API、Files and Collections 和 Batch API。", + "go.faq.a5.gptRetention": "所有 API 功能的使用都会生成滥用监控日志,并最多保留 30 天。", + "go.faq.a5.learnMore": "了解更多", + "go.faq.a5.deepseekRetention": "ZDR 协议每月续签。当前协议有效期至 2026 年 8 月 31 日。", "go.faq.a5.beforeExceptions": "Go 模型托管在美国。提供商遵循零留存政策,不使用您的数据进行模型训练,", "go.faq.a5.exceptionsLink": "以下例外情况除外", "go.faq.q6": "我可以充值余额吗?", @@ -333,7 +345,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 提供精选模型阵容,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。", "zen.api.error.rateLimitExceeded": "超出速率限制。请稍后重试。", "zen.api.error.modelNotSupported": "不支持模型 {{model}}", @@ -342,6 +354,7 @@ export const dict = { "zen.api.error.providerNotSupported": "不支持提供商 {{provider}}", "zen.api.error.missingApiKey": "缺少 API 密钥。", "zen.api.error.invalidApiKey": "无效的 API 密钥。", + "zen.api.error.requestBlockedByUpstreamProvider": "请求被上游提供商阻止。", "zen.api.error.subscriptionQuotaExceeded": "超出订阅配额。请在 {{retryIn}} 后重试。", "zen.api.error.goSubscriptionRollingLimitExceeded": "已达到 5 小时使用限额。将在 {{retryIn}} 后重置。如需立即继续使用该模型,请启用从可用余额扣费:{{consoleGoUrl}}", @@ -355,7 +368,7 @@ export const dict = { "您的工作区已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{billingUrl}}", "zen.api.error.userMonthlyLimitReached": "您已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{membersUrl}}", "zen.api.error.modelDisabled": "模型已禁用", - "zen.api.error.regionNotAllowed": "该模型部署在中国。如果你想使用该模型,请在设置中启用它:{{consoleGoUrl}}", + "zen.api.error.regionNotAllowed": "该模型的最新版本仅在中国提供托管服务,需明确选择启用:{{consoleGoUrl}}", "zen.api.error.trialEnded": "{{model}} 的限免活动已结束。您可以订阅 OpenCode Go 继续使用该模型 - {{link}}", "black.meta.title": "OpenCode Black | 访问全球顶尖编程模型", @@ -641,7 +654,7 @@ export const dict = { "workspace.lite.promo.price": "首月 $5", "workspace.lite.promo.modelsTitle": "包含模型", "workspace.lite.promo.footer": - "该计划主要面向国际用户设计,模型部署在美国、欧盟和新加坡,以确保全球范围内的稳定访问体验。定价和使用额度可能会根据早期用户的使用情况和反馈持续调整与优化。", + "该计划主要面向国际用户,提供稳定的全球访问体验。随着我们持续了解早期使用情况并收集反馈,定价和使用限额可能会有所调整。", "workspace.lite.promo.subscribe": "订阅 Go", "workspace.lite.promo.subscribing": "正在重定向...", "workspace.lite.promo.otherMethods": "其他付款方式", @@ -682,11 +695,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..190a927d3f 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,8 @@ export const dict = { "zen.privacy.exceptionsLink": "以下例外情況", "go.title": "OpenCode Go | 低成本全民編碼模型", - "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.banner.text": "GPT 5.6 Luna 限時享有 2 倍使用額度", + "go.meta.description": "Go 首月 $5,之後 $10/月,提供充裕的使用限額,並可穩定存取領先的編碼模型。", "go.hero.title": "低成本全民編碼模型", "go.hero.body": "Go 將代理編碼帶給全世界的程式設計師。提供寬裕的限額以及對最強大開源模型的穩定存取,讓你可以使用強大的代理進行構建,而無需擔心成本或可用性。", @@ -257,7 +261,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", @@ -288,8 +291,7 @@ export const dict = { "go.problem.item1": "低成本訂閱定價", "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", + "go.problem.item4": "針對代理編碼測試的精選模型陣容", "go.how.title": "Go 如何運作", "go.how.body": "Go 起價為首月 $5,之後 $10/月。您可以將其與 OpenCode 或任何代理搭配使用。", "go.how.step1.title": "建立帳號", @@ -310,8 +312,7 @@ export const dict = { "go.faq.q2": "Go 包含哪些模型?", "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 等開源模型。", + "go.faq.a3": "不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的限額,並可穩定存取精選模型陣容。", "go.faq.q4": "Go 費用是多少?", "go.faq.a4.p1.beforePricing": "Go 費用為", "go.faq.a4.p1.pricingLink": "首月 $5", @@ -320,8 +321,19 @@ export const dict = { "go.faq.a4.p2.accountLink": "帳戶", "go.faq.a4.p3": "中管理訂閱。隨時取消。", "go.faq.q5": "資料與隱私怎麼辦?", - "go.faq.a5.body": - "該方案主要面向國際用戶設計,模型託管在美國、歐盟和新加坡,以確保全球穩定存取。我們的供應商遵循零留存政策,不會將你的資料用於模型訓練。", + "go.faq.a5.model": "模型", + "go.faq.a5.training": "模型訓練", + "go.faq.a5.retention": "資料保留", + "go.faq.a5.retention30": "30 天", + "go.faq.a5.retention0": "0 天", + "go.faq.a5.used": "使用", + "go.faq.a5.notUsed": "不使用", + "go.faq.a5.noAgreement": "無協議", + "go.faq.a5.grokRetention": + "ZDR 會停用依賴儲存資料的重要 API 功能,包括具狀態的 Responses API、Files and Collections 與 Batch API。", + "go.faq.a5.gptRetention": "所有 API 功能的使用都會產生濫用監控日誌,並保留最多 30 天。", + "go.faq.a5.learnMore": "了解更多", + "go.faq.a5.deepseekRetention": "ZDR 協議每月續簽。目前的協議有效至 2026 年 8 月 31 日。", "go.faq.a5.beforeExceptions": "Go 模型託管在美國。供應商遵循零留存政策,不會將你的資料用於模型訓練,但有", "go.faq.a5.exceptionsLink": "以下例外", "go.faq.q6": "我可以儲值額度嗎?", @@ -333,7 +345,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 提供精選模型陣容,並在滾動視窗(5 小時、每週和每月)內提供更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。", "zen.api.error.rateLimitExceeded": "超出頻率限制。請稍後再試。", "zen.api.error.modelNotSupported": "不支援模型 {{model}}", @@ -342,6 +354,7 @@ export const dict = { "zen.api.error.providerNotSupported": "不支援供應商 {{provider}}", "zen.api.error.missingApiKey": "缺少 API 金鑰。", "zen.api.error.invalidApiKey": "無效的 API 金鑰。", + "zen.api.error.requestBlockedByUpstreamProvider": "請求遭上游供應商封鎖。", "zen.api.error.subscriptionQuotaExceeded": "超出訂閱配額。請在 {{retryIn}} 後重試。", "zen.api.error.goSubscriptionRollingLimitExceeded": "已達 5 小時使用上限,將在 {{retryIn}} 後重置。若要立即繼續使用此模型,請從可用餘額啟用使用量:{{consoleGoUrl}}", @@ -355,7 +368,7 @@ export const dict = { "你的工作區已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{billingUrl}}", "zen.api.error.userMonthlyLimitReached": "你已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{membersUrl}}", "zen.api.error.modelDisabled": "模型已停用", - "zen.api.error.regionNotAllowed": "此模型部署於中國。如果你想使用此模型,請在設定中啟用它:{{consoleGoUrl}}", + "zen.api.error.regionNotAllowed": "此模型的最新版本僅於中國託管,且需明確選擇啟用:{{consoleGoUrl}}", "zen.api.error.trialEnded": "{{model}} 的限免活动已結束。您可以訂閱 OpenCode Go 繼續使用該模型 - {{link}}", "black.meta.title": "OpenCode Black | 存取全球最佳編碼模型", @@ -641,7 +654,7 @@ export const dict = { "workspace.lite.promo.price": "首月 $5", "workspace.lite.promo.modelsTitle": "包含模型", "workspace.lite.promo.footer": - "該計畫主要面向國際用戶設計,模型部署在美國、歐盟和新加坡,以確保全球範圍內的穩定存取體驗。定價和使用額度可能會根據早期用戶的使用情況和回饋持續調整與優化。", + "此方案主要為國際使用者設計,提供穩定的全球存取服務。隨著我們從初期使用情況和回饋中持續了解需求,價格和使用額度可能會有所調整。", "workspace.lite.promo.subscribe": "訂閱 Go", "workspace.lite.promo.subscribing": "重新導向中...", "workspace.lite.promo.otherMethods": "其他付款方式", @@ -682,11 +695,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/brand/index.css b/packages/console/app/src/routes/brand/index.css index 8a32651591..0e39f3742f 100644 --- a/packages/console/app/src/routes/brand/index.css +++ b/packages/console/app/src/routes/brand/index.css @@ -376,7 +376,6 @@ align-items: center; gap: 16px; opacity: 0; - transition: opacity 0.2s ease; @media (max-width: 40rem) { position: static; 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..bcf7c896a4 100644 --- a/packages/console/app/src/routes/go/index.css +++ b/packages/console/app/src/routes/go/index.css @@ -327,6 +327,37 @@ body { } } + [data-component="desktop-app-banner"] { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 32px; + + [data-slot="badge"] { + background: var(--color-background-strong); + color: var(--color-text-inverted); + font-weight: 500; + padding: 4px 8px; + line-height: 1; + flex-shrink: 0; + } + + [data-slot="content"] { + display: flex; + align-items: center; + gap: 1ch; + } + + [data-slot="text"] { + color: var(--color-text-strong); + line-height: 1.4; + + @media (max-width: 30.625rem) { + display: none; + } + } + } + [data-slot="hero-copy"] { img { margin-bottom: 24px; @@ -492,6 +523,12 @@ body { @media (prefers-color-scheme: light) { color: color-mix(in srgb, var(--color-text-weak) 82%, var(--color-text-strong)); } + + @media (max-width: 60rem) { + &:not([data-tick="1"], [data-tick="25"], [data-tick="100"], [data-tick="250"]) { + display: none; + } + } } [data-slot="ylabels"] [data-ylabel] { @@ -628,8 +665,8 @@ body { stroke: none; } - [data-bar][data-kind="free"] { - fill: var(--color-text-strong); + [data-bar][data-kind="promo"] { + fill: color-mix(in srgb, var(--bar-go) 50%, transparent); } [data-val] { @@ -652,11 +689,6 @@ body { transform-origin: center; } - [data-point][data-kind="free"] { - fill: var(--color-background); - stroke: var(--color-text-strong); - } - [data-point][data-kind="go"] { fill: var(--color-background-interactive); stroke: var(--color-text-strong); @@ -1028,28 +1060,47 @@ body { margin-bottom: 32px; [data-slot="faq-models"] { + margin: 16px 0 0; + padding-left: 20px; + + li { + margin-bottom: 0; + list-style: disc; + } + } + + [data-slot="faq-model-table"] { + max-width: 560px; margin-top: 16px; overflow-x: auto; table { width: 100%; - min-width: 28rem; border-collapse: collapse; + line-height: 150%; } th, td { - padding: 12px 16px; + padding: 8px 12px; + border-bottom: 1px solid var(--color-border-weak); text-align: left; white-space: nowrap; } th { - background: var(--color-background-weak); color: var(--color-text-strong); font-weight: 500; } } + + [data-slot="faq-retention-notes"] { + margin-top: 16px; + + p { + margin: 8px 0 0; + } + } } } diff --git a/packages/console/app/src/routes/go/index.tsx b/packages/console/app/src/routes/go/index.tsx index 960099fe49..17c78f214d 100644 --- a/packages/console/app/src/routes/go/index.tsx +++ b/packages/console/app/src/routes/go/index.tsx @@ -23,20 +23,25 @@ const checkLoggedIn = query(async () => { }, "checkLoggedIn.get") const models = [ - { name: "GLM-5.2", provider: "DeepInfra, Fireworks AI, Z.ai" }, - { name: "GLM-5.1", provider: "DeepInfra, Fireworks AI, Z.ai" }, - { name: "Kimi K2.7 Code", provider: "Moonshot AI" }, - { name: "Kimi K2.6", provider: "Moonshot AI" }, - { name: "MiMo-V2.5-Pro", provider: "Xiaomi MiMo" }, - { name: "MiMo-V2.5", provider: "Xiaomi MiMo" }, - { name: "Qwen3.7 Max", provider: "Alibaba Cloud Model Studio" }, - { name: "Qwen3.7 Plus", provider: "Alibaba Cloud Model Studio" }, - { name: "Qwen3.6 Plus", provider: "Alibaba Cloud Model Studio" }, - { name: "MiniMax M3", provider: "MiniMax" }, - { name: "MiniMax M2.7", provider: "MiniMax" }, - { name: "DeepSeek V4 Pro", provider: "DeepSeek" }, - { name: "DeepSeek V4 Flash", provider: "DeepSeek" }, -] + { name: "Grok 4.5", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" }, + { name: "GPT 5.6 Luna", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" }, + { name: "GLM-5.2", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, + { name: "GLM-5.1", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, + { name: "Kimi K3", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, + { name: "Kimi K2.7 Code", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, + { name: "Kimi K2.6", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, + { name: "MiMo-V2.5-Pro", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, + { name: "MiMo-V2.5", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, + { name: "Qwen3.8 Max", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, + { name: "Qwen3.7 Max", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, + { name: "Qwen3.7 Plus", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, + { name: "Qwen3.6 Plus", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, + { name: "MiniMax M3", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, + { name: "MiniMax M2.7", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, + { name: "DeepSeek V4 Pro", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, + { name: "DeepSeek V4 Flash", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, + { name: "Hy3", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, +] as const function LimitsGraph(props: { href: string }) { let root!: HTMLElement @@ -59,15 +64,17 @@ function LimitsGraph(props: { href: string }) { onCleanup(() => observer.disconnect()) }) - const free = 200 + const baseline = 100 const graph = [ + { id: "grok-4.5", name: "Grok 4.5", req: 120, d: "50ms" }, + { id: "kimi-k3", name: "Kimi K3", req: 110, d: "75ms" }, + { id: "qwen3.8-max", name: "Qwen3.8 Max", req: 160, d: "90ms" }, { id: "glm-5.2", name: "GLM-5.2", req: 880, d: "100ms" }, - { id: "qwen3.7-max", name: "Qwen3.7 Max", req: 950, d: "110ms" }, - { id: "kimi-k2.7-code", name: "Kimi K2.7 Code", req: 1150, d: "150ms" }, { id: "minimax-m3", name: "MiniMax M3", req: 3200, d: "210ms" }, - { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "240ms" }, { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "270ms" }, + { id: "gpt-5.6-luna", name: "GPT 5.6 Luna (2x usage)", req: 4100, baseReq: 2050, d: "290ms" }, { id: "qwen3.7-plus", name: "Qwen3.7 Plus", req: 4300, d: "300ms" }, + { id: "hy3", name: "Hy3", req: 4300, d: "320ms" }, { id: "mimo-v2.5", name: "MiMo-V2.5", req: 30100, d: "340ms" }, { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", req: 31650, d: "340ms" }, ] @@ -79,15 +86,13 @@ function LimitsGraph(props: { href: string }) { const bottom = 44 const plot = w - left - right - const ratio = (n: number) => n / free + const ratio = (n: number) => n / baseline const rmax = Math.max(1, ...graph.map((m) => ratio(m.req))) const log = (n: number) => Math.log10(Math.max(n, 1)) const base = 24 const p = 2.2 const x = (r: number) => left + base + Math.pow(log(r) / log(rmax), p) * (plot - base) - const start = (x(1) / w) * 100 - - const ticks = [1, 5, 10, 25, 50, 100].filter((t) => t <= rmax) + const ticks = [1, 5, 10, 25, 50, 100, 250].filter((t) => t <= rmax) const labels = (() => { const set = new Set() let last = -Infinity @@ -108,10 +113,8 @@ function LimitsGraph(props: { href: string }) { const bh = 8 const gap = 20 const step = bh + gap - const h = 330 + Math.max(0, graph.length - 8) * step - const sep = bh + 40 - const fy = top + 22 - const gy = (i: number) => fy + sep + step * i + const gy = (i: number) => top + 22 + step * i + const h = gy(graph.length - 1) + bottom const my = graph.length < 2 ? gy(0) : (gy(0) + gy(graph.length - 1)) / 2 const px = (n: number) => `${(n / w) * 100}%` const py = (n: number) => `${(n / h) * 100}%` @@ -121,10 +124,9 @@ function LimitsGraph(props: { href: string }) { return (
    - - {(t) => ( - - - - )} - + {(t) => } - - - - {(m, i) => ( + {m.baseReq && ( + + )} )} @@ -170,9 +173,6 @@ function LimitsGraph(props: { href: string }) { diff --git a/packages/session-ui/src/components/message-file.test.ts b/packages/session-ui/src/components/message-file.test.ts index 7bdf007631..a769ae01bb 100644 --- a/packages/session-ui/src/components/message-file.test.ts +++ b/packages/session-ui/src/components/message-file.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { FilePart } from "@opencode-ai/sdk/v2" -import { attached, inline, kind } from "./message-file" +import { attached, inline, kind, typeLabel } from "./message-file" function file(part: Partial = {}): FilePart { return { @@ -21,7 +21,7 @@ describe("message-file", () => { expect(attached(file())).toBe(false) }) - test("treats only non-attachment source ranges as inline references", () => { + test("keeps data-backed file mentions inline", () => { expect( inline( file({ @@ -34,22 +34,30 @@ describe("message-file", () => { ), ).toBe(true) - expect( - inline( - file({ - url: "data:text/plain;base64,SGVsbG8=", - source: { - type: "file", - path: "/repo/README.txt", - text: { value: "@README.txt", start: 0, end: 11 }, - }, - }), - ), - ).toBe(false) + const mentioned = file({ + url: "data:text/plain;base64,SGVsbG8=", + source: { + type: "file", + path: "/repo/README.txt", + text: { value: "@README.txt", start: 0, end: 11 }, + }, + }) + expect(inline(mentioned)).toBe(true) + expect(attached(mentioned)).toBe(false) }) test("separates image and file attachment kinds", () => { expect(kind(file({ mime: "image/png" }))).toBe("image") expect(kind(file({ mime: "application/pdf" }))).toBe("file") }) + + test("labels attachment types from the basename extension", () => { + expect(typeLabel("list.md", "text/plain")).toBe("Markdown") + expect(typeLabel("/repo/src/main.ts", "text/plain")).toBe("TypeScript") + expect(typeLabel("/tmp/report.pdf", "application/pdf")).toBe("PDF") + expect(typeLabel("notes.xyz", "text/plain")).toBe("XYZ") + expect(typeLabel("/home/user/my.project/Makefile", "text/plain")).toBe("File") + expect(typeLabel(".gitignore", "text/plain")).toBe("File") + expect(typeLabel("/repo/.env", "text/plain")).toBe("File") + }) }) diff --git a/packages/session-ui/src/components/message-file.ts b/packages/session-ui/src/components/message-file.ts index ecc7456902..e09269f33f 100644 --- a/packages/session-ui/src/components/message-file.ts +++ b/packages/session-ui/src/components/message-file.ts @@ -1,14 +1,34 @@ +import { bundledLanguagesInfo } from "shiki" +import { getFilename } from "@opencode-ai/core/util/path" import type { FilePart } from "@opencode-ai/sdk/v2" export function attached(part: FilePart) { - return part.url.startsWith("data:") + return part.url.startsWith("data:") && !inline(part) } export function inline(part: FilePart) { - if (attached(part)) return false return part.source?.text?.start !== undefined && part.source?.text?.end !== undefined } export function kind(part: FilePart) { return part.mime.startsWith("image/") ? "image" : "file" } + +// language metadata only; grammars stay behind shiki's lazy imports +const LANGUAGE_NAMES = new Map( + bundledLanguagesInfo.flatMap((info) => + [info.id, ...(info.aliases ?? [])].map((alias) => [alias, info.name] as [string, string]), + ), +) + +// attachments carry text/plain for all text files, so the label comes from the extension; +// filename may be an absolute path, so extract the basename before looking for one +export function typeLabel(filename: string, mime: string) { + if (mime === "application/pdf") return "PDF" + const base = getFilename(filename) + // idx 0 is a dotfile like .gitignore, not an extension + const idx = base.lastIndexOf(".") + const suffix = idx <= 0 ? "" : base.slice(idx + 1).toLowerCase() + if (!suffix) return "File" + return LANGUAGE_NAMES.get(suffix) ?? suffix.toUpperCase() +} diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index 88779a3c6c..f4e8f68981 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -57,8 +57,20 @@ } &[data-type="image"] { - width: 48px; - height: 48px; + position: relative; + width: 58px; + height: 46px; + border: none; + + /* inset box-shadows do not paint over content, so the hairline is an overlay */ + &::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base); + pointer-events: none; + } } &[data-type="file"] { @@ -121,10 +133,6 @@ align-items: flex-end; } - [data-slot="user-message-attachments"] + [data-slot="user-message-body"] { - margin-top: 8px; - } - [data-slot="user-message-text"] { display: inline-block; white-space: pre-wrap; @@ -883,7 +891,7 @@ width: 16px; height: 2px; border-radius: 999px; - background-color: var(--icon-weak-base); + background-color: var(--v2-icon-icon-muted); transition: background-color 0.2s ease; } @@ -1312,10 +1320,63 @@ } } +body[data-new-layout] [data-component="user-message"] { + font-weight: 440; + + [data-slot="user-message-text"] { + background: var(--v2-background-bg-layer-01); + } + + [data-slot="user-message-comments"] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + width: min(320px, 82%); + margin-left: auto; + + &[data-bounded] { + width: 318px; + max-width: 100%; + margin-top: 8px; + } + + [data-component="tooltip-v2-trigger"] { + width: 100%; + } + + [data-component="button-v2"] { + font-family: var(--v2-font-family-sans, "Inter", sans-serif); + font-weight: 530; + font-variation-settings: + "wght" 530, + "slnt" 0; + } + } + + [data-slot="user-message-body"] + [data-slot="user-message-attachments"], + [data-slot="user-message-comments"] + [data-slot="user-message-attachments"] { + margin-top: 8px; + } + + [data-slot="user-message-text"][data-comments] { + width: 342px; + padding-bottom: 12px; + } +} + +[data-color-scheme="light"] body[data-new-layout] [data-component="user-message"] [data-slot="user-message-text"] { + background: var(--v2-background-bg-layer-02); +} + body:not([data-new-layout]) { [data-component="user-message"] { color: var(--text-strong); + [data-slot="user-message-attachments"] + [data-slot="user-message-body"] { + margin-top: 8px; + } + [data-slot="user-message-attachment"] { background: var(--surface-weak); border: 1px solid var(--border-weak-base); @@ -1323,6 +1384,16 @@ body:not([data-new-layout]) { &:hover { border-color: var(--border-strong-base); } + + &[data-type="image"] { + width: 48px; + height: 48px; + border: 1px solid var(--border-weak-base); + + &::after { + content: none; + } + } } [data-slot="user-message-attachment-name"] { diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index 1ba6be82c8..e63b5b5685 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -47,11 +47,14 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Markdown } from "./markdown" import { ImagePreview } from "@opencode-ai/ui/image-preview" import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/core/util/path" +import { AttachmentCardV2 } from "../v2/components/attachment-card-v2" +import { CommentCardV2 } from "../v2/components/comment-card-v2" import { checksum } from "@opencode-ai/core/util/encode" import { Tooltip } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { Spinner } from "@opencode-ai/ui/spinner" import { TextShimmer } from "@opencode-ai/ui/text-shimmer" @@ -59,8 +62,7 @@ import { AnimatedCountList } from "./tool-count-summary" import { ToolStatusTitle } from "./tool-status-title" import { patchFiles } from "./apply-patch-file" import { animate } from "motion" -import { useLocation } from "@solidjs/router" -import { attached, inline, kind } from "./message-file" +import { attached, inline, kind, typeLabel } from "./message-file" import { readPartText } from "./message-part-text" import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2" @@ -167,6 +169,7 @@ export interface MessageProps { showAssistantCopyPartID?: string | null showReasoningSummaries?: boolean useV2Actions?: boolean + comments?: UserMessageComment[] } export type SessionAction = (input: { sessionID: string; messageID: string }) => Promise | void @@ -174,6 +177,16 @@ export type SessionAction = (input: { sessionID: string; messageID: string }) => export type UserActions = { fork?: SessionAction revert?: SessionAction + openAttachment?: (file: FilePart) => void +} + +export type UserMessageComment = { + path: string + comment: string + selection?: { + startLine: number + endLine: number + } } export interface MessagePartProps { @@ -507,6 +520,7 @@ export function getToolInfo( } } case "bash": + case "shell": return { icon: "console", title: i18n.t("ui.tool.shell"), @@ -524,6 +538,7 @@ export function getToolInfo( title: i18n.t("ui.messagePart.title.write"), subtitle: input.filePath ? getFilename(input.filePath) : undefined, } + case "patch": case "apply_patch": return { icon: "code-lines", @@ -567,29 +582,18 @@ function urls(text: string | undefined) { }) } -function sessionLink(id: string | undefined, path: string, href?: (id: string) => string | undefined) { - if (!id) return - - const direct = href?.(id) - if (direct) return direct - - const idx = path.indexOf("/session") - if (idx === -1) return - return `${path.slice(0, idx)}/session/${id}` -} - -function currentSession(path: string) { - return path.match(/\/session\/([^/?#]+)/)?.[1] +function sessionLink(id: string | undefined, href?: (id: string) => string | undefined) { + if (!id) return undefined + return href?.(id) } function taskSession( input: Record, - path: string, + parentID: string | undefined, sessions: Session[] | undefined, agents?: readonly { name: string; color?: string }[], ) { - const parentID = currentSession(path) - if (!parentID) return + if (!parentID) return undefined const description = typeof input.description === "string" ? input.description : "" const agent = taskAgent(input.subagent_type, agents).name return (sessions ?? []) @@ -715,8 +719,8 @@ export function renderable(part: PartType, showReasoningSummaries = true) { } function toolDefaultOpen(tool: string, shell = false, edit = false) { - if (tool === "bash") return shell - if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit + if (tool === "bash" || tool === "shell") return shell + if (tool === "edit" || tool === "write" || tool === "patch" || tool === "apply_patch") return edit } export function partDefaultOpen(part: PartType, shell = false, edit = false) { @@ -946,6 +950,7 @@ export function Message(props: MessageProps) { parts={props.parts} actions={props.actions} useV2Actions={props.useV2Actions} + comments={props.comments} /> )} @@ -1158,11 +1163,40 @@ export function ContextToolGroup(props: { ) } +function UserMessageComments(props: { comments: UserMessageComment[]; bounded: boolean }) { + const i18n = useI18n() + const [state, setState] = createStore({ expanded: false }) + const comments = createMemo(() => (props.bounded && !state.expanded ? props.comments.slice(0, 5) : props.comments)) + + return ( +
    + + {(comment) => ( + + )} + + 5 && !state.expanded}> + setState("expanded", true)}> + {i18n.t("ui.common.showMore")} + + +
    + ) +} + export function UserMessageDisplay(props: { message: UserMessage parts: PartType[] actions?: UserActions useV2Actions?: boolean + comments?: UserMessageComment[] }) { const data = useData() const dialog = useDialog() @@ -1184,6 +1218,8 @@ export function UserMessageDisplay(props: { const attachments = createMemo(() => files().filter(attached)) + const messageComments = createMemo(() => (newLayout() ? (props.comments ?? []) : [])) + const inlineFiles = createMemo(() => files().filter(inline)) const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? []) @@ -1238,83 +1274,114 @@ export function UserMessageDisplay(props: { .finally(() => setState("busy", false)) } + const renderAttachments = () => ( + 0}> +
    + + {(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)} + + + ) + }} + +
    +
    + ) + return (
    - 0}> -
    - - {(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} - -
    - ) - }} - + {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")} + /> + + -
    - +
    +
    ) @@ -1429,7 +1496,7 @@ export function registerTool(input: { name: string; render?: ToolComponent }) { } export function getTool(name: string) { - return state[name]?.render + return state[name === "apply_patch" ? "patch" : name === "bash" ? "shell" : name]?.render } export const ToolRegistry = { @@ -1496,7 +1563,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { }) const taskHref = createMemo(() => { if (part().tool !== "task") return - return sessionLink(taskId(), useLocation().pathname, data.sessionHref) + return sessionLink(taskId(), data.sessionHref) }) const taskSubtitle = createMemo(() => { if (part().tool !== "task") return undefined @@ -1535,6 +1602,14 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined} subtitle={taskSubtitle()} href={taskHref()} + onSubtitleClick={(event) => { + if (!data.navigateToSession) return + if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return + const id = taskId() + if (!id) return + event.preventDefault() + data.navigateToSession(id) + }} /> ) }} @@ -1915,11 +1990,10 @@ ToolRegistry.register({ render(props) { const data = useData() const i18n = useI18n() - const location = useLocation() const childSessionId = createMemo(() => { const value = props.metadata.sessionId if (typeof value === "string" && value) return value - return taskSession(props.input, location.pathname, data.store.session, data.store.agent) + return taskSession(props.input, data.sessionID, data.store.session, data.store.agent) }) const agent = createMemo(() => taskAgent(props.input.subagent_type, data.store.agent)) const title = createMemo(() => agent().name ?? i18n.t("ui.tool.agent.default")) @@ -1936,18 +2010,13 @@ ToolRegistry.register({ }) const running = createMemo(() => props.status === "pending" || props.status === "running") - const href = createMemo(() => sessionLink(childSessionId(), location.pathname, data.sessionHref)) + const href = createMemo(() => sessionLink(childSessionId(), data.sessionHref)) const clickable = createMemo(() => !!(childSessionId() && (data.navigateToSession || href()))) const open = () => { const id = childSessionId() if (!id) return - if (data.navigateToSession) { - data.navigateToSession(id) - return - } - const value = href() - if (value) window.location.assign(value) + data.navigateToSession?.(id) } const navigate = (event: MouseEvent) => { @@ -2024,7 +2093,7 @@ ToolRegistry.register({ }) ToolRegistry.register({ - name: "bash", + name: "shell", render(props) { const i18n = useI18n() const pending = () => props.status === "pending" || props.status === "running" @@ -2049,13 +2118,14 @@ ToolRegistry.register({ (
    - +
    @@ -2259,7 +2329,7 @@ ToolRegistry.register({ }) ToolRegistry.register({ - name: "apply_patch", + name: "patch", render(props) { const i18n = useI18n() const fileComponent = useFileComponent() diff --git a/packages/session-ui/src/components/session-diff.ts b/packages/session-ui/src/components/session-diff.ts index 48e8eee310..2fbd022235 100644 --- a/packages/session-ui/src/components/session-diff.ts +++ b/packages/session-ui/src/components/session-diff.ts @@ -1,6 +1,7 @@ import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs" import { parsePatch } from "diff" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" type LegacyDiff = { file: string @@ -13,7 +14,7 @@ type LegacyDiff = { } type SnapshotDiff = SnapshotFileDiff & { file: string } -type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff +type ReviewDiff = SnapshotDiff | FileDiffInfo | VcsFileDiff | LegacyDiff export type DiffSource = Pick export type ViewDiff = { diff --git a/packages/session-ui/src/components/session-review.tsx b/packages/session-ui/src/components/session-review.tsx index 8db21f025b..1585a8aa32 100644 --- a/packages/session-ui/src/components/session-review.tsx +++ b/packages/session-ui/src/components/session-review.tsx @@ -16,6 +16,7 @@ import { checksum } from "@opencode-ai/core/util/encode" import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { type FileContent, type SnapshotFileDiff, type VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { type SelectedLineRange } from "@pierre/diffs" import { Dynamic } from "solid-js/web" @@ -62,10 +63,10 @@ export type SessionReviewCommentActions = { export type SessionReviewFocus = { file: string; id: string } -type RawReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { +type RawReviewDiff = (SnapshotFileDiff | FileDiffInfo | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } -type ReviewDiff = ((SnapshotFileDiff & { file: string }) | VcsFileDiff) & { +type ReviewDiff = ((SnapshotFileDiff & { file: string }) | FileDiffInfo | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult } diff --git a/packages/session-ui/src/components/session-turn.tsx b/packages/session-ui/src/components/session-turn.tsx index 75274fc50e..301a74d3f0 100644 --- a/packages/session-ui/src/components/session-turn.tsx +++ b/packages/session-ui/src/components/session-turn.tsx @@ -4,6 +4,7 @@ import { Message as MessageType, Part as PartType, } from "@opencode-ai/sdk/v2/client" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { SessionStatus } from "@opencode-ai/sdk/v2" import { useData } from "../context" import { useFileComponent } from "@opencode-ai/ui/context/file" @@ -90,7 +91,7 @@ function list(value: T[] | undefined | null, fallback: T[]) { return fallback } -type SummaryDiff = SnapshotFileDiff & { file: string } +type SummaryDiff = (SnapshotFileDiff & { file: string }) | FileDiffInfo function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff { return typeof value.file === "string" diff --git a/packages/session-ui/src/components/tool-error-card.tsx b/packages/session-ui/src/components/tool-error-card.tsx index 35720a2753..2251641857 100644 --- a/packages/session-ui/src/components/tool-error-card.tsx +++ b/packages/session-ui/src/components/tool-error-card.tsx @@ -16,6 +16,7 @@ export interface ToolErrorCardProps extends Omit, "c onOpenChange?: (open: boolean) => void subtitle?: string href?: string + onSubtitleClick?: (event: MouseEvent) => void } export function ToolErrorCard(props: ToolErrorCardProps) { @@ -35,6 +36,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) { "onOpenChange", "subtitle", "href", + "onSubtitleClick", ]) const setOpen = (value: boolean) => { if (props.open === undefined) setState("open", value) @@ -51,6 +53,8 @@ export function ToolErrorCard(props: ToolErrorCardProps) { webfetch: "ui.tool.webfetch", websearch: "ui.tool.websearch", bash: "ui.tool.shell", + shell: "ui.tool.shell", + patch: "ui.tool.patch", apply_patch: "ui.tool.patch", question: "ui.tool.questions", } @@ -111,7 +115,10 @@ export function ToolErrorCard(props: ToolErrorCardProps) { data-slot="basic-tool-tool-subtitle" class="clickable subagent-link" href={split.href!} - onClick={(e) => e.stopPropagation()} + onClick={(event) => { + event.stopPropagation() + split.onSubtitleClick?.(event) + }} > {subtitle()} diff --git a/packages/session-ui/src/context/data.tsx b/packages/session-ui/src/context/data.tsx index 999ff510d5..76eacb3336 100644 --- a/packages/session-ui/src/context/data.tsx +++ b/packages/session-ui/src/context/data.tsx @@ -1,4 +1,5 @@ import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, Provider } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { createSimpleContext } from "@opencode-ai/ui/context" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -21,7 +22,7 @@ type Data = { [sessionID: string]: SessionStatus } session_diff: { - [sessionID: string]: SnapshotFileDiff[] + [sessionID: string]: (SnapshotFileDiff | FileDiffInfo)[] } session_diff_preload?: { [sessionID: string]: PreloadMultiFileDiffResult[] @@ -46,6 +47,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ init: (props: { data: Data directory: string + sessionID?: string onNavigateToSession?: NavigateToSessionFn onSessionHref?: SessionHrefFn }) => { @@ -56,6 +58,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ get directory() { return props.directory }, + get sessionID() { + return props.sessionID + }, navigateToSession: props.onNavigateToSession, sessionHref: props.onSessionHref, } diff --git a/packages/session-ui/src/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 new file mode 100644 index 0000000000..6810406237 --- /dev/null +++ b/packages/session-ui/src/v2/components/attachment-card-v2.css @@ -0,0 +1,70 @@ +[data-component="attachment-card-v2"] { + display: flex; + flex-direction: column; + gap: 6px; + box-sizing: border-box; + width: 160px; + min-width: 160px; + max-width: 160px; + padding: 8px; + border-radius: 6px; + background: var(--v2-overlay-simple-overlay-hover); + 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); + } + + &[data-clickable] { + cursor: pointer; + } + + [data-slot="attachment-card-v2-title"], + [data-slot="attachment-card-v2-subtitle"] { + max-width: 100%; + font-family: var(--v2-font-family-sans, "Inter", sans-serif); + font-style: normal; + font-size: 11px; + line-height: 12px; + letter-spacing: 0.05px; + font-variation-settings: "slnt" 0; + } + + [data-slot="attachment-card-v2-title"] { + width: 100%; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 530; + color: var(--v2-text-text-base); + } + + [data-slot="attachment-card-v2-subtitle"] { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + overflow: hidden; + white-space: nowrap; + font-weight: 440; + color: var(--v2-text-text-muted); + + [data-component="file-icon"] { + width: 12px; + height: 12px; + flex: none; + } + } +} diff --git a/packages/session-ui/src/v2/components/attachment-card-v2.tsx b/packages/session-ui/src/v2/components/attachment-card-v2.tsx new file mode 100644 index 0000000000..69910671e2 --- /dev/null +++ b/packages/session-ui/src/v2/components/attachment-card-v2.tsx @@ -0,0 +1,33 @@ +import type { JSX } from "solid-js" +import "./attachment-card-v2.css" + +/** Shared 160px two-line card used by v2 file and comment attachments in the composer and timeline. */ +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 +}) { + return ( +
    props.onClick?.()} + > + 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 new file mode 100644 index 0000000000..dbcdb24345 --- /dev/null +++ b/packages/session-ui/src/v2/components/comment-card-v2.tsx @@ -0,0 +1,64 @@ +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: { + comment: string + path: string + 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 ( + + { + 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/line-comment-annotations-v2.tsx b/packages/session-ui/src/v2/components/line-comment-annotations-v2.tsx index 4e8ed86732..cd27a1ecc7 100644 --- a/packages/session-ui/src/v2/components/line-comment-annotations-v2.tsx +++ b/packages/session-ui/src/v2/components/line-comment-annotations-v2.tsx @@ -11,6 +11,7 @@ import { import { useI18n } from "@opencode-ai/ui/context/i18n" import { cloneSelectedLineRange, formatSelectedLineLabel } from "../../pierre/selection-bridge" import { LineCommentEditorV2, LineCommentV2 } from "@opencode-ai/ui/v2/line-comment-v2" +import type { LineCommentEditorV2Mention } from "@opencode-ai/ui/v2/line-comment-v2" type LineCommentControllerV2Props = { comments: Accessor @@ -23,6 +24,7 @@ type LineCommentControllerV2Props = { onDelete?: (comment: T) => void renderCommentActions?: (comment: T, controls: { edit: VoidFunction; remove: VoidFunction }) => JSX.Element editSubmitLabel?: string + mention?: LineCommentEditorV2Mention } type CommentProps = { @@ -43,6 +45,7 @@ type DraftProps = { onSubmit: (value: string) => void cancelLabel?: string submitLabel?: string + mention?: LineCommentEditorV2Mention } function lineCommentElementV2(view: Accessor) { @@ -70,6 +73,7 @@ function lineCommentElementV2(view: Accessor) { onSubmit={view().editor!.onSubmit} cancelLabel={view().editor!.cancelLabel} submitLabel={view().editor!.submitLabel} + mention={view().editor!.mention} />
    @@ -87,6 +91,7 @@ function lineCommentDraftElementV2(view: Accessor) { onSubmit={view().onSubmit} cancelLabel={view().cancelLabel} submitLabel={view().submitLabel} + mention={view().mention} />
    ) @@ -142,6 +147,7 @@ export function createLineCommentControllerV2(props: }, cancelLabel: i18n.t("ui.lineComment.cancel"), submitLabel: props.editSubmitLabel, + mention: props.mention, } : undefined }, @@ -165,6 +171,7 @@ export function createLineCommentControllerV2(props: }, cancelLabel: i18n.t("ui.lineComment.cancel"), submitLabel: i18n.t("ui.lineComment.submit"), + mention: props.mention, }), }) @@ -202,6 +209,7 @@ export function createLineCommentControllerV2(props: } return { + note, annotations, renderAnnotation, renderGutterUtility, diff --git a/packages/session-ui/src/v2/components/prompt-input/attachments.css b/packages/session-ui/src/v2/components/prompt-input/attachments.css new file mode 100644 index 0000000000..3b8173d105 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/attachments.css @@ -0,0 +1,44 @@ +@keyframes prompt-input-v2-attachments-fade-left { + from { + visibility: hidden; + } + to { + visibility: visible; + } +} + +@keyframes prompt-input-v2-attachments-fade-right { + from { + visibility: visible; + } + to { + visibility: hidden; + } +} + +[data-component="prompt-input-v2-attachments"] { + timeline-scope: --prompt-input-v2-attachments-scroll; + + [data-slot^="prompt-attachments-fade-"] { + visibility: hidden; + } +} + +@supports (animation-timeline: --prompt-input-v2-attachments-scroll) and + (timeline-scope: --prompt-input-v2-attachments-scroll) { + [data-component="prompt-input-v2-attachments"] [data-slot="prompt-attachments-scroll"] { + scroll-timeline: --prompt-input-v2-attachments-scroll x; + } + + [data-component="prompt-input-v2-attachments"] [data-slot="prompt-attachments-fade-left"] { + animation: prompt-input-v2-attachments-fade-left linear both; + animation-timeline: --prompt-input-v2-attachments-scroll; + animation-range: 0 0.1px; + } + + [data-component="prompt-input-v2-attachments"] [data-slot="prompt-attachments-fade-right"] { + animation: prompt-input-v2-attachments-fade-right linear both; + animation-timeline: --prompt-input-v2-attachments-scroll; + animation-range: calc(100% - 1.1px) calc(100% - 1px); + } +} diff --git a/packages/session-ui/src/v2/components/prompt-input/attachments.ts b/packages/session-ui/src/v2/components/prompt-input/attachments.ts new file mode 100644 index 0000000000..89f38ec4a3 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/attachments.ts @@ -0,0 +1,278 @@ +import { onMount } from "solid-js" +import { makeEventListener } from "@solid-primitives/event-listener" +import type { PromptInputV2Attachment, PromptInputV2Prompt } from "./types" + +const accepted = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "application/pdf", + "text/*", + "application/json", + "application/ld+json", + "application/toml", + "application/x-toml", + "application/x-yaml", + "application/xml", + "application/yaml", + ".c", + ".cc", + ".cjs", + ".conf", + ".cpp", + ".css", + ".csv", + ".cts", + ".env", + ".go", + ".gql", + ".graphql", + ".h", + ".hh", + ".hpp", + ".htm", + ".html", + ".ini", + ".java", + ".js", + ".json", + ".jsx", + ".log", + ".md", + ".mdx", + ".mjs", + ".mts", + ".py", + ".rb", + ".rs", + ".sass", + ".scss", + ".sh", + ".sql", + ".toml", + ".ts", + ".tsx", + ".txt", + ".xml", + ".yaml", + ".yml", + ".zsh", +] + +type PromptTarget = { + current: () => PromptInputV2Prompt + cursor: () => number | undefined + set: (prompt: PromptInputV2Prompt, cursor?: number) => void +} + +export type PromptInputV2AttachmentConfig = { + picker?: ( + options: { defaultPath?: string; multiple?: boolean; accept?: string[] }, + onFile: (file: File) => Promise, + ) => Promise + directory: () => string + isDialogActive: () => boolean + warn: () => void + duplicate: () => void + onError: (error: unknown) => void + readClipboardImage?: () => Promise + getPathForFile?: (file: File) => string + store?: (file: File) => Promise<{ id: string; url: string }> +} + +export function createPromptInputV2Attachments( + input: PromptInputV2AttachmentConfig & { + capture: () => PromptTarget + editor: () => HTMLElement | undefined + focusEditor: () => void + addPart: (part: PromptInputV2Prompt[number]) => boolean + setDraggingType: (type: "image" | "@mention" | null) => void + }, +) { + const capture = () => { + const prompt = input.capture() + const editor = input.editor() + if (!editor) return + return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) } + } + const add = async (file: File, toast = true, target = capture(), clipboard = false) => { + if (!target) return false + const mime = await attachmentMime(file) + if (!mime) { + if (toast) input.warn() + return false + } + const blob = input.store ? await input.store(file) : await blobReference(file) + const sourcePath = input.getPathForFile?.(file) || undefined + // Native clipboard images arrive with a fresh timestamped filename on every paste, so identical + // clipboard content is matched on bytes alone. + const duplicate = target.prompt + .current() + .some( + (part) => + part.type === "image" && + part.blob.id === blob.id && + (sourcePath + ? part.sourcePath === sourcePath + : !part.sourcePath && (clipboard || part.filename === file.name)), + ) + if (duplicate) { + input.duplicate() + return true + } + const attachment: PromptInputV2Attachment = { + type: "image", + id: globalThis.crypto?.randomUUID?.() ?? Math.random().toString(16).slice(2), + filename: file.name, + sourcePath, + mime, + blob, + } + target.prompt.set([...target.prompt.current(), attachment], target.cursor) + return true + } + const addAttachments = async (files: File[], toast = true, target = capture()) => { + const found = await files.reduce(async (result, file) => { + const previous = await result + return (await add(file, false, target)) || previous + }, Promise.resolve(false)) + if (!found && files.length > 0 && toast) input.warn() + return found + } + const handlePaste = async (event: ClipboardEvent) => { + const clipboardData = event.clipboardData + if (!clipboardData) return + const target = capture() + if (!target) return + event.preventDefault() + event.stopPropagation() + const files = Array.from(clipboardData.items).flatMap((item) => { + if (item.kind !== "file") return [] + const file = item.getAsFile() + return file ? [file] : [] + }) + if (files.length > 0) { + await addAttachments(files, true, target) + return + } + const plainText = clipboardData.getData("text/plain") ?? "" + if (input.readClipboardImage && !plainText) { + const file = await input.readClipboardImage() + if (file && (await add(file, true, target, true))) return + } + if (!plainText) return + const text = plainText.includes("\r") ? plainText.replace(/\r\n?/g, "\n") : plainText + const put = () => { + if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true + input.focusEditor() + return input.addPart({ type: "text", content: text, start: 0, end: 0 }) + } + if (text.includes("\n") || largePaste(text)) { + put() + return + } + if (typeof document.execCommand === "function" && document.execCommand("insertText", false, text)) return + put() + } + const handleDrop = async (event: DragEvent) => { + if (input.isDialogActive()) return + event.preventDefault() + input.setDraggingType(null) + const plainText = event.dataTransfer?.getData("text/plain") + if (plainText?.startsWith("file:")) { + const path = plainText.slice("file:".length) + input.focusEditor() + input.addPart({ type: "file", path, content: `@${path}`, start: 0, end: 0 }) + return + } + const files = event.dataTransfer?.files + if (files) await addAttachments(Array.from(files)) + } + + onMount(() => { + makeEventListener(document, "dragover", (event) => { + if (input.isDialogActive()) return + event.preventDefault() + if (event.dataTransfer?.types.includes("Files")) input.setDraggingType("image") + else if (event.dataTransfer?.types.includes("text/plain")) input.setDraggingType("@mention") + }) + makeEventListener(document, "dragleave", (event) => { + if (!input.isDialogActive() && !event.relatedTarget) input.setDraggingType(null) + }) + makeEventListener(document, "drop", handleDrop) + }) + + return { + addAttachments, + handlePaste, + handleDrop, + pick(fallback: () => void) { + if (!input.picker) { + fallback() + return + } + void input + .picker({ defaultPath: input.directory(), multiple: true, accept: accepted }, (file) => add(file)) + .catch(input.onError) + }, + } +} + +const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]) + +async function blobReference(file: File) { + const id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await file.arrayBuffer()))) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + return { id, url: URL.createObjectURL(file) } +} +const imageExtensions = new Map([ + ["gif", "image/gif"], + ["jpeg", "image/jpeg"], + ["jpg", "image/jpeg"], + ["png", "image/png"], + ["webp", "image/webp"], +]) +const textMimes = new Set([ + "application/json", + "application/ld+json", + "application/toml", + "application/x-toml", + "application/x-yaml", + "application/xml", + "application/yaml", +]) + +async function attachmentMime(file: File) { + const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? "" + if (imageMimes.has(type) || type === "application/pdf") return type + const index = file.name.lastIndexOf(".") + const suffix = index === -1 ? "" : file.name.slice(index + 1).toLowerCase() + const fallback = imageExtensions.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined) + if ((!type || type === "application/octet-stream") && fallback) return fallback + if (type.startsWith("text/") || textMimes.has(type) || type.endsWith("+json") || type.endsWith("+xml")) { + return "text/plain" + } + const bytes = new Uint8Array(await file.slice(0, 4096).arrayBuffer()) + if (bytes.some((byte) => byte === 0)) return + const control = bytes.filter((byte) => byte < 9 || (byte > 13 && byte < 32)).length + if (bytes.length > 0 && control / bytes.length > 0.3) return + return "text/plain" +} + +function cursorPosition(editor: HTMLElement) { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return 0 + const range = selection.getRangeAt(0) + if (!editor.contains(range.startContainer)) return 0 + const before = range.cloneRange() + before.selectNodeContents(editor) + before.setEnd(range.startContainer, range.startOffset) + return before.toString().replace(/\u200B/g, "").length +} + +function largePaste(text: string) { + if (text.length >= 8000) return true + return text.split("\n").length - 1 >= 120 +} diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx new file mode 100644 index 0000000000..d29fd4df23 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx @@ -0,0 +1,714 @@ +import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { AttachmentCardV2 } from "../attachment-card-v2" +import { CommentCardV2 } from "../comment-card-v2" +import { typeLabel } from "../../../components/message-file" +import type { + PromptInputV2Attachment, + PromptInputV2Comment, + PromptInputV2Option, + PromptInputV2PersistedState, + PromptInputV2Prompt, + PromptInputV2Suggestion, +} from "./types" +import type { PromptInputV2Interaction, PromptInputV2SelectControl } from "./interaction" +import "./attachments.css" + +export type { + PromptInputV2Attachment, + PromptInputV2Comment, + PromptInputV2Option, + PromptInputV2PersistedState, + PromptInputV2Suggestion, +} from "./types" + +export type PromptInputV2Mode = "normal" | "shell" + +export type PromptInputV2Props = { + controller: PromptInputV2Interaction + disabled?: boolean + readOnly?: boolean + borderUnderlay?: boolean + class?: string + modelControl?: JSX.Element + variantControlVisible?: boolean + attachKeybind?: string[] + attachShortcut?: string +} + +export function PromptInputV2(props: PromptInputV2Props) { + const state = props.controller.state + const view = props.controller.view + let editor: HTMLDivElement | undefined + let localInput = false + const updateCursor = () => { + if (!editor || !window.getSelection()?.isCollapsed) return + props.controller.onCursor(promptInputV2Cursor(editor)) + } + const mode = createMemo(() => state.mode) + const buttons = createMemo(() => ({ + opacity: mode() === "normal" ? 1 : 0, + "pointer-events": mode() === "normal" ? ("auto" as const) : ("none" as const), + transition: "opacity 200ms ease", + })) + + createEffect(() => { + const parts = props.controller.parts() + if (!editor) return + if (localInput) { + localInput = false + return + } + renderPromptInputV2Editor(editor, parts) + }) + + return ( +
    + { + const list = event.currentTarget.files + if (list) props.controller.addAttachments(Array.from(list)) + event.currentTarget.value = "" + }} + /> + + props.controller.dispatch({ type: "popover.active", id: item.id })} + onSelect={(item) => props.controller.dispatch({ type: "popover.select", item })} + /> + +
    { + event.preventDefault() + if (!props.disabled) props.controller.submit() + }} + onDragEnter={props.controller.onDragEnter} + onDragOver={props.controller.onDragOver} + onDragLeave={props.controller.onDragLeave} + onDrop={props.controller.onDrop} + > + +
    + Drop files to attach +
    +
    + + + props.controller.removeAttachment(attachment.id)} + onCommentClick={(comment) => props.controller.toggleContext(comment.key)} + onCommentRemove={(comment) => props.controller.removeContext(comment.key)} + /> + + +
    +
    { + editor = element + props.controller.setEditor(element) + renderPromptInputV2Editor(element, props.controller.parts()) + }} + data-component="prompt-input" + role="textbox" + aria-multiline="true" + aria-label="Prompt" + contenteditable={!props.disabled && !props.readOnly} + autocapitalize={state.mode === "normal" ? "sentences" : "off"} + autocorrect={state.mode === "normal" ? "on" : "off"} + spellcheck={state.mode === "normal"} + // @ts-expect-error + autocomplete="off" + class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword" + classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }} + onInput={(event) => { + const cursor = promptInputV2Cursor(event.currentTarget) + const prompt = parsePromptInputV2Editor(event.currentTarget) + const images = props.controller.parts().filter((part) => part.type === "image") + localInput = true + props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor) + }} + onKeyDown={(event) => { + if (props.controller.onKeyDown(event)) return + if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { + event.preventDefault() + if (event.repeat) return + props.controller.submit() + } + }} + onKeyUp={updateCursor} + onPointerUp={updateCursor} + onPaste={props.controller.onPaste} + onFocus={() => props.controller.dispatch({ type: "focus.editor" })} + /> + +
    + {view.placeholder?.() ?? + (state.mode === "shell" ? "Enter shell command..." : "Ask anything, / for commands, @ for context...")} +
    +
    +
    + +
    +
    + + + {(control) => ( + + )} + + + {(control) => ( + + )} + + } + > + {props.modelControl} + + + {(control) => ( + 1}> + + + )} + +
    + +
    + +
    + ) +} + +function renderPromptInputV2Editor(editor: HTMLDivElement, prompt: PromptInputV2Prompt) { + const active = document.activeElement === editor + editor.replaceChildren( + ...prompt.flatMap((part) => { + if (part.type === "image") return [] + if (part.type === "text") return [document.createTextNode(part.content)] + const mention = document.createElement("span") + mention.textContent = part.content + mention.contentEditable = "false" + mention.dataset.mention = + part.type === "file" && part.mime === "application/x-directory" ? "reference" : part.type + if (part.type === "agent") mention.dataset.name = part.name + if (part.type === "file") { + mention.dataset.path = part.path + if (part.mime) mention.dataset.mime = part.mime + if (part.filename) mention.dataset.filename = part.filename + } + return [mention] + }), + ) + if (!active) return + const selection = window.getSelection() + const range = document.createRange() + range.selectNodeContents(editor) + range.collapse(false) + selection?.removeAllRanges() + selection?.addRange(range) +} + +function parsePromptInputV2Editor(editor: HTMLDivElement) { + const parts: Exclude[] = [] + let buffer = "" + let position = 0 + + const flush = () => { + if (!buffer) return + parts.push({ type: "text", content: buffer, start: position, end: position + buffer.length }) + position += buffer.length + buffer = "" + } + const mention = (element: HTMLElement) => { + flush() + const content = element.textContent ?? "" + if (element.dataset.mention === "agent") { + parts.push({ + type: "agent", + name: element.dataset.name ?? content.slice(1), + content, + start: position, + end: position + content.length, + }) + position += content.length + return + } + parts.push({ + type: "file", + path: element.dataset.path ?? content.slice(1), + content, + start: position, + end: position + content.length, + ...(element.dataset.mime ? { mime: element.dataset.mime } : {}), + ...(element.dataset.filename ? { filename: element.dataset.filename } : {}), + }) + position += content.length + } + const visit = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + buffer += node.textContent ?? "" + return + } + if (!(node instanceof HTMLElement)) return + if (node.dataset.mention) { + mention(node) + return + } + if (node.tagName === "BR") { + buffer += "\n" + return + } + Array.from(node.childNodes).forEach(visit) + } + + Array.from(editor.childNodes).forEach((node, index, nodes) => { + visit(node) + if (node instanceof HTMLElement && ["DIV", "P"].includes(node.tagName) && index < nodes.length - 1) buffer += "\n" + }) + flush() + if ( + parts.every((part) => part.type === "text") && + parts.every((part) => part.content.replace(/[\n\u200B]/g, "") === "") + ) { + return [{ type: "text" as const, content: "", start: 0, end: 0 }] + } + if (parts.length > 0) return parts + return [{ type: "text" as const, content: "", start: 0, end: 0 }] +} + +function promptInputV2Cursor(editor: HTMLDivElement) { + const selection = window.getSelection() + if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0 + const range = selection.getRangeAt(0).cloneRange() + range.selectNodeContents(editor) + range.setEnd(selection.anchorNode!, selection.anchorOffset) + return range.toString().length +} + +export function PromptInputV2Attachments(props: { + attachments: PromptInputV2Attachment[] + comments?: PromptInputV2Comment[] + activeCommentID?: string + removeLabel: string + onAttachmentClick?: (attachment: PromptInputV2Attachment) => void + onAttachmentRemove: (attachment: PromptInputV2Attachment) => void + onCommentClick?: (comment: PromptInputV2Comment) => void + onCommentRemove?: (comment: PromptInputV2Comment) => void +}) { + return ( + 0 || (props.comments?.length ?? 0) > 0}> +
    +
    + + {(comment) => ( +
    + + props.onCommentClick?.(comment)} + /> + + +
    + )} +
    + + {(attachment) => ( +
    + + + {typeLabel(attachment.filename, attachment.mime)} + + } + > + {attachment.filename} props.onAttachmentClick?.(attachment)} + /> +
    + + + +
    + )} + +
    +
    +
    +
    + + ) +} + +export function PromptInputV2AddMenu(props: { + disabled?: boolean + title: string + keybind?: string[] + attachLabel: string + attachShortcut?: string + commandsLabel: string + contextLabel: string + shellLabel: string + onAttach: () => void + onCommands: () => void + onContext: () => void + onShell: () => void +}) { + return ( + + {props.title} + + + } + > + + } + variant="ghost-muted" + size="large" + disabled={props.disabled} + aria-label={props.title} + /> + + + + {props.attachLabel} + + + + {props.commandsLabel} + + + {props.contextLabel} + + + {props.shellLabel} + + + + + + ) +} + +function PromptInputV2ConfiguredSelect(props: { + title: string + keybind?: string[] + control: PromptInputV2SelectControl + model?: boolean +}) { + const current = () => props.control.current() + const providerID = () => props.control.options().find((option) => option.id === current())?.providerID + return ( + + + + } + onSelect={props.control.onSelect} + /> + ) +} + +export function PromptInputV2Select(props: { + title: string + keybind?: string[] + options: PromptInputV2Option[] + current: string + currentIcon?: JSX.Element + class?: string + onOpenChange?: (open: boolean) => void + onSelect: (id: string) => void +}) { + return ( + + {props.title} + + + } + > + + + {props.currentIcon} + + {props.options.find((option) => option.id === props.current)?.label ?? props.current} + + + + + + + + + + {(option) => ( + + {option.label} + + )} + + + + + + + ) +} + +export function PromptInputV2Popover(props: { + emptyLabel: string + items: PromptInputV2Suggestion[] + activeID?: string + search?: { + value: string + label: string + placeholder: string + onValueChange: (value: string) => void + onKeyDown: (event: KeyboardEvent) => void + } + onActiveChange: (item: PromptInputV2Suggestion) => void + onSelect: (item: PromptInputV2Suggestion) => void +}) { + return ( +
    event.preventDefault()} + > + + {(search) => ( +
    + requestAnimationFrame(() => element.focus())} + value={search().value} + aria-label={search().label} + placeholder={search().placeholder} + class="w-full bg-transparent text-[13px] leading-5 text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint" + onInput={(event) => search().onValueChange(event.currentTarget.value)} + onKeyDown={(event) => search().onKeyDown(event)} + onMouseDown={(event) => event.stopPropagation()} + /> +
    + )} +
    + 0} + fallback={
    {props.emptyLabel}
    } + > + + {(item) => ( + + )} + +
    +
    + ) +} + +export function PromptInputV2SubmitButton(props: { + mode: PromptInputV2Mode + stopping: boolean + disabled: boolean + sendLabel: string + stopLabel: string + onSubmit: () => void + onStop: () => void +}) { + return ( + + { + event.preventDefault() + event.stopPropagation() + if (props.stopping) { + props.onStop() + return + } + props.onSubmit() + }} + /> + + ) +} + +function PromptInputV2SuggestionIcon(props: { item: PromptInputV2Suggestion }) { + if (props.item.kind === "agent") return + if (props.item.kind === "command") return null + return ( + + ) +} diff --git a/packages/session-ui/src/v2/components/prompt-input/interaction.ts b/packages/session-ui/src/v2/components/prompt-input/interaction.ts new file mode 100644 index 0000000000..f4a9fa74b7 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/interaction.ts @@ -0,0 +1,482 @@ +import { createEffect, on, type Accessor } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import { useFilteredList } from "@opencode-ai/ui/hooks" +import { createPromptInputV2Attachments, type PromptInputV2AttachmentConfig } from "./attachments" +import { createPromptInputV2Store, type PromptInputV2StoreInput } from "./store" +import type { + PromptInputV2Attachment, + PromptInputV2Comment, + PromptInputV2History, + PromptInputV2HistoryEntry, + PromptInputV2Option, + PromptInputV2PersistedState, + PromptInputV2Suggestion, +} from "./types" +import { + createPromptInputV2InteractionState, + transitionPromptInputV2, + type PromptInputV2InteractionCommand, + type PromptInputV2InteractionEvent, +} from "./machine" + +export type PromptInputV2SelectControl = { + options: Accessor + current: Accessor + onSelect: (id: string) => void + keybind?: Accessor +} + +export type PromptInputV2ViewConfig = { + placeholder?: Accessor + add?: { + onAttach: () => void + } + agent?: PromptInputV2SelectControl + model?: PromptInputV2SelectControl + variant?: PromptInputV2SelectControl + submit: { + stopping: Accessor + working?: Accessor + onSubmit: () => void + onStop: () => void + } + shell?: { + onOpen: () => void + onClose: () => void + } + onKeyDown?: (event: KeyboardEvent) => void + onPaste?: (event: ClipboardEvent) => void + onDrop?: (event: DragEvent) => void +} + +export function createPromptInputV2State() { + return createStore(createPromptInputV2InteractionState()) +} + +export function createPromptInputV2Controller(input: { + store: PromptInputV2StoreInput + state?: ReturnType + identity?: Accessor + history?: PromptInputV2History + commands: Accessor + context: Accessor + searchContextFiles: (query: string) => PromptInputV2Suggestion[] | Promise + openAttachment?: (attachment: PromptInputV2Attachment) => void + openContext?: (key: string) => void + onContextRemove?: (item: PromptInputV2Comment) => void + onEditor?: (element: HTMLElement) => void + onSuggestionSelect?: (item: PromptInputV2Suggestion) => (() => void) | void + view: PromptInputV2ViewConfig + attachments?: PromptInputV2AttachmentConfig +}) { + let editor: HTMLElement | undefined + let fileInput: HTMLInputElement | undefined + const draft = createPromptInputV2Store(input.store) + const [state, setState] = input.state ?? createPromptInputV2State() + if (input.identity) { + createEffect(on(input.identity, () => setState(reconcile(createPromptInputV2InteractionState())), { defer: true })) + } + function addPart(part: PromptInputV2PersistedState["prompt"][number]) { + if (part.type === "image") return false + if (part.type === "file" || part.type === "agent") { + draft.addMention(part) + return true + } + draft.addText(part.content) + return true + } + const attachments = input.attachments + ? createPromptInputV2Attachments({ + ...input.attachments, + capture: () => ({ + current: () => draft.state.prompt, + cursor: () => draft.state.cursor, + set: draft.setPrompt, + }), + editor: () => editor, + focusEditor: () => editor?.focus(), + addPart, + setDraggingType: (type) => dispatch({ type: type ? "drag.enter" : "drag.leave" }), + }) + : undefined + const attach = () => { + if (!attachments) { + input.view.add?.onAttach() + return + } + attachments.pick(() => fileInput?.click()) + } + const contextList = useFilteredList({ + items: async (query) => { + const fixed = input.context().filter((item) => item.kind !== "file") + const recent = input.context().filter((item) => item.kind === "file" && item.recent) + if (!query.trim()) return [...fixed, ...recent] + const seen = new Set(recent.map((item) => item.id)) + const files = (await input.searchContextFiles(query)).filter((item) => !seen.has(item.id)) + return [...fixed, ...recent, ...files] + }, + key: (item) => item.id, + filterKeys: ["label"], + skipFilter: (item) => item.kind === "file" && !item.recent, + groupBy: (item) => { + if (item.kind === "reference") return "reference" + if (item.kind === "agent") return "agent" + if (item.kind === "resource") return "resource" + if (item.recent) return "recent" + return "file" + }, + sortGroupsBy: (a, b) => { + const order = ["reference", "agent", "resource", "recent", "file"] + return order.indexOf(a.category) - order.indexOf(b.category) + }, + }) + const commandList = useFilteredList({ + items: () => input.commands(), + key: (item) => item.id, + filterKeys: ["trigger", "title"], + }) + const list = () => (state.popover.type === "context" ? contextList : commandList) + const suggestions = () => list().flat() + + const execute = (command: PromptInputV2InteractionCommand) => { + if (command.type === "draft.setText") { + draft.setText(command.value) + return + } + if (command.type === "mention.add") { + if (command.item.mention) draft.addMention(command.item.mention) + return + } + if (command.type === "popover.filter") { + ;(command.popover === "command" ? commandList : contextList).onInput(command.query) + return + } + if (command.type === "suggestion.select") { + const item = suggestions().find((entry) => entry.id === command.id) + if (item) dispatch({ type: "popover.select", item }) + return + } + if (command.type === "focus.editor") requestAnimationFrame(() => editor?.focus()) + } + + function dispatch(event: PromptInputV2InteractionEvent) { + const mode = state.mode + const result = transitionPromptInputV2(state, event, draft.state) + const action = event.type === "popover.select" ? input.onSuggestionSelect?.(event.item) : undefined + if (event.type === "popover.select") { + if (!action || state.popover.type !== "command-menu") result.commands.forEach(execute) + if (action && event.item.kind === "command" && state.popover.type !== "command-menu") { + draft.setPrompt( + draft.state.prompt.filter((part): part is PromptInputV2Attachment => part.type === "image"), + 0, + ) + } + } + setState(reconcile(result.state)) + if (event.type !== "popover.select") result.commands.forEach(execute) + if (mode !== result.state.mode) { + if (result.state.mode === "shell") input.view.shell?.onOpen() + if (result.state.mode === "normal") input.view.shell?.onClose() + } + if (event.type === "popover.select") { + if (!action) return result.handled + action() + } + return result.handled + } + + const onKeyDown = (event: KeyboardEvent) => { + if ( + state.mode === "normal" && + (event.metaKey || event.ctrlKey) && + !event.altKey && + !event.shiftKey && + event.key.toLowerCase() === "u" + ) { + event.preventDefault() + attach() + return true + } + const handled = dispatch({ + type: "key.down", + key: event.key, + ctrl: event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey, + composing: event.isComposing, + ids: suggestions().map((item) => item.id), + empty: draft.state.prompt.every((part) => !("content" in part) || part.content.length === 0), + }) + if (handled) event.preventDefault() + if (handled && event.key !== "Enter" && event.key !== "Tab" && state.popover.type !== "closed") { + const activeID = state.popover.activeID ?? "" + requestAnimationFrame(() => + document.querySelector(`[data-suggestion-id="${CSS.escape(activeID)}"]`)?.scrollIntoView({ block: "nearest" }), + ) + } + if (handled) return true + const stop = + input.view.submit.working?.() && + ((event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "g") || + event.key === "Escape") + if (stop) { + event.preventDefault() + input.view.submit.onStop() + return true + } + if ( + !event.altKey && + !event.ctrlKey && + !event.metaKey && + (event.key === "ArrowUp" || event.key === "ArrowDown") && + navigateHistory(event.key === "ArrowUp" ? "up" : "down") + ) { + event.preventDefault() + return true + } + input.view.onKeyDown?.(event) + return event.defaultPrevented + } + + createEffect(() => { + if (state.popover.type === "closed") return + const ids = suggestions().map((item) => item.id) + if (state.popover.activeID ? ids.includes(state.popover.activeID) : ids.length === 0) return + dispatch({ type: "popover.results", ids }) + }) + + const restoreFocus = (cursor = draft.state.cursor ?? promptLength(draft.state.prompt)) => { + requestAnimationFrame(() => { + editor?.focus() + setEditorCursor(editor, cursor) + }) + } + + const applyHistory = (entry: PromptInputV2HistoryEntry, position: "start" | "end") => { + input.history?.restore?.(entry.metadata) + const cursor = position === "start" ? 0 : promptLength(entry.prompt) + draft.setPrompt(clonePrompt(entry.prompt), cursor) + restoreFocus(cursor) + } + const navigateHistory = (direction: "up" | "down") => { + if (!input.history || !editor) return false + const selection = window.getSelection() + if (!selection?.isCollapsed || !editor.contains(selection.anchorNode)) return false + const text = draft.state.prompt.map((part) => ("content" in part ? part.content : "")).join("") + if (!canNavigateHistory(direction, text, editorCursor(editor), state.historyIndex >= 0)) return false + const entries = input.history.entries(state.mode) + if (direction === "up") { + if (entries.length === 0 || state.historyIndex >= entries.length - 1) return false + if (state.historyIndex === -1) { + setState("savedHistory", { + prompt: clonePrompt(draft.state.prompt), + metadata: input.history.capture?.(), + }) + } + const index = state.historyIndex + 1 + setState("historyIndex", index) + applyHistory(entries[index]!, "start") + return true + } + if (state.historyIndex < 0) return false + if (state.historyIndex > 0) { + const index = state.historyIndex - 1 + setState("historyIndex", index) + applyHistory(entries[index]!, "end") + return true + } + const saved = state.savedHistory ?? { prompt: [{ type: "text", content: "", start: 0, end: 0 }] } + setState({ historyIndex: -1, savedHistory: undefined }) + applyHistory(saved, "end") + return true + } + + return { + state, + view: input.view, + suggestions, + dispatch, + onKeyDown, + value() { + return draft.state.prompt.map((part) => ("content" in part ? part.content : "")).join("") + }, + parts() { + return draft.state.prompt + }, + addPart, + contextItem(id: string) { + return draft.state.context.items.find((item) => item.key === id) + }, + comments() { + return draft.state.context.items.filter((item) => !!item.comment?.trim()) + }, + attachments(): PromptInputV2Attachment[] { + return draft.state.prompt.filter((part): part is PromptInputV2Attachment => part.type === "image") + }, + toggleContext(id: string) { + dispatch({ type: "context.active", id }) + input.openContext?.(id) + }, + removeContext(id: string) { + const item = draft.state.context.items.find((entry) => entry.key === id) + if (item) input.onContextRemove?.(item) + draft.removeContext(id) + if (state.activeContextID === id) dispatch({ type: "context.active", id }) + }, + openAttachment(attachment: PromptInputV2Attachment) { + input.openAttachment?.(attachment) + }, + removeAttachment(id: string) { + draft.removeAttachment(id) + }, + canSubmit() { + const persisted = draft.state + if (persisted.prompt.some((part) => part.type === "image")) return true + if (persisted.context.items.some((item) => !!item.comment?.trim())) return true + return persisted.prompt.some((part) => "content" in part && !!part.content.trim()) + }, + setEditor(element: HTMLElement) { + editor = element + input.onEditor?.(element) + }, + restoreFocus, + onInput(value: string, prompt?: PromptInputV2PersistedState["prompt"], cursor?: number) { + if (prompt) draft.setPrompt(prompt, cursor) + dispatch({ type: "input.changed", value, persist: !prompt }) + }, + onCursor(cursor: number) { + draft.setCursor(cursor) + }, + openCommands() { + dispatch({ type: "commands.open" }) + }, + openContext() { + dispatch({ type: "context.open" }) + }, + openShell() { + dispatch({ type: "mode.shell" }) + }, + closeShell() { + dispatch({ type: "mode.normal" }) + }, + submit() { + input.view.submit.onSubmit() + dispatch({ type: "popover.close" }) + }, + stop() { + input.view.submit.onStop() + }, + addHistory(prompt: PromptInputV2PersistedState["prompt"], mode: "normal" | "shell") { + input.history?.add(prompt, mode) + setState({ historyIndex: -1, savedHistory: undefined }) + }, + resetHistory() { + setState({ historyIndex: -1, savedHistory: undefined }) + }, + onPaste(event: ClipboardEvent) { + const clipboard = event.clipboardData + if ( + attachments && + (Array.from(clipboard?.items ?? []).some((item) => item.kind === "file") || !clipboard?.getData("text/plain")) + ) { + void attachments.handlePaste(event) + return + } + input.view.onPaste?.(event) + if (event.defaultPrevented) return + const text = clipboard?.getData("text/plain") + if (!text) return + event.preventDefault() + if (typeof document.execCommand === "function" && document.execCommand("insertText", false, text)) return + const target = event.currentTarget + const selection = window.getSelection() + if (!(target instanceof HTMLElement) || !selection?.rangeCount || !target.contains(selection.anchorNode)) return + const range = selection.getRangeAt(0) + range.deleteContents() + const node = document.createTextNode(text) + range.insertNode(node) + range.setStartAfter(node) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: text })) + }, + onDragEnter(event: DragEvent) { + event.preventDefault() + dispatch({ type: "drag.enter" }) + }, + onDragOver(event: DragEvent) { + event.preventDefault() + }, + onDragLeave() { + dispatch({ type: "drag.leave" }) + }, + onDrop(event: DragEvent) { + event.preventDefault() + dispatch({ type: "drag.leave" }) + if (attachments) { + event.stopPropagation() + void attachments.handleDrop(event) + return + } + input.view.onDrop?.(event) + }, + attach, + setFileInput(element: HTMLInputElement) { + fileInput = element + }, + addAttachments(files: File[]) { + if (attachments) void attachments.addAttachments(files) + }, + setQuery(value: string) { + dispatch({ type: "popover.query", value }) + }, + } +} + +export type PromptInputV2Interaction = ReturnType + +function canNavigateHistory(direction: "up" | "down", text: string, cursor: number, inHistory: boolean) { + const position = Math.max(0, Math.min(cursor, text.length)) + if (inHistory) return position === 0 || position === text.length + if (direction === "up") return position === 0 && text.length === 0 + return position === text.length +} + +function clonePrompt(prompt: PromptInputV2PersistedState["prompt"]): PromptInputV2PersistedState["prompt"] { + return prompt.map((part) => + part.type === "file" ? { ...part, selection: part.selection ? { ...part.selection } : undefined } : { ...part }, + ) +} + +function promptLength(prompt: PromptInputV2PersistedState["prompt"]) { + return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0) +} + +function editorCursor(editor: HTMLElement) { + const selection = window.getSelection() + if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0 + const range = selection.getRangeAt(0).cloneRange() + range.selectNodeContents(editor) + range.setEnd(selection.anchorNode!, selection.anchorOffset) + return range.toString().length +} + +function setEditorCursor(editor: HTMLElement | undefined, cursor: number) { + if (!editor) return + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let remaining = cursor + let node = walker.nextNode() + while (node) { + const length = node.textContent?.length ?? 0 + if (remaining <= length) { + const range = document.createRange() + range.setStart(node, remaining) + range.collapse(true) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + return + } + remaining -= length + node = walker.nextNode() + } +} diff --git a/packages/session-ui/src/v2/components/prompt-input/machine.test.ts b/packages/session-ui/src/v2/components/prompt-input/machine.test.ts new file mode 100644 index 0000000000..e36982666e --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/machine.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from "bun:test" +import type { PromptInputV2PersistedState, PromptInputV2Suggestion } from "./types" +import { createPromptInputV2InteractionState, transitionPromptInputV2 } from "./machine" + +const command: PromptInputV2Suggestion = { + id: "review", + kind: "command", + label: "/review", +} + +function persisted(value = ""): PromptInputV2PersistedState { + return { + prompt: [{ type: "text", content: value, start: 0, end: value.length }], + cursor: value.length, + context: { items: [] }, + } +} + +describe("prompt input v2 interaction machine", () => { + test("opens inline commands only when slash is the entire prompt", () => { + const state = createPromptInputV2InteractionState() + const open = transitionPromptInputV2(state, { type: "input.changed", value: "/re" }, persisted()) + const closed = transitionPromptInputV2(state, { type: "input.changed", value: "explain /re" }, persisted()) + + expect(open.state.popover).toEqual({ type: "command-inline", query: "re" }) + expect(closed.state.popover).toEqual({ type: "closed" }) + }) + + test("completes nested slash command names", () => { + const open = transitionPromptInputV2( + createPromptInputV2InteractionState(), + { type: "input.changed", value: "/review/" }, + persisted(), + ) + const item = { ...command, label: "/review/nested" } + const selected = transitionPromptInputV2(open.state, { type: "popover.select", item }, persisted("/review/")) + + expect(open.state.popover).toEqual({ type: "command-inline", query: "review/" }) + expect(selected.commands).toContainEqual({ type: "draft.setText", value: "/review/nested " }) + }) + + test("opens context completion at the cursor", () => { + const value = "alpha @sr omega" + const input = persisted(value) + input.cursor = 9 + + const result = transitionPromptInputV2( + createPromptInputV2InteractionState(), + { type: "input.changed", value, persist: false }, + input, + ) + + expect(result.state.popover).toEqual({ type: "context", query: "sr" }) + }) + + test("enters shell mode from an initial exclamation mark", () => { + const result = transitionPromptInputV2( + createPromptInputV2InteractionState(), + { type: "input.changed", value: "!", persist: false }, + persisted("!"), + ) + + expect(result.state.mode).toBe("shell") + expect(result.commands).toContainEqual({ type: "draft.setText", value: "" }) + }) + + test("leaves shell mode with escape", () => { + const state = { ...createPromptInputV2InteractionState(), mode: "shell" as const } + const result = transitionPromptInputV2( + state, + { type: "key.down", key: "Escape", ctrl: false, composing: false, ids: [] }, + persisted(), + ) + + expect(result.state.mode).toBe("normal") + expect(result.handled).toBeTrue() + }) + + test("leaves shell mode with backspace when empty", () => { + const state = { ...createPromptInputV2InteractionState(), mode: "shell" as const } + const result = transitionPromptInputV2( + state, + { type: "key.down", key: "Backspace", ctrl: false, composing: false, ids: [], empty: true }, + persisted(), + ) + + expect(result.state.mode).toBe("normal") + expect(result.handled).toBeTrue() + }) + + test("closes a popover with ctrl-g before stopping a run", () => { + const state = { + ...createPromptInputV2InteractionState(), + popover: { type: "context" as const, query: "", activeID: "first" }, + } + const result = transitionPromptInputV2( + state, + { type: "key.down", key: "g", ctrl: true, composing: false, ids: ["first"] }, + persisted(), + ) + + expect(result.state.popover).toEqual({ type: "closed" }) + expect(result.handled).toBeTrue() + }) + + test("opens the searchable command menu for a populated draft", () => { + const result = transitionPromptInputV2( + createPromptInputV2InteractionState(), + { type: "commands.open" }, + persisted("existing text"), + ) + + expect(result.state.popover).toEqual({ type: "command-menu", query: "" }) + expect(result.state.focus).toBe("command-search") + }) + + test("prepends a menu command and preserves existing text as arguments", () => { + const open = transitionPromptInputV2( + createPromptInputV2InteractionState(), + { type: "commands.open" }, + persisted("existing text"), + ) + const selected = transitionPromptInputV2( + open.state, + { type: "popover.select", item: command }, + persisted("existing text"), + ) + + expect(selected.commands).toContainEqual({ type: "draft.setText", value: "/review existing text" }) + expect(selected.state.popover).toEqual({ type: "closed" }) + }) + + test("stores selected context files as prompt file parts", () => { + const item: PromptInputV2Suggestion = { + id: "src/index.ts", + kind: "file", + label: "index.ts", + path: "src/index.ts", + } + const state = { + ...createPromptInputV2InteractionState(), + popover: { type: "context" as const, query: "index" }, + } + + const selected = transitionPromptInputV2(state, { type: "popover.select", item }, persisted("@index")) + + expect(selected.commands).toContainEqual({ type: "mention.add", item }) + }) + + test("loops active popover items with arrow keys", () => { + const state = { + ...createPromptInputV2InteractionState(), + popover: { type: "context" as const, query: "", activeID: "second" }, + } + const result = transitionPromptInputV2( + state, + { type: "key.down", key: "ArrowDown", ctrl: false, composing: false, ids: ["first", "second"] }, + persisted(), + ) + + expect(result.state.popover).toEqual({ type: "context", query: "", activeID: "first" }) + expect(result.handled).toBeTrue() + }) +}) diff --git a/packages/session-ui/src/v2/components/prompt-input/machine.ts b/packages/session-ui/src/v2/components/prompt-input/machine.ts new file mode 100644 index 0000000000..d2508e249c --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/machine.ts @@ -0,0 +1,261 @@ +import type { PromptInputV2HistoryEntry, PromptInputV2PersistedState, PromptInputV2Suggestion } from "./types" + +export type PromptInputV2InteractionState = { + mode: "normal" | "shell" + popover: + | { type: "closed" } + | { type: "context"; query: string; activeID?: string } + | { type: "command-inline"; query: string; activeID?: string } + | { type: "command-menu"; query: string; activeID?: string } + drag: "idle" | "active" + focus: "editor" | "command-search" | "external" + activeContextID?: string + historyIndex: number + savedHistory?: PromptInputV2HistoryEntry +} + +export type PromptInputV2InteractionEvent = + | { type: "input.changed"; value: string; persist?: boolean } + | { type: "commands.open" } + | { type: "context.open" } + | { type: "popover.query"; value: string } + | { type: "popover.results"; ids: string[] } + | { type: "popover.active"; id: string } + | { type: "popover.close" } + | { type: "popover.select"; item: PromptInputV2Suggestion } + | { type: "key.down"; key: string; ctrl: boolean; composing: boolean; ids: string[]; empty?: boolean } + | { type: "mode.shell" } + | { type: "mode.normal" } + | { type: "drag.enter" } + | { type: "drag.leave" } + | { type: "focus.editor" } + | { type: "focus.external" } + | { type: "context.active"; id: string } + +export type PromptInputV2InteractionCommand = + | { type: "draft.setText"; value: string } + | { type: "mention.add"; item: PromptInputV2Suggestion } + | { type: "popover.filter"; popover: "command" | "context"; query: string } + | { type: "suggestion.select"; id: string } + | { type: "focus.editor" } + | { type: "focus.command-search" } + +export type PromptInputV2Transition = { + state: PromptInputV2InteractionState + commands: PromptInputV2InteractionCommand[] + handled: boolean +} + +export function createPromptInputV2InteractionState(): PromptInputV2InteractionState { + return { + mode: "normal", + popover: { type: "closed" }, + drag: "idle", + focus: "external", + historyIndex: -1, + } +} + +export function transitionPromptInputV2( + state: PromptInputV2InteractionState, + event: PromptInputV2InteractionEvent, + persisted: PromptInputV2PersistedState, +): PromptInputV2Transition { + if (event.type === "input.changed") return inputChanged(state, event.value, event.persist !== false, persisted.cursor) + if (event.type === "commands.open") return openCommands(state, persisted) + if (event.type === "context.open") return openContext(state, persisted) + if (event.type === "popover.query") return queryChanged(state, event.value) + if (event.type === "popover.results") return resultsChanged(state, event.ids) + if (event.type === "popover.active") return activeChanged(state, event.id) + if (event.type === "popover.close") return changed({ ...state, popover: { type: "closed" } }) + if (event.type === "popover.select") return suggestionSelected(state, event.item, persisted) + if (event.type === "key.down") return keyDown(state, event) + if (event.type === "mode.shell") return changed({ ...state, mode: "shell", popover: { type: "closed" } }) + if (event.type === "mode.normal") return changed({ ...state, mode: "normal" }) + if (event.type === "drag.enter") return changed({ ...state, drag: "active" }) + if (event.type === "drag.leave") return changed({ ...state, drag: "idle" }) + if (event.type === "focus.editor") return changed({ ...state, focus: "editor" }) + if (event.type === "context.active") { + return changed({ ...state, activeContextID: state.activeContextID === event.id ? undefined : event.id }) + } + return changed({ ...state, focus: "external" }) +} + +function inputChanged( + state: PromptInputV2InteractionState, + value: string, + persist: boolean, + cursor: number | undefined, +): PromptInputV2Transition { + const setText: PromptInputV2InteractionCommand[] = persist ? [{ type: "draft.setText", value }] : [] + if (state.mode === "normal" && value === "!") { + return changed({ ...state, mode: "shell", popover: { type: "closed" }, focus: "editor" }, [ + { type: "draft.setText", value: "" }, + ]) + } + const context = value.slice(0, cursor ?? value.length).match(/(?:^|\s)@([^\s@]*)$/) + if (context) { + const query = context[1] ?? "" + return changed({ ...state, popover: { type: "context", query }, focus: "editor" }, [ + ...setText, + { type: "popover.filter", popover: "context", query }, + ]) + } + + const command = value.match(/^\/(\S*)$/) + if (command) { + const query = command[1] ?? "" + return changed({ ...state, popover: { type: "command-inline", query }, focus: "editor" }, [ + ...setText, + { type: "popover.filter", popover: "command", query }, + ]) + } + + return changed( + { ...state, popover: state.popover.type === "command-menu" ? state.popover : { type: "closed" }, focus: "editor" }, + setText, + ) +} + +function openCommands( + state: PromptInputV2InteractionState, + persisted: PromptInputV2PersistedState, +): PromptInputV2Transition { + if (!populated(persisted)) { + return changed({ ...state, popover: { type: "command-inline", query: "" }, focus: "editor" }, [ + { type: "draft.setText", value: promptText(persisted) + "/" }, + { type: "popover.filter", popover: "command", query: "" }, + { type: "focus.editor" }, + ]) + } + return changed({ ...state, popover: { type: "command-menu", query: "" }, focus: "command-search" }, [ + { type: "popover.filter", popover: "command", query: "" }, + { type: "focus.command-search" }, + ]) +} + +function openContext( + state: PromptInputV2InteractionState, + persisted: PromptInputV2PersistedState, +): PromptInputV2Transition { + return changed({ ...state, popover: { type: "context", query: "" }, focus: "editor" }, [ + { type: "draft.setText", value: promptText(persisted) + "@" }, + { type: "popover.filter", popover: "context", query: "" }, + { type: "focus.editor" }, + ]) +} + +function queryChanged(state: PromptInputV2InteractionState, query: string): PromptInputV2Transition { + if (state.popover.type === "closed") return unchanged(state) + const popover = state.popover.type === "context" ? "context" : "command" + return changed({ ...state, popover: { ...state.popover, query, activeID: undefined } }, [ + { type: "popover.filter", popover, query }, + ]) +} + +function resultsChanged(state: PromptInputV2InteractionState, ids: string[]): PromptInputV2Transition { + if (state.popover.type === "closed") return unchanged(state) + const activeID = state.popover.activeID && ids.includes(state.popover.activeID) ? state.popover.activeID : ids[0] + if (activeID === state.popover.activeID) return unchanged(state) + return changed({ ...state, popover: { ...state.popover, activeID } }) +} + +function activeChanged(state: PromptInputV2InteractionState, id: string): PromptInputV2Transition { + if (state.popover.type === "closed" || state.popover.activeID === id) return unchanged(state) + return changed({ ...state, popover: { ...state.popover, activeID: id } }) +} + +function suggestionSelected( + state: PromptInputV2InteractionState, + item: PromptInputV2Suggestion, + persisted: PromptInputV2PersistedState, +): PromptInputV2Transition { + const current = promptText(persisted) + const commands: PromptInputV2InteractionCommand[] = [] + if (item.kind === "command") { + commands.push({ + type: "draft.setText", + value: + state.popover.type === "command-menu" + ? current.trim() + ? `${item.label} ${current.trim()}` + : `${item.label} ` + : replaceTrigger(current, "/", `${item.label} `), + }) + } else { + commands.push({ type: "mention.add", item }) + } + commands.push({ type: "focus.editor" }) + return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, commands) +} + +function keyDown( + state: PromptInputV2InteractionState, + event: Extract, +): PromptInputV2Transition { + if (event.ctrl && event.key.toLowerCase() === "g") { + if (state.popover.type === "closed") return unchanged(state) + return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, [{ type: "focus.editor" }], true) + } + if (state.popover.type === "closed") { + if (state.mode === "shell" && (event.key === "Escape" || (event.key === "Backspace" && event.empty))) { + return changed({ ...state, mode: "normal" }, [], true) + } + return unchanged(state) + } + if (event.key === "Escape") { + return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, [{ type: "focus.editor" }], true) + } + if (event.key === "Tab" || (event.key === "Enter" && !event.composing)) { + if (!state.popover.activeID) return unchanged(state, true) + return unchanged(state, true, [{ type: "suggestion.select", id: state.popover.activeID }]) + } + const direction = + event.key === "ArrowDown" || (event.ctrl && event.key === "n") + ? 1 + : event.key === "ArrowUp" || (event.ctrl && event.key === "p") + ? -1 + : 0 + if (!direction || event.ids.length === 0) return unchanged(state) + const current = state.popover.activeID ? event.ids.indexOf(state.popover.activeID) : -1 + const index = + current < 0 + ? direction === 1 + ? 0 + : event.ids.length - 1 + : (current + direction + event.ids.length) % event.ids.length + return changed({ ...state, popover: { ...state.popover, activeID: event.ids[index] } }, [], true) +} + +function promptText(persisted: PromptInputV2PersistedState) { + return persisted.prompt.map((part) => (part.type === "text" ? part.content : "")).join("") +} + +function populated(persisted: PromptInputV2PersistedState) { + return ( + !!promptText(persisted).trim() || + persisted.context.items.length > 0 || + persisted.prompt.some((part) => part.type === "file" || part.type === "image") + ) +} + +function replaceTrigger(value: string, trigger: "@" | "/", replacement: string) { + const index = trigger === "/" ? value.indexOf(trigger) : value.lastIndexOf(trigger) + return index < 0 ? replacement : value.slice(0, index) + replacement +} + +function changed( + state: PromptInputV2InteractionState, + commands: PromptInputV2InteractionCommand[] = [], + handled = false, +): PromptInputV2Transition { + return { state, commands, handled } +} + +function unchanged( + state: PromptInputV2InteractionState, + handled = false, + commands: PromptInputV2InteractionCommand[] = [], +): PromptInputV2Transition { + return { state, commands, handled } +} diff --git a/packages/session-ui/src/v2/components/prompt-input/prompt-input.stories.tsx b/packages/session-ui/src/v2/components/prompt-input/prompt-input.stories.tsx new file mode 100644 index 0000000000..6c535b0284 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/prompt-input.stories.tsx @@ -0,0 +1,221 @@ +// @ts-nocheck +import { createStore } from "solid-js/store" +import { PromptInputV2, type PromptInputV2PersistedState, type PromptInputV2Suggestion } from "." +import { createPromptInputV2Controller } from "./interaction" +import { createPromptInputV2Store } from "./store" +import { createEffect } from "solid-js" + +const agents = [ + { id: "build", label: "Build" }, + { id: "plan", label: "Plan" }, + { id: "review", label: "Review" }, +] + +const variants = [ + { id: "default", label: "Default" }, + { id: "fast", label: "Fast" }, + { id: "thinking", label: "Thinking" }, +] + +const models = [ + { id: "claude-sonnet", name: "Claude Sonnet", providerID: "anthropic" }, + { id: "gpt-5", name: "GPT-5", providerID: "openai" }, + { id: "gemini-pro", name: "Gemini Pro", providerID: "google" }, +] + +const contextSuggestions: PromptInputV2Suggestion[] = [ + { + id: "file-prompt", + kind: "file", + label: "prompt-input-v2.tsx", + path: "src/components/prompt-input-v2.tsx", + recent: true, + mention: { + type: "file", + path: "src/components/prompt-input-v2.tsx", + content: "@src/components/prompt-input-v2.tsx", + start: 0, + end: 0, + }, + }, + { + id: "file-story", + kind: "file", + label: "prompt-input-v2.stories.tsx", + path: "src/components/prompt-input-v2.stories.tsx", + mention: { + type: "file", + path: "src/components/prompt-input-v2.stories.tsx", + content: "@src/components/prompt-input-v2.stories.tsx", + start: 0, + end: 0, + }, + }, + { + id: "agent-review", + kind: "agent", + label: "@review", + description: "Ask the review agent", + mention: { type: "agent", name: "review", content: "@review", start: 0, end: 0 }, + }, + { + id: "reference-docs", + kind: "reference", + label: "@UI guidelines", + path: "docs/ui.md", + description: "Project reference", + mention: { + type: "file", + path: "docs/ui.md", + content: "@UI guidelines", + start: 0, + end: 0, + mime: "application/x-directory", + filename: "UI guidelines", + }, + }, +] + +const commandSuggestions: PromptInputV2Suggestion[] = [ + { + id: "command-fix", + kind: "command", + label: "/fix", + trigger: "fix", + title: "Fix", + description: "Fix the current issue", + keybind: ["Enter"], + }, + { + id: "command-review", + kind: "command", + label: "/review", + trigger: "review", + title: "Review", + description: "Review pending changes", + }, + { + id: "command-test", + kind: "command", + label: "/test", + trigger: "test", + title: "Test", + description: "Run relevant tests", + }, +] + +function ControlledPromptInput() { + // Agent choice is a persisted user/workspace preference in v1, not part of PromptStore. + const [preferences, setPreferences] = createStore({ agent: "build" }) + + const [runtime, setRuntime] = createStore({ + stopping: false, + }) + + // This matches the v1 PromptStore and can use the same persistence boundary. + const state = createStore({ + prompt: [ + { type: "text", content: "", start: 0, end: 0 }, + { + type: "image", + id: "attachment-1", + filename: "requirements.md", + mime: "text/markdown", + blob: { id: "requirements", url: "data:text/markdown;base64,IyBSZXF1aXJlbWVudHM=" }, + }, + ], + cursor: 0, + model: { providerID: "anthropic", modelID: "claude-sonnet", variant: null }, + context: { + items: [ + { + key: "file:src/components/prompt-input-v2.tsx:1:40", + type: "file", + path: "src/components/prompt-input-v2.tsx", + selection: { startLine: 1, startChar: 0, endLine: 40, endChar: 0 }, + comment: "Keep this component context-free", + }, + ], + }, + }) + const store = createPromptInputV2Store(state) + + const controller = createPromptInputV2Controller({ + store: state, + commands: () => commandSuggestions, + context: () => contextSuggestions, + searchContextFiles: (query) => { + const needle = query.trim().toLowerCase() + return contextSuggestions.filter( + (item) => item.kind === "file" && `${item.label} ${item.path ?? ""}`.toLowerCase().includes(needle), + ) + }, + view: { + add: { + onAttach: () => addAttachment("architecture.txt", "text/plain"), + }, + agent: { + options: () => agents, + current: () => preferences.agent, + onSelect: (agent) => setPreferences("agent", agent), + }, + model: { + options: () => models.map((model) => ({ id: model.id, label: model.name, providerID: model.providerID })), + current: () => + models.find( + (model) => model.id === store.state.model?.modelID && model.providerID === store.state.model?.providerID, + )?.id ?? "", + onSelect(id) { + const model = models.find((item) => item.id === id) + if (!model) return + store.setModel({ + providerID: model.providerID, + modelID: model.id, + variant: store.state.model?.variant, + }) + }, + }, + variant: { + options: () => variants, + current: () => store.state.model?.variant ?? "default", + onSelect: (variant) => store.setVariant(variant === "default" ? null : variant), + }, + submit: { + stopping: () => runtime.stopping, + working: () => runtime.stopping, + onSubmit: () => { + store.reset() + setRuntime("stopping", true) + }, + onStop: () => setRuntime("stopping", false), + }, + onDrop: (event) => addAttachment(event.dataTransfer?.files[0]?.name ?? "dropped-file.txt", "text/plain"), + }, + }) + + const addAttachment = (filename: string, mime: string) => { + store.addAttachment({ + type: "image", + id: `attachment-${store.state.prompt.filter((part) => part.type === "image").length + 1}`, + filename, + mime, + blob: { id: filename, url: `data:${mime};base64,` }, + }) + } + + return ( +
    + +
    + ) +} + +export default { + title: "Session UI/PromptInputV2", + id: "session-ui-prompt-input-v2", + component: PromptInputV2, +} + +export const ControlledComposition = { + render: () => , +} diff --git a/packages/session-ui/src/v2/components/prompt-input/store.test.ts b/packages/session-ui/src/v2/components/prompt-input/store.test.ts new file mode 100644 index 0000000000..768a222450 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/store.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "bun:test" +import { createStore } from "solid-js/store" +import type { PromptInputV2PersistedState } from "./types" +import { createPromptInputV2Store } from "./store" + +function createPromptStore() { + return createPromptInputV2Store( + createStore({ + prompt: [ + { type: "text", content: "old", start: 0, end: 3 }, + { + type: "image", + id: "attachment-1", + filename: "notes.txt", + mime: "text/plain", + blob: { id: "a", url: "blob:a" }, + }, + ], + cursor: 3, + model: { providerID: "anthropic", modelID: "claude-sonnet", variant: null }, + context: { items: [] }, + }), + ) +} + +describe("prompt input v2 store", () => { + test("accepts an accessor for the backing store", () => { + const [state, setState] = createStore({ + prompt: [{ type: "text", content: "", start: 0, end: 0 }], + cursor: 0, + context: { items: [] }, + }) + const prompt = createPromptInputV2Store([() => state, setState]) + + prompt.setText("accessed") + + expect(prompt.state.prompt).toEqual([{ type: "text", content: "accessed", start: 0, end: 8 }]) + expect(prompt.state.cursor).toBe(8) + }) + + test("updates prompt text and cursor together while preserving attachments", () => { + const prompt = createPromptStore() + + prompt.setText("updated") + + expect(prompt.state.prompt).toEqual([ + { type: "text", content: "updated", start: 0, end: 7 }, + { + type: "image", + id: "attachment-1", + filename: "notes.txt", + mime: "text/plain", + blob: { id: "a", url: "blob:a" }, + }, + ]) + expect(prompt.state.cursor).toBe(7) + }) + + test("inserts text without flattening structured mentions", () => { + const [state, setState] = createStore({ + prompt: [ + { type: "text", content: "A ", start: 0, end: 2 }, + { type: "file", path: "one", content: "@one", start: 2, end: 6 }, + { type: "text", content: " B", start: 6, end: 8 }, + ], + cursor: 2, + context: { items: [] }, + }) + const prompt = createPromptInputV2Store([state, setState]) + + prompt.addText("X\nY") + + expect(prompt.state.prompt).toEqual([ + { type: "text", content: "A X\nY", start: 0, end: 5 }, + { type: "file", path: "one", content: "@one", start: 5, end: 9 }, + { type: "text", content: " B", start: 9, end: 11 }, + ]) + expect(prompt.state.cursor).toBe(5) + }) + + test("mutates context, attachments, and model through shared actions", () => { + const prompt = createPromptStore() + const context = { key: "file:src/index.ts", type: "file" as const, path: "src/index.ts" } + + prompt.addContext(context) + prompt.addContext(context) + prompt.addMention({ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 0 }) + prompt.removeAttachment("attachment-1") + prompt.setVariant("thinking") + + expect(prompt.state.context.items).toEqual([context]) + expect(prompt.state.prompt).toEqual([ + { type: "text", content: "old", start: 0, end: 3 }, + { type: "file", path: "src/app.ts", content: "@src/app.ts", start: 3, end: 14 }, + { type: "text", content: " ", start: 14, end: 15 }, + ]) + expect(prompt.state.model?.variant).toBe("thinking") + + prompt.removeContext(context.key) + prompt.setPrompt([{ type: "text", content: "old", start: 0, end: 3 }], 3) + prompt.setModel(undefined) + + expect(prompt.state.context.items).toEqual([]) + expect(prompt.state.prompt).toEqual([{ type: "text", content: "old", start: 0, end: 3 }]) + expect(prompt.state.model).toBeUndefined() + }) + + test("resets the prompt and cursor", () => { + const prompt = createPromptStore() + + prompt.reset() + + expect(prompt.state.prompt).toEqual([{ type: "text", content: "", start: 0, end: 0 }]) + expect(prompt.state.cursor).toBe(0) + }) +}) diff --git a/packages/session-ui/src/v2/components/prompt-input/store.ts b/packages/session-ui/src/v2/components/prompt-input/store.ts new file mode 100644 index 0000000000..5a27de6c4d --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/store.ts @@ -0,0 +1,152 @@ +import { batch, type Accessor } from "solid-js" +import type { SetStoreFunction, Store } from "solid-js/store" +import type { + PromptInputV2AgentPart, + PromptInputV2Attachment, + PromptInputV2Comment, + PromptInputV2FilePart, + PromptInputV2Model, + PromptInputV2PersistedState, + PromptInputV2Prompt, +} from "./types" + +export type PromptInputV2StoreTuple = [ + Store | Accessor>, + SetStoreFunction, +] + +export type PromptInputV2StoreInput = PromptInputV2StoreTuple | Accessor + +export function createPromptInputV2Store(input: PromptInputV2StoreInput) { + const tuple = () => (typeof input === "function" ? input() : input) + const store = () => { + const value = tuple()[0] + return typeof value === "function" ? value() : value + } + const setStore = () => tuple()[1] + + return { + get state() { + return store() + }, + setPrompt(prompt: PromptInputV2Prompt, cursor?: number) { + batch(() => { + setStore()("prompt", prompt) + if (cursor !== undefined) setStore()("cursor", cursor) + }) + }, + setCursor(cursor: number) { + setStore()("cursor", cursor) + }, + setText(content: string) { + batch(() => { + setStore()("prompt", (prompt) => [ + { type: "text", content, start: 0, end: content.length }, + ...prompt.filter((part) => part.type !== "text"), + ]) + setStore()("cursor", content.length) + }) + }, + addText(content: string) { + const cursor = store().cursor ?? promptLength(store().prompt) + batch(() => { + setStore()("prompt", (prompt) => insertText(prompt, cursor, content)) + setStore()("cursor", cursor + content.length) + }) + }, + reset() { + batch(() => { + setStore()("prompt", [{ type: "text", content: "", start: 0, end: 0 }]) + setStore()("cursor", 0) + }) + }, + setModel(model: PromptInputV2Model | undefined) { + setStore()("model", model) + }, + setVariant(variant: string | null) { + if (store().model) setStore()("model", "variant", variant) + }, + addContext(item: PromptInputV2Comment) { + if (store().context.items.some((entry) => entry.key === item.key)) return + setStore()("context", "items", (items) => [...items, item]) + }, + removeContext(key: string) { + setStore()("context", "items", (items) => items.filter((item) => item.key !== key)) + }, + addMention(mention: PromptInputV2FilePart | PromptInputV2AgentPart) { + const text = store() + .prompt.map((part) => ("content" in part ? part.content : "")) + .join("") + const end = store().cursor ?? text.length + const start = text.slice(0, end).lastIndexOf("@") + setStore()("prompt", insertMention(store().prompt, start < 0 ? end : start, end, mention)) + setStore()("cursor", (start < 0 ? end : start) + mention.content.length + 1) + }, + addAttachment(attachment: PromptInputV2Attachment) { + setStore()("prompt", (prompt) => [...prompt, attachment]) + }, + removeAttachment(id: string) { + setStore()("prompt", (parts) => parts.filter((part) => part.type !== "image" || part.id !== id)) + }, + } +} + +export type PromptInputV2Store = ReturnType + +function insertText(prompt: PromptInputV2Prompt, cursor: number, content: string): PromptInputV2Prompt { + let position = 0 + let inserted = false + const parts = prompt.flatMap((part) => { + if (part.type === "image") return [part] + const start = position + position += part.content.length + if (inserted) return [part] + if (part.type === "text" && cursor >= start && cursor <= position) { + inserted = true + const offset = cursor - start + return [{ ...part, content: part.content.slice(0, offset) + content + part.content.slice(offset) }] + } + if (cursor > start) return [part] + inserted = true + return [{ type: "text", content, start: 0, end: 0 }, part] + }) + if (!inserted) parts.push({ type: "text", content, start: 0, end: 0 }) + return withOffsets(parts) +} + +function insertMention( + prompt: PromptInputV2Prompt, + start: number, + end: number, + mention: PromptInputV2FilePart | PromptInputV2AgentPart, +): PromptInputV2Prompt { + let position = 0 + const parts = prompt.flatMap((part) => { + if (part.type === "image") return [part] + const partStart = position + position += part.content.length + if (part.type !== "text" || start < partStart || end > position) return [part] + const before = part.content.slice(0, start - partStart) + const after = part.content.slice(end - partStart) + return [ + ...(before ? [{ type: "text" as const, content: before, start: 0, end: 0 }] : []), + mention, + { type: "text" as const, content: ` ${after}`, start: 0, end: 0 }, + ] + }) + return withOffsets(parts) +} + +function withOffsets(prompt: PromptInputV2Prompt): PromptInputV2Prompt { + let offset = 0 + return prompt.map((part) => { + if (part.type === "image") return part + const next = { ...part, start: offset, end: offset + part.content.length } + offset = next.end + return next + }) +} + +function promptLength(prompt: PromptInputV2Prompt) { + return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0) +} diff --git a/packages/session-ui/src/v2/components/prompt-input/types.ts b/packages/session-ui/src/v2/components/prompt-input/types.ts new file mode 100644 index 0000000000..c6b47a92ce --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/types.ts @@ -0,0 +1,106 @@ +import type { FilePartSource } from "@opencode-ai/sdk/v2/client" + +type PromptInputV2PartBase = { + content: string + start: number + end: number +} + +export type PromptInputV2TextPart = PromptInputV2PartBase & { + type: "text" +} + +export type PromptInputV2FilePart = PromptInputV2PartBase & { + type: "file" + path: string + selection?: PromptInputV2Selection + mime?: string + filename?: string + url?: string + source?: FilePartSource +} + +export type PromptInputV2AgentPart = PromptInputV2PartBase & { + type: "agent" + name: string +} + +export type PromptInputV2Attachment = { + type: "image" + id: string + filename: string + sourcePath?: string + mime: string + blob: { id: string; url: string } +} + +export type PromptInputV2Prompt = ( + | PromptInputV2TextPart + | PromptInputV2FilePart + | PromptInputV2AgentPart + | PromptInputV2Attachment +)[] + +export type PromptInputV2Model = { + providerID: string + modelID: string + variant?: string | null +} + +export type PromptInputV2Selection = { + startLine: number + startChar: number + endLine: number + endChar: number +} + +export type PromptInputV2Comment = { + type: "file" + key: string + path: string + selection?: PromptInputV2Selection + comment?: string + commentID?: string + commentOrigin?: "review" | "file" + preview?: string +} + +export type PromptInputV2PersistedState = { + prompt: PromptInputV2Prompt + cursor?: number + model?: PromptInputV2Model + context: { + items: PromptInputV2Comment[] + } +} + +export type PromptInputV2HistoryEntry = { + prompt: PromptInputV2Prompt + metadata?: unknown +} + +export type PromptInputV2History = { + entries: (mode: "normal" | "shell") => PromptInputV2HistoryEntry[] + add: (prompt: PromptInputV2Prompt, mode: "normal" | "shell") => void + capture?: () => unknown + restore?: (metadata: unknown) => void +} + +export type PromptInputV2Option = { + id: string + label: string + providerID?: string +} + +export type PromptInputV2Suggestion = { + id: string + kind: "agent" | "command" | "file" | "reference" | "resource" + label: string + title?: string + trigger?: string + description?: string + path?: string + keybind?: string[] + recent?: boolean + mention?: PromptInputV2FilePart | PromptInputV2AgentPart +} diff --git a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx index 3e7bd83051..ba276a8f52 100644 --- a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx @@ -7,6 +7,7 @@ import { useI18n } from "@opencode-ai/ui/context/i18n" import { mediaKindFromPath } from "../../pierre/media" import { cloneSelectedLineRange, previewSelectedLines } from "../../pierre/selection-bridge" import type { FileContent, SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" @@ -27,7 +28,7 @@ import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import "./session-review-v2.css" -type ReviewDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff +type ReviewDiff = (SnapshotFileDiff & { file: string }) | FileDiffInfo | VcsFileDiff export type SessionReviewFilePreviewV2Props = { file: string @@ -263,7 +264,9 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop {getDirectory(props.file)}
    - +
    + +
    { diff --git a/packages/session-ui/src/v2/components/session-review-v2.css b/packages/session-ui/src/v2/components/session-review-v2.css index b116ebb43f..704e8fd7d7 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); @@ -62,7 +63,7 @@ align-items: center; justify-content: space-between; gap: 8px; - padding: 12px 16px 8px 8px; + padding: 12px 16px 12px 8px; flex-shrink: 0; } @@ -76,6 +77,11 @@ color: var(--text-strong, var(--v2-text-text-base)); } +[data-component="session-review-v2-sidebar-root"] + [data-slot="session-review-v2-sidebar-title"]:not(:has([data-component="select-v2-root"])) { + margin-left: 8px; +} + [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-title"] [data-component="select-v2-root"] { @@ -85,7 +91,7 @@ } [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-filter"] { - padding: 0 8px 8px; + padding: 0 8px 4px; flex-shrink: 0; min-width: 0; box-sizing: border-box; @@ -97,16 +103,77 @@ width: 100%; max-width: 100%; min-width: 0; - margin-top: 2px; + margin-top: 3px; } [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] { flex: 1; min-height: 0; + timeline-scope: --session-review-v2-sidebar-tree-scroll; +} + +@keyframes session-review-v2-sidebar-tree-fade-top { + from { + visibility: hidden; + } + to { + visibility: visible; + } +} + +@keyframes session-review-v2-sidebar-tree-fade-bottom { + from { + visibility: visible; + } + to { + visibility: hidden; + } +} + +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::before, +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::after { + content: ""; + position: absolute; + inset-inline: 0; + z-index: 10; + height: 16px; + pointer-events: none; + visibility: hidden; +} + +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::before { + top: 0; + background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent); +} + +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::after { + bottom: 0; + background: linear-gradient(to top, var(--v2-background-bg-base), transparent); +} + +@supports (animation-timeline: --session-review-v2-sidebar-tree-scroll) and + (timeline-scope: --session-review-v2-sidebar-tree-scroll) { + [data-component="session-review-v2-sidebar-root"] + [data-slot="session-review-v2-sidebar-tree"] + .scroll-view__viewport { + scroll-timeline: --session-review-v2-sidebar-tree-scroll y; + } + + [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::before { + animation: session-review-v2-sidebar-tree-fade-top linear both; + animation-timeline: --session-review-v2-sidebar-tree-scroll; + animation-range: 0 0.1px; + } + + [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::after { + animation: session-review-v2-sidebar-tree-fade-bottom linear both; + animation-timeline: --session-review-v2-sidebar-tree-scroll; + animation-range: calc(100% - 1.1px) calc(100% - 1px); + } } [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport { - padding: 0 8px 12px; + padding: 4px 8px 12px; } [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] .scroll-view__thumb { @@ -143,7 +210,7 @@ align-items: center; justify-content: space-between; gap: 8px; - padding: 8px 12px; + padding: 10px 12px; padding-left: 8px; flex-shrink: 0; border-bottom: 1px solid var(--border-weaker-base, var(--v2-border-border-weak)); @@ -237,16 +304,8 @@ color: var(--v2-icon-icon-base); } -[data-component="icon-button-v2"][data-variant="ghost"].session-review-v2-sidebar-toggle[aria-expanded="true"]:not( - :disabled - ), -[data-component="icon-button-v2"][data-variant="ghost"].session-review-v2-sidebar-toggle[aria-expanded="true"]:is( - :hover, - [data-state="hover"], - :active, - [data-state="pressed"] - ):not(:disabled) { - background-color: var(--v2-overlay-simple-overlay-pressed); +[data-component="icon-button-v2"][data-variant="ghost"].session-review-v2-sidebar-toggle[aria-expanded="true"] { + background-color: var(--v2-background-bg-layer-02); } [data-component="session-review-v2"] [data-slot="session-review-v2-file-header"] { @@ -267,9 +326,37 @@ flex: 1; } -[data-component="session-review-v2"] [data-slot="session-review-v2-file-header"] [data-component="diff-changes"] { +[data-component="session-review-v2"] [data-slot="session-review-v2-file-diff"] { + position: relative; flex-shrink: 0; margin-left: auto; + background-color: var(--v2-background-bg-base); + + &::before { + content: ""; + position: absolute; + top: -8px; + bottom: -8px; + right: 100%; + width: 16px; + z-index: 1; + pointer-events: none; + background: linear-gradient(90deg, transparent, var(--v2-background-bg-base)); + } + + &::after { + content: ""; + position: absolute; + inset: -8px -16px -8px 0; + z-index: 0; + pointer-events: none; + background-color: var(--v2-background-bg-base); + } + + [data-component="diff-changes"] { + position: relative; + z-index: 1; + } } [data-component="session-review-v2"] [data-slot="session-review-v2-file-status"] { diff --git a/packages/session-ui/src/v2/components/session-review-v2.tsx b/packages/session-ui/src/v2/components/session-review-v2.tsx index 3c13c75d7b..d2f3b6b8a6 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-v2.tsx @@ -26,7 +26,6 @@ export type SessionReviewV2Props = { empty?: JSX.Element sidebarOpen?: boolean sidebar?: JSX.Element - sidebarToggle?: JSX.Element activeFile?: string files: string[] onSelectFile: (file: string) => void @@ -40,6 +39,7 @@ export type SessionReviewV2Props = { export type SessionReviewV2SidebarProps = { open: boolean + transition: boolean title?: JSX.Element stats?: JSX.Element filter: string @@ -77,6 +77,7 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) {
    @@ -163,15 +165,13 @@ export function SessionReviewV2(props: SessionReviewV2Props) { } const prev = () => { - const files = props.files - if (files.length === 0) return - return files[(fileIndex() - 1 + files.length) % files.length] + if (!canCycle()) return + return props.files[(fileIndex() - 1 + props.files.length) % props.files.length] } const next = () => { - const files = props.files - if (files.length === 0) return - return files[(fileIndex() + 1) % files.length] + if (!canCycle()) return + return props.files[(fileIndex() + 1) % props.files.length] } const canCycle = () => props.files.length > 0 @@ -185,21 +185,22 @@ export function SessionReviewV2(props: SessionReviewV2Props) { props.onSelectFile(file) } - // The prev/next tooltips advertise < and >; keep the keys working while the + // Keep the advertised arrow keys working while the // pane is mounted, but never while typing in an input or comment editor. makeEventListener(document, "keydown", (event) => { if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return - if (event.key !== "<" && event.key !== ">") return + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return const target = event.target if (target instanceof HTMLElement && (target.isContentEditable || target.closest("input, textarea, select"))) return if (!props.hasDiffs || !canCycle()) return + const file = event.key === "ArrowLeft" ? prev() : next() + if (!file) return event.preventDefault() - cycle(event.key === "<" ? prev() : next()) + cycle(file) }) const toolbarStart = () => ( <> - {props.sidebarToggle}
    @@ -216,10 +217,11 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
    {i18n.t("ui.sessionReviewV2.previousFile")} - + } > @@ -228,17 +230,18 @@ export function SessionReviewV2(props: SessionReviewV2Props) { variant="ghost" size="small" class="session-review-v2-file-nav-button" - disabled={!canCycle()} + disabled={!prev()} onClick={() => cycle(prev())} aria-label={i18n.t("ui.sessionReviewV2.previousFile")} /> {i18n.t("ui.sessionReviewV2.nextFile")} - "]} variant="neutral" /> + } > @@ -247,7 +250,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) { variant="ghost" size="small" class="session-review-v2-file-nav-button" - disabled={!canCycle()} + disabled={!next()} onClick={() => cycle(next())} aria-label={i18n.t("ui.sessionReviewV2.nextFile")} /> @@ -319,7 +322,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) { ) } -export function SessionReviewV2SidebarToggle(props: { opened: boolean; onToggle: () => void }) { +export function SessionReviewV2SidebarToggle(props: { opened: boolean; disabled?: boolean; onToggle: () => void }) { const i18n = useI18n() return ( @@ -330,6 +333,8 @@ export function SessionReviewV2SidebarToggle(props: { opened: boolean; onToggle: class="session-review-v2-sidebar-toggle" aria-label={i18n.t("ui.sessionReviewV2.toggleSidebar")} aria-expanded={props.opened} + data-expanded={props.opened ? "" : undefined} + disabled={props.disabled} onClick={props.onToggle} icon={} /> diff --git a/packages/slack/package.json b/packages/slack/package.json index 11bb354f0d..46d7d185e3 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.11", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index f1def44cec..a04d6761a7 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.17.18", + "version": "1.18.11", "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/app/src/routes/model-catalog.ts b/packages/stats/app/src/routes/model-catalog.ts index efd6f6b2e0..47fa1cf347 100644 --- a/packages/stats/app/src/routes/model-catalog.ts +++ b/packages/stats/app/src/routes/model-catalog.ts @@ -1,8 +1,8 @@ import { query } from "@solidjs/router" -export const modelCatalogSourceUrl = "https://models.dev/catalog.json" -export const modelCatalogPricingUrl = "https://models.dev/api.json" -export const modelCatalogLabSourceUrl = "https://models.dev/labs" +export const modelCatalogSourceUrl = "https://models.opencode.ai/catalog.json" +export const modelCatalogPricingUrl = "https://models.opencode.ai/api.json" +export const modelCatalogLabSourceUrl = "https://models.opencode.ai/labs" export type ModelCatalogCost = { input: number diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index 6733366530..e02d8e1314 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-core", - "version": "1.17.18", + "version": "1.18.11", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index a4d44b874d..9eb222ec80 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-server", - "version": "1.17.18", + "version": "1.18.11", "private": true, "type": "module", "license": "MIT", diff --git a/packages/storybook/.storybook/main.ts b/packages/storybook/.storybook/main.ts index f592863e56..3fb9dae29a 100644 --- a/packages/storybook/.storybook/main.ts +++ b/packages/storybook/.storybook/main.ts @@ -47,6 +47,8 @@ export default defineMain({ { find: /^@\/context\/language$/, replacement: path.resolve(mocks, "app/context/language.ts") }, { find: /^@\/context\/platform$/, replacement: path.resolve(mocks, "app/context/platform.ts") }, { find: /^@\/context\/global-sync$/, replacement: path.resolve(mocks, "app/context/global-sync.ts") }, + { find: /^@\/context\/server-sync$/, replacement: path.resolve(mocks, "app/context/server-sync.ts") }, + { find: /^@\/context\/server-sdk$/, replacement: path.resolve(mocks, "app/context/server-sdk.ts") }, { find: /^@\/hooks\/use-providers$/, replacement: path.resolve(mocks, "app/hooks/use-providers.ts") }, { find: /^@\/components\/dialog-select-model$/, diff --git a/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx b/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx index 0c7ff8bbdd..0741f7e4ef 100644 --- a/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx +++ b/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx @@ -1,9 +1,8 @@ -import { splitProps } from "solid-js" +import { splitProps, type JSX } from "solid-js" -export function ModelSelectorPopover(props: { triggerAs: any; triggerProps?: Record; children: any }) { - const [local] = splitProps(props, ["triggerAs", "triggerProps", "children"]) - const Trigger = local.triggerAs - return {local.children} +export function ModelSelectorPopover(props: { trigger: (props: Record) => JSX.Element }) { + const [local] = splitProps(props, ["trigger"]) + return <>{local.trigger({})} } export const ModelSelectorPopoverV2 = ModelSelectorPopover diff --git a/packages/storybook/.storybook/mocks/app/context/command.ts b/packages/storybook/.storybook/mocks/app/context/command.ts index 673019f2f0..16ff08bef4 100644 --- a/packages/storybook/.storybook/mocks/app/context/command.ts +++ b/packages/storybook/.storybook/mocks/app/context/command.ts @@ -22,5 +22,8 @@ export function useCommand() { keybind(id: string) { return keybinds[id] }, + keybindParts(id: string) { + return keybinds[id]?.split("+") ?? [] + }, } } diff --git a/packages/storybook/.storybook/mocks/app/context/language.ts b/packages/storybook/.storybook/mocks/app/context/language.ts index 2510acc6f9..081dc26bfd 100644 --- a/packages/storybook/.storybook/mocks/app/context/language.ts +++ b/packages/storybook/.storybook/mocks/app/context/language.ts @@ -10,7 +10,7 @@ const dict: Record = { "prompt.loading": "Loading prompt...", "prompt.placeholder.normal": "Ask anything...", "prompt.placeholder.simple": "Ask anything...", - "prompt.placeholder.shell": "Run a shell command... {{example}}", + "prompt.placeholder.shell": "Enter shell command... {{example}}", "prompt.placeholder.summarizeComment": "Summarize this comment", "prompt.placeholder.summarizeComments": "Summarize these comments", "prompt.action.attachFile": "Attach files", @@ -22,6 +22,69 @@ const dict: Record = { "prompt.mode.shell": "Shell", "prompt.mode.normal": "Prompt", "dialog.model.select.title": "Select model", + "dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode", + "dialog.model.unpaid.addMore.title": "Add more models from popular providers", + "dialog.model.unpaid.viewMoreProviders": "See 70+ more providers", + "dialog.provider.opencode.tagline": "Reliable optimized models", + "dialog.provider.opencodeGo.tagline": "Low cost subscription for everyone", + "dialog.provider.custom.label": "Custom OpenAI-compatible provider", + "dialog.provider.search.placeholder": "Search providers", + "dialog.provider.empty": "No providers found", + "dialog.provider.group.popular": "Popular", + "dialog.provider.group.other": "Other", + "dialog.provider.tag.recommended": "Recommended", + "settings.providers.tag.custom": "Custom", + "command.provider.connect": "Connect provider", + "provider.connect.title": "Connect {{provider}}", + "provider.connect.selectMethod": "Select login method for {{provider}}.", + "provider.connect.method.apiKey": "API key", + "provider.connect.apiKey.description": + "Enter your {{provider}} API key to connect your account and use {{provider}} models in OpenCode.", + "provider.connect.apiKey.label": "{{provider}} API key", + "provider.connect.apiKey.placeholder": "API key", + "provider.connect.apiKey.required": "API key is required", + "provider.connect.opencodeZen.line1": + "OpenCode Zen gives you access to a curated set of reliable optimized models for coding agents.", + "provider.connect.opencodeZen.line2": + "With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and more.", + "provider.connect.opencodeZen.visit.prefix": "Visit ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", + "provider.connect.opencodeZen.visit.suffix": " to collect your API key.", + "provider.connect.oauth.code.visit.prefix": "Visit ", + "provider.connect.oauth.code.visit.link": "this link", + "provider.connect.oauth.code.visit.suffix": + " to collect your authorization code to connect your account and use {{provider}} models in OpenCode.", + "provider.connect.oauth.code.label": "{{method}} authorization code", + "provider.connect.oauth.code.placeholder": "Authorization code", + "provider.connect.oauth.code.required": "Authorization code is required", + "provider.connect.oauth.code.invalid": "Invalid authorization code", + "provider.connect.oauth.auto.visit.prefix": "Visit ", + "provider.connect.oauth.auto.visit.link": "this link", + "provider.connect.oauth.auto.visit.suffix": + " and enter the code below to connect your account and use {{provider}} models in OpenCode.", + "provider.connect.oauth.auto.confirmationCode": "Confirmation code", + "provider.connect.status.inProgress": "Authorization in progress...", + "provider.connect.status.waiting": "Waiting for authorization...", + "provider.connect.status.failed": "Authorization failed: {{error}}", + "provider.connect.toast.connected.title": "{{provider}} connected", + "provider.connect.toast.connected.description": "{{provider}} models are now available to use.", + "common.continue": "Continue", + "model.tag.free": "Free", + "model.tag.latest": "Latest", + "model.input.text": "text", + "model.input.image": "image", + "model.input.audio": "audio", + "model.input.video": "video", + "model.input.pdf": "pdf", + "model.tooltip.context.label": "Context", + "model.tooltip.inputs": "Inputs", + "model.tooltip.model": "Model", + "model.tooltip.provider": "Provider", + "model.tooltip.reasoning": "Reasoning", + "model.tooltip.reasoning.allowed": "Allows reasoning", + "model.tooltip.reasoning.none": "No reasoning", + "common.close": "Close", + "common.goBack": "Go back", "common.default": "Default", "common.key.esc": "Esc", "command.category.file": "File", @@ -73,6 +136,7 @@ function render(template: string, params?: Record) { export function useLanguage() { return { locale: () => "en" as const, + intl: () => "en-US", t(key: string, params?: Record) { return render(dict[key] ?? key, params) }, diff --git a/packages/storybook/.storybook/mocks/app/context/platform.ts b/packages/storybook/.storybook/mocks/app/context/platform.ts index 74233cd1e5..bab923bff5 100644 --- a/packages/storybook/.storybook/mocks/app/context/platform.ts +++ b/packages/storybook/.storybook/mocks/app/context/platform.ts @@ -2,13 +2,10 @@ import type { Platform } from "../../../../../app/src/context/platform" const value: Platform = { platform: "web", - openLink() {}, + openExternal() {}, restart: async () => {}, - back() {}, - forward() {}, notify: async () => {}, fetch: globalThis.fetch.bind(globalThis), - parseMarkdown: async (markdown: string) => markdown, } export function usePlatform() { diff --git a/packages/storybook/.storybook/mocks/app/context/prompt.ts b/packages/storybook/.storybook/mocks/app/context/prompt.ts index 1ea1fc7983..c41ba553fe 100644 --- a/packages/storybook/.storybook/mocks/app/context/prompt.ts +++ b/packages/storybook/.storybook/mocks/app/context/prompt.ts @@ -60,6 +60,10 @@ export function isPromptEqual(a: Prompt, b: Prompt) { return a.every((part, i) => JSON.stringify(part) === JSON.stringify(b[i])) } +export function isCommentItem(item: ContextItem) { + return !!item.comment?.trim() +} + export function createPromptState() { const [store, setStore] = createStore({ prompt: clonePrompt(DEFAULT_PROMPT), 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..e1bc700ac7 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "1.17.18", + "version": "1.18.11", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index 26ffadbcf6..957f3151e9 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.11", "type": "module", "license": "MIT", "repository": { @@ -104,6 +104,7 @@ "remend": "catalog:", "shiki": "catalog:", "solid-list": "catalog:", + "solid-sonner": "catalog:", "strip-ansi": "7.1.2" }, "peerDependencies": { diff --git a/packages/ui/src/components/dock-surface.css b/packages/ui/src/components/dock-surface.css index fd34304464..91fe502264 100644 --- a/packages/ui/src/components/dock-surface.css +++ b/packages/ui/src/components/dock-surface.css @@ -7,6 +7,20 @@ overflow: clip; } +[data-dock-border-underlay] { + /* Later shadows paint underneath, so this solid ring masks overlapping surfaces without changing border geometry. */ + box-shadow: + var(--dock-shell-visual-shadow, var(--shadow-xs-border)), + 0 0 0 var(--dock-shell-border-underlay-width, 1px) + var(--dock-shell-border-underlay-background, var(--surface-raised-stronger-non-alpha)); +} + +[data-dock-border-underlay="v2"] { + --dock-shell-visual-shadow: var(--v2-elevation-raised); + --dock-shell-border-underlay-width: 0.5px; + --dock-shell-border-underlay-background: var(--v2-background-bg-base); +} + [data-dock-surface="tray"] { background-color: var(--background-base); border: 1px solid var(--border-weak-base); diff --git a/packages/ui/src/components/resize-handle.tsx b/packages/ui/src/components/resize-handle.tsx index d7774a684b..bc89b257b1 100644 --- a/packages/ui/src/components/resize-handle.tsx +++ b/packages/ui/src/components/resize-handle.tsx @@ -8,6 +8,8 @@ export interface ResizeHandleProps extends Omit void onCollapse?: () => void + /** Called while dragging when size crosses `collapseThreshold`. */ + onCollapseChange?: (collapsed: boolean) => void collapseThreshold?: number } @@ -20,17 +22,26 @@ export function ResizeHandle(props: ResizeHandleProps) { "max", "onResize", "onCollapse", + "onCollapseChange", "collapseThreshold", "class", "classList", ]) const handleMouseDown = (e: MouseEvent) => { + if (e.detail > 1) return e.preventDefault() const edge = local.edge ?? (local.direction === "vertical" ? "start" : "end") const start = local.direction === "horizontal" ? e.clientX : e.clientY const startSize = local.size + const min = local.min + const max = local.max + const threshold = local.collapseThreshold ?? 0 + const onResize = local.onResize + const onCollapse = local.onCollapse + const onCollapseChange = local.onCollapseChange let current = startSize + let collapsed = false document.body.style.userSelect = "none" document.body.style.overflow = "hidden" @@ -46,8 +57,12 @@ export function ResizeHandle(props: ResizeHandleProps) { ? start - pos : pos - start current = startSize + delta - const clamped = Math.min(local.max, Math.max(local.min, current)) - local.onResize(clamped) + const nextCollapsed = threshold > 0 && current < threshold + if (nextCollapsed !== collapsed) { + collapsed = nextCollapsed + onCollapseChange?.(collapsed) + } + onResize(Math.min(max, Math.max(min, current))) } const onMouseUp = () => { @@ -56,10 +71,11 @@ export function ResizeHandle(props: ResizeHandleProps) { document.removeEventListener("mousemove", onMouseMove) document.removeEventListener("mouseup", onMouseUp) - const threshold = local.collapseThreshold ?? 0 - if (local.onCollapse && threshold > 0 && current < threshold) { - local.onCollapse() + if (collapsed) { + onCollapse?.() + return } + onCollapseChange?.(false) } document.addEventListener("mousemove", onMouseMove) diff --git a/packages/ui/src/components/scroll-view.css b/packages/ui/src/components/scroll-view.css index 7c5d8ed0f7..e2425a01e4 100644 --- a/packages/ui/src/components/scroll-view.css +++ b/packages/ui/src/components/scroll-view.css @@ -27,6 +27,7 @@ transition: opacity 200ms ease; cursor: default; user-select: none; + pointer-events: auto; opacity: 0; } diff --git a/packages/ui/src/components/scroll-view.test.ts b/packages/ui/src/components/scroll-view.test.ts index d338fa59bc..84131b1f17 100644 --- a/packages/ui/src/components/scroll-view.test.ts +++ b/packages/ui/src/components/scroll-view.test.ts @@ -72,4 +72,19 @@ describe("scrollTopFromThumbPointer", () => { expect(scrollTopFromThumbPointer({ ...input, pointer: 0 })).toBe(0) expect(scrollTopFromThumbPointer({ ...input, pointer: 1_000 })).toBe(5_400) }) + + test("uses scrollClientHeight when the thumb track differs from the viewport", () => { + const input = { + pointer: 400, + viewportTop: 100, + grabOffset: 0, + clientHeight: 400, + scrollClientHeight: 800, + scrollHeight: 8_000, + thumbHeight: 40, + } + // track usable = 400 - 16 - 40 = 344; thumbTop = 400 - 100 - 8 = 292 + // maxScroll = 8000 - 800 = 7200 → 292/344 * 7200 + expect(scrollTopFromThumbPointer(input)).toBeCloseTo((292 / 344) * 7200) + }) }) diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index 0a567ce1c5..5116d94f9e 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -1,4 +1,15 @@ -import { onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js" +import { + createEffect, + createMemo, + mergeProps, + onCleanup, + onMount, + Show, + splitProps, + type Accessor, + type ComponentProps, +} from "solid-js" +import { Portal } from "solid-js/web" import { createResizeObserver } from "@solid-primitives/resize-observer" import { createStore } from "solid-js/store" import { useI18n } from "../context/i18n" @@ -8,7 +19,17 @@ export type ScrollViewThumbVisibility = "hover" | "scroll" export interface ScrollViewProps extends ComponentProps<"div"> { viewportRef?: (el: HTMLDivElement) => void orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb + /** + * `hover`: show while hovered or scrolling. `scroll`: show only while scrolling. + * + * In most cases, scrolling a container = hovering over it, so this change has no effect. + * This is a special case to account for the home page scroll, where scrolling a container != hovering over it + * */ thumbVisibility?: ScrollViewThumbVisibility + /** Mount the thumb into an external track. Scroll metrics still come from this ScrollView. */ + thumbContainer?: HTMLElement | Accessor + /** Element whose hover reveals the thumb. Defaults to the ScrollView root when unset. */ + thumbHoverTarget?: HTMLElement | Accessor } export const scrollKey = (event: Pick) => { @@ -65,12 +86,14 @@ export function scrollTopFromThumbPointer(input: { clientHeight: number scrollHeight: number thumbHeight: number + /** Viewport height used for max scroll. Defaults to `clientHeight` (track == viewport). */ + scrollClientHeight?: number }) { const padding = 8 const maxThumbTop = input.clientHeight - padding * 2 - input.thumbHeight if (maxThumbTop <= 0) return 0 const thumbTop = Math.max(0, Math.min(input.pointer - input.viewportTop - padding - input.grabOffset, maxThumbTop)) - return (thumbTop / maxThumbTop) * Math.max(0, input.scrollHeight - input.clientHeight) + return (thumbTop / maxThumbTop) * Math.max(0, input.scrollHeight - (input.scrollClientHeight ?? input.clientHeight)) } export function ScrollView(props: ScrollViewProps) { @@ -78,7 +101,16 @@ export function ScrollView(props: ScrollViewProps) { const merged = mergeProps({ orientation: "vertical", thumbVisibility: "hover" }, props) const [local, events, rest] = splitProps( merged, - ["class", "children", "viewportRef", "orientation", "thumbVisibility", "style"], + [ + "class", + "children", + "viewportRef", + "orientation", + "thumbVisibility", + "thumbContainer", + "thumbHoverTarget", + "style", + ], [ "onScroll", "onWheel", @@ -96,6 +128,15 @@ export function ScrollView(props: ScrollViewProps) { let viewportRef!: HTMLDivElement let thumbRef!: HTMLDivElement + const resolveEl = (value: HTMLElement | Accessor | undefined) => { + if (typeof value === "function") return value() + return value + } + + const thumbMount = createMemo(() => resolveEl(local.thumbContainer)) + const thumbHover = createMemo(() => resolveEl(local.thumbHoverTarget)) + const hoverRoot = () => !local.thumbHoverTarget && !local.thumbContainer + const [state, setState] = createStore({ isHovered: false, isDragging: false, @@ -114,7 +155,6 @@ export function ScrollView(props: ScrollViewProps) { let scrollIdleTimer: ReturnType | undefined const markScrolling = () => { - if (local.thumbVisibility !== "scroll") return setState("isScrolling", true) if (scrollIdleTimer !== undefined) clearTimeout(scrollIdleTimer) scrollIdleTimer = setTimeout(() => setState("isScrolling", false), 800) @@ -122,8 +162,8 @@ export function ScrollView(props: ScrollViewProps) { const thumbVisible = () => { if (isDragging()) return true - if (local.thumbVisibility === "scroll") return isScrolling() - return isHovered() + if (isScrolling()) return true + return local.thumbVisibility === "hover" && isHovered() } onCleanup(() => { @@ -141,7 +181,8 @@ export function ScrollView(props: ScrollViewProps) { setState("showThumb", true) const trackPadding = 8 - const trackHeight = clientHeight - trackPadding * 2 + const trackClientHeight = thumbMount()?.clientHeight || clientHeight + const trackHeight = trackClientHeight - trackPadding * 2 const minThumbHeight = 32 // Calculate raw thumb height based on ratio @@ -165,16 +206,40 @@ export function ScrollView(props: ScrollViewProps) { local.viewportRef(viewportRef) } - createResizeObserver([viewportRef, viewportRef.firstElementChild], updateThumb) + createResizeObserver( + () => [viewportRef, viewportRef.firstElementChild, thumbMount()].filter(Boolean) as HTMLElement[], + updateThumb, + ) updateThumb() }) + createEffect(() => { + thumbMount() + updateThumb() + }) + + createEffect(() => { + const target = thumbHover() + if (!target) return + + const enter = () => setState("isHovered", true) + const leave = () => setState("isHovered", false) + target.addEventListener("pointerenter", enter) + target.addEventListener("pointerleave", leave) + onCleanup(() => { + target.removeEventListener("pointerenter", enter) + target.removeEventListener("pointerleave", leave) + setState("isHovered", false) + }) + }) + const onThumbPointerDown = (e: PointerEvent) => { e.preventDefault() e.stopPropagation() setState("isDragging", true) const grabOffset = e.clientY - thumbRef.getBoundingClientRect().top + const track = thumbMount() ?? viewportRef thumbRef.setPointerCapture(e.pointerId) @@ -182,9 +247,10 @@ export function ScrollView(props: ScrollViewProps) { const { scrollHeight, clientHeight } = viewportRef viewportRef.scrollTop = scrollTopFromThumbPointer({ pointer: e.clientY, - viewportTop: viewportRef.getBoundingClientRect().top, + viewportTop: track.getBoundingClientRect().top, grabOffset, - clientHeight, + clientHeight: track.clientHeight, + scrollClientHeight: clientHeight, scrollHeight, thumbHeight: thumbHeight(), }) @@ -203,6 +269,23 @@ export function ScrollView(props: ScrollViewProps) { thumbRef.addEventListener("pointercancel", done) } + const renderThumb = () => ( +
    { + thumbRef = el + }} + onPointerDown={onThumbPointerDown} + class="scroll-view__thumb" + data-visible={thumbVisible()} + data-dragging={isDragging()} + style={{ + height: `${thumbHeight()}px`, + transform: `translateY(${thumbTop()}px)`, + "z-index": 100, // ensure it displays over content + }} + /> + ) + // Keybinds implementation // We ensure the viewport has a tabindex so it can receive focus // We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior, @@ -253,8 +336,12 @@ export function ScrollView(props: ScrollViewProps) { ref={rootRef} class={`scroll-view ${local.class || ""}`} style={local.style} - onPointerEnter={() => setState("isHovered", true)} - onPointerLeave={() => setState("isHovered", false)} + onPointerEnter={() => { + if (hoverRoot()) setState("isHovered", true) + }} + onPointerLeave={() => { + if (hoverRoot()) setState("isHovered", false) + }} {...rest} > {/* Viewport */} @@ -290,20 +377,11 @@ export function ScrollView(props: ScrollViewProps) { {local.children}
    - {/* Thumb Overlay */} + {/* Thumb Overlay — optionally portaled into an external track */} -
    + + {(mount) => {renderThumb()}} +
    ) diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index cd26b0cd50..ef630e9547 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -698,3 +698,164 @@ body[data-new-layout] #terminal-panel [data-component="tabs"][data-variant="norm } } } + +body[data-new-layout] #review-panel [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"] { + [data-slot="tabs-list"] { + height: 52px; + padding-right: 12px; + gap: 8px; + --tabs-review-fade: 8px; + } + + [data-slot="tabs-trigger-wrapper"] { + height: 28px; + padding-inline: 10px; + border: 0; + background-color: transparent; + box-shadow: none; + + &::after { + display: none; + } + + [data-slot="tabs-trigger"] { + padding: 0 !important; + } + + &:has([data-slot="tabs-trigger-close-button"]) { + padding-right: 8px; + + [data-slot="tabs-trigger-close-button"] { + margin-left: 2px; + } + } + + &:has([data-selected]) { + background-color: var(--v2-background-bg-layer-02); + box-shadow: none; + } + } + + [data-slot="tabs-trigger"] { + font-family: Inter, sans-serif; + font-style: normal; + font-weight: 440; + font-size: 13px; + line-height: 16px; + letter-spacing: -0.04px; + color: var(--v2-text-text-muted); + font-variation-settings: "slnt" 0; + font-variant-numeric: tabular-nums; + + &[data-selected] { + color: var(--v2-text-text-base); + } + + .tab-fileicon-color, + .tab-fileicon-mono, + [data-slot="icon-svg"] { + margin-left: -2px; + } + } + + /* Preview/temporary tabs: beat trigger + .text-14-medium upright defaults */ + [data-slot="tabs-trigger"] .italic { + font-style: italic !important; + font-variation-settings: "slnt" -10 !important; + } +} + +body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-tabs-bar { + width: 100%; + min-width: 0; + gap: 8px; + box-sizing: border-box; + border-bottom: 1px solid var(--border-weaker-base, var(--v2-border-border-weak)); + background-color: var(--v2-background-bg-base); +} + +body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-tabs-bar [data-slot="tabs-list"], +body[data-new-layout] + #review-panel + [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"] + .session-review-v2-tabs-bar + [data-slot="tabs-list"] { + flex: 1 1 0; + min-width: 0; + width: auto; + border-bottom: none; +} + +body[data-new-layout] + #review-panel + [data-component="tabs"] + .session-review-v2-tabs-bar + [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 { + flex-shrink: 0; + margin-left: auto; +} + +body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-open-in-app { + flex-shrink: 0; +} 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/file-tree-v2.css b/packages/ui/src/v2/components/file-tree-v2.css index e4236bcccd..ba88d09228 100644 --- a/packages/ui/src/v2/components/file-tree-v2.css +++ b/packages/ui/src/v2/components/file-tree-v2.css @@ -14,12 +14,14 @@ justify-content: flex-start; gap: 6px; padding-right: 8px; + overflow: visible; border: none; border-radius: 6px; background-color: transparent; color: var(--v2-text-text-muted); text-align: left; cursor: pointer; + scroll-margin-block: 8px; transition: background-color 120ms ease, color 120ms ease; @@ -42,6 +44,21 @@ background-color: var(--v2-overlay-simple-overlay-pressed); } +[data-component="file-tree-v2"] [data-slot="file-tree-v2-guide"] { + position: absolute; + top: 0; + margin-top: -2px; + height: calc(100% + 2px); + width: 1px; + pointer-events: none; + background-color: var(--v2-border-border-muted); + opacity: 0; +} + +[data-component="file-tree-v2"]:hover [data-slot="file-tree-v2-guide"] { + opacity: 0.5; +} + [data-component="file-tree-v2"] .filetree-icon--mono { color: var(--v2-icon-icon-muted); } @@ -108,7 +125,7 @@ } [data-component="file-tree-v2"] [data-slot="file-tree-v2-change"][data-change="modified"] { - opacity: 0; + color: var(--v2-state-fg-info); } [data-component="file-tree-v2"] [data-slot="file-tree-v2-change"][data-change="added"] { diff --git a/packages/ui/src/v2/components/icon.tsx b/packages/ui/src/v2/components/icon.tsx index b0b70bf37f..b22d3140fe 100644 --- a/packages/ui/src/v2/components/icon.tsx +++ b/packages/ui/src/v2/components/icon.tsx @@ -89,6 +89,10 @@ const icons = { viewBox: "0 0 16 16", body: ``, }, + "outline-xmark": { + viewBox: "0 0 16 16", + body: ``, + }, "outline-chevron-down": { viewBox: "0 0 16 16", body: ``, @@ -129,6 +133,10 @@ const icons = { viewBox: "0 0 16 16", body: ``, }, + "outline-share": { + viewBox: "0 0 16 16", + body: ``, + }, reset: { viewBox: "0 0 20 20", body: ``, 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