diff --git a/.github/TEAM_MEMBERS b/.github/TEAM_MEMBERS index f892eb95db..ee2e26f452 100644 --- a/.github/TEAM_MEMBERS +++ b/.github/TEAM_MEMBERS @@ -1,4 +1,5 @@ adamdotdevin +arvsrn Brendonovich fwang Hona @@ -7,11 +8,14 @@ jayair jlongster kitlangton kommander +ludvigrask MrMushrooooom nexxeln R44VC0RP rekram1-node thdxr simonklee +Slickstef11 +usrnk1 vimtor -starptech +StarpTech diff --git a/.opencode/command/translate.md b/.opencode/command/translate.md index 8d493f4a81..de18ae2ee8 100644 --- a/.opencode/command/translate.md +++ b/.opencode/command/translate.md @@ -1,6 +1,6 @@ --- description: translate English to other languages -model: opencode/claude-opus-4-8 +model: opencode/gpt-5.6-sol --- run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time. diff --git a/.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/README.zh.md b/README.zh.md index 93f81e83cd..352e13ccaa 100644 --- a/README.zh.md +++ b/README.zh.md @@ -125,4 +125,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换: --- -**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode) +**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode) diff --git a/README.zht.md b/README.zht.md index 59d9709bd3..f09358b133 100644 --- a/README.zht.md +++ b/README.zht.md @@ -125,4 +125,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。 --- -**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode) +**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode) diff --git a/artifacts/glm52-rise-video/sst-env.d.ts b/artifacts/glm52-rise-video/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/artifacts/glm52-rise-video/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/bun.lock b/bun.lock index 742578ad2b..4d6d657727 100644 --- a/bun.lock +++ b/bun.lock @@ -29,8 +29,9 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { + "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", "@dnd-kit/dom": "0.5.0", "@dnd-kit/helpers": "0.5.0", @@ -72,6 +73,7 @@ "shiki": "catalog:", "solid-js": "catalog:", "solid-list": "catalog:", + "solid-presence": "0.2.0", "tailwindcss": "catalog:", }, "devDependencies": { @@ -93,7 +95,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.17.13", + "version": "1.18.3", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -141,7 +143,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "0.0.1", + "version": "1.18.3", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -155,7 +157,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -191,7 +193,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -218,7 +220,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -240,7 +242,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -264,7 +266,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -284,7 +286,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.17.13", + "version": "1.18.3", "bin": { "opencode": "./bin/opencode", }, @@ -292,7 +294,7 @@ "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.49", + "@ai-sdk/azure": "3.0.88", "@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", @@ -301,14 +303,14 @@ "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", - "@ai-sdk/openai": "3.0.53", + "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@ai-sdk/togetherai": "2.0.41", "@ai-sdk/vercel": "2.0.39", - "@ai-sdk/xai": "3.0.82", + "@ai-sdk/xai": "3.0.102", "@aws-sdk/credential-providers": "3.1057.0", "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", @@ -336,7 +338,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.10.0", + "gitlab-ai-provider": "6.11.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -378,7 +380,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -432,7 +434,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -446,7 +448,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { "effect": "catalog:", }, @@ -458,7 +460,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -490,7 +492,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -506,7 +508,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -537,7 +539,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -556,7 +558,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.17.13", + "version": "1.18.3", "bin": { "opencode": "./bin/opencode", }, @@ -567,7 +569,7 @@ "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.49", + "@ai-sdk/azure": "3.0.88", "@ai-sdk/cerebras": "2.0.60", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", @@ -576,13 +578,13 @@ "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", - "@ai-sdk/openai": "3.0.53", + "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", "@ai-sdk/togetherai": "2.0.41", "@ai-sdk/vercel": "2.0.39", - "@ai-sdk/xai": "3.0.82", + "@ai-sdk/xai": "3.0.102", "@aws-sdk/credential-providers": "3.1057.0", "@clack/prompts": "1.0.0-alpha.1", "@effect/opentelemetry": "catalog:", @@ -629,7 +631,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.10.0", + "gitlab-ai-provider": "6.11.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -687,7 +689,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -704,9 +706,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.3.4", - "@opentui/keymap": ">=0.3.4", - "@opentui/solid": ">=0.3.4", + "@opentui/core": ">=0.4.5", + "@opentui/keymap": ">=0.4.5", + "@opentui/solid": ">=0.4.5", }, "optionalPeers": [ "@opentui/core", @@ -763,7 +765,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { "cross-spawn": "catalog:", }, @@ -778,7 +780,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -793,7 +795,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -837,7 +839,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -850,9 +852,10 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { "@ibm/plex": "6.4.1", + "@kobalte/core": "catalog:", "@opencode-ai/stats-core": "workspace:*", "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -883,7 +886,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -902,7 +905,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -934,6 +937,7 @@ "@types/node": "catalog:", "@types/react": "18.0.25", "react": "18.2.0", + "react-dom": "18.2.0", "solid-js": "catalog:", "storybook": "^10.2.13", "storybook-solidjs-vite": "^10.0.9", @@ -943,7 +947,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -970,7 +974,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1021,7 +1025,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.17.13", + "version": "1.18.3", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -1064,17 +1068,16 @@ ], "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", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", - "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", - "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", }, "overrides": { @@ -1086,6 +1089,7 @@ }, "catalog": { "@cloudflare/workers-types": "4.20251008.0", + "@corvu/drawer": "0.2.4", "@effect/opentelemetry": "4.0.0-beta.83", "@effect/platform-node": "4.0.0-beta.83", "@effect/sql-sqlite-bun": "4.0.0-beta.83", @@ -1096,9 +1100,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.3.4", - "@opentui/keymap": "0.3.4", - "@opentui/solid": "0.3.4", + "@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", @@ -1109,7 +1113,7 @@ "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "@tailwindcss/vite": "4.1.11", - "@tanstack/solid-virtual": "3.13.28", + "@tanstack/solid-virtual": "3.13.32", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", "@types/bun": "1.3.13", @@ -1129,7 +1133,7 @@ "hono": "4.10.7", "hono-openapi": "1.1.2", "luxon": "3.6.1", - "marked": "17.0.1", + "marked": "17.0.6", "marked-shiki": "1.2.1", "opentui-spinner": "0.0.7", "remeda": "2.26.0", @@ -1171,7 +1175,7 @@ "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="], - "@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="], + "@ai-sdk/azure": ["@ai-sdk/azure@3.0.88", "", { "dependencies": { "@ai-sdk/deepseek": "2.0.47", "@ai-sdk/openai": "3.0.84", "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RRjZkB1lYplh8dpBarnvkl1j7sYLHsyXua7erL3oNcMK7fHcl4bPO5C7iQhD1O/DqD/zCceDifnege1s+8yEvw=="], "@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="], @@ -1181,7 +1185,7 @@ "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="], - "@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="], + "@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MzcQ321JO8OY+TVLFI81A7cIIuoeLLxrLCDD+8C1E3Ro6UFyfMtRXo9bw9OhTMRSDMo6hgSDOo4Fekz8aJtQYQ=="], "@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EtvsWfGrqx3OhzJdoi82qH+4yzEPPKZr2utyQ+w8cHKoFeg0+8Lou9Z3uixy73WEwz8Z1+AR8QT9fZ64AWGYPA=="], @@ -1211,7 +1215,7 @@ "@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "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-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="], - "@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=="], + "@ai-sdk/xai": ["@ai-sdk/xai@3.0.102", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.56", "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NeQyOR7OCqDMgaLS4uNX/ep/HrwUzzFYLzXQSRoqLy2jsnqxAJhsgltRwAwf+ADjyPBIAKEOestWnIQA+LrLrQ=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -1467,6 +1471,10 @@ "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="], + "@corvu/dialog": ["@corvu/dialog@0.2.4", "", { "dependencies": { "@corvu/utils": "~0.4.2", "solid-dismissible": "~0.1.1", "solid-focus-trap": "~0.1.8", "solid-presence": "~0.2.0", "solid-prevent-scroll": "~0.1.10" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-n54vJq+fOy8GVrnYBdJpD6JXNuyx7LOeMrRxwzAvZnYGpW8+AA12tnb/P/2emJj/HjOO5otheGKb0breshdFlA=="], + + "@corvu/drawer": ["@corvu/drawer@0.2.4", "", { "dependencies": { "@corvu/dialog": "~0.2.4", "@corvu/utils": "~0.4.2", "@solid-primitives/memo": "^1.4.1", "solid-transition-size": "~0.1.4" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-7jQoGZ8ROB9CmXam2nMY2wEskU3IoFwZQywkF/7vrBc/edGsPv7mOVQ1GN6G+4nd7nrMZ3UtqHAhmRh9V0azlw=="], + "@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -2031,27 +2039,27 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@opentui/core": ["@opentui/core@0.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="], + "@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.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="], + "@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.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="], + "@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.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="], + "@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.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="], + "@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.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="], + "@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.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="], + "@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.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="], + "@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.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="], + "@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.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="], + "@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.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "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-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="], + "@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=="], @@ -2667,6 +2675,8 @@ "@solid-primitives/media": ["@solid-primitives/media@2.3.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-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="], + "@solid-primitives/memo": ["@solid-primitives/memo@1.5.0", "", { "dependencies": { "@solid-primitives/scheduled": "^1.5.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-nSMHpFdnOMP88t7lqtktUXJlhQdJk0BQs2v3jUqcn+OtFwUm27Oa/coKl1BHDxL/65jR5drjqxxvuhaIpiUi0w=="], + "@solid-primitives/props": ["@solid-primitives/props@3.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-XzG6en9gSFwmvbKcATm2BxL63HegZ+BAG5fmHi8jyBppQHcaths7ffz+6vYvwYy3nlgLa20ufJLj7tst+PcHFA=="], "@solid-primitives/refs": ["@solid-primitives/refs@1.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA=="], @@ -2771,9 +2781,9 @@ "@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="], - "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.28", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-kRuOEL5orH/rzGgxNgfgOttsgV6cgrUeupVtrHMITb5p0rZ3hnxhbu/lhKcR9+7x+EJdfUtJIb2CVC85mlw15g=="], + "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.32", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-yhX4A4Kgn+wyTg6Mmu8+zwoMTwjz4K1ucvLfRJ8f0rPGDDAIqSaf0v6oU0yT9+SvrjmUaZQ0VX7g4byexbhNng=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="], "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], @@ -3243,7 +3253,7 @@ "builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="], - "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="], + "bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="], "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], @@ -3841,7 +3851,7 @@ "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.11.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-SJ6f5qa7P8md6lPrserryER3zerLkrezlnqqYQ2AbvDPpHLbwtbyk0FYJ5kNRcmbI80i/VMcsMBP0YIRdc3ucQ=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], @@ -4303,7 +4313,7 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], "marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="], @@ -5099,11 +5109,15 @@ "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + "solid-dismissible": ["solid-dismissible@0.1.1", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-9kcKBJIMdS+586cA1g63HYWxKh3h89leeNHbPZ1csYjuni+NvPBtNr11l0iEX2AKKEt6FHk6qNhc/gjoYAW1pA=="], + + "solid-focus-trap": ["solid-focus-trap@0.1.9", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-LTyNki6GUJPRLXV5uMWPkYClB07SUMubbr2EkAddiR0CJCF/I283txilMU9RURSr/P8EewMfXWu2o3aWrK7A5A=="], + "solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="], "solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="], - "solid-presence": ["solid-presence@0.1.8", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA=="], + "solid-presence": ["solid-presence@0.2.0", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-YM92o+jvpzX3XGaD4rLYmq/Kc2ZVh47GSCLEufHBFQQIurvZTs8SoGJxO8BJGNDxBKdcS8F3dYhW1SDXp4BNjA=="], "solid-prevent-scroll": ["solid-prevent-scroll@0.1.10", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-KplGPX2GHiWJLZ6AXYRql4M127PdYzfwvLJJXMkO+CMb8Np4VxqDAg5S8jLdwlEuBis/ia9DKw2M8dFx5u8Mhw=="], @@ -5111,6 +5125,8 @@ "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=="], + "solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="], "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], @@ -5591,8 +5607,6 @@ "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], - "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], - "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], @@ -5641,7 +5655,11 @@ "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], - "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], + "@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], + + "@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + + "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], "@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], @@ -5653,9 +5671,9 @@ "@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], "@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], @@ -5695,7 +5713,11 @@ "@ai-sdk/vercel/@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/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=="], + "@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cQrN6OUn/jvsY3OdsU6Wn+ss7vp1iwIcakZKSlSRMnYqShBfyT7Qht+eqmgxs7w9ttrw6FAG6o11AiBs+iEsTA=="], + + "@ai-sdk/xai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.13", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-ZPtVYt5QIJzOta1kdUiDuCx4HhFkvNPv/rvmZ2b1iXwybYjJsCnNYR4PAw4kW7rgVfDARvHXcU64efWuqNp6bw=="], + + "@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bjYld/2KGPLt78kpqbya+fD4LYS7BqVQJyUjE3qAHrYB0FR2Q90BaWEVIBZaguTWXf/A8L6uG1zO1v9TxVlGWg=="], "@astrojs/check/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -5919,6 +5941,8 @@ "@jsx-email/doiuse-email/htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="], + "@kobalte/core/solid-presence": ["solid-presence@0.1.8", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA=="], + "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "@mdx-js/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -6001,7 +6025,7 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], - "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], + "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], "@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], @@ -6027,6 +6051,8 @@ "@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], @@ -6143,8 +6169,14 @@ "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="], + "ai-gateway-provider/@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="], + + "ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="], + "ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], + "ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="], + "ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.8.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ=="], "ajv-keywords/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], @@ -6367,7 +6399,7 @@ "opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="], - "opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], + "opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], @@ -6443,6 +6475,8 @@ "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], + "solid-transition-size/@corvu/utils": ["@corvu/utils@0.3.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.7" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ZWlyWEE8qV9+CB9OAyo2bTrZGXQN9ZeM+JfYv89zoR+lRACKTDuoOZEdiyL8Uc7U5dUSH1uTqKhTTnaHWb+wZA=="], + "sort-keys/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="], "sst/aws4fetch": ["aws4fetch@1.0.18", "", {}, "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ=="], @@ -6579,6 +6613,8 @@ "@ai-sdk/vercel/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@ai-sdk/xai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@astrojs/check/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "@astrojs/check/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -6871,6 +6907,10 @@ "@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], + "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + + "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], + "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], "@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], @@ -6941,6 +6981,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/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=="], "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -7091,6 +7141,10 @@ "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="], + "opencode/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + + "opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], + "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], @@ -7339,6 +7393,8 @@ "@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], @@ -7355,6 +7411,10 @@ "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], @@ -7425,6 +7485,8 @@ "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "opencode/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], diff --git a/infra/lake.ts b/infra/lake.ts index dd11ca38f9..d4e46168e3 100644 --- a/infra/lake.ts +++ b/infra/lake.ts @@ -67,6 +67,10 @@ const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", { configuration: { enforceWorkgroupConfiguration: true, publishCloudwatchMetricsEnabled: true, + // Athena bills $5/TB scanned; kill any query that would scan more than 2 TB + // so a regression cannot silently burn money. Stats sync full passes scan + // ~250 GB as of 2026-07. + bytesScannedCutoffPerQuery: 2 * 1024 ** 4, resultConfiguration: { outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`, }, 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/infra/stats.ts b/infra/stats.ts index b5b0e1c600..10d37119f0 100644 --- a/infra/stats.ts +++ b/infra/stats.ts @@ -185,7 +185,9 @@ export const statSync = new sst.aws.Service("StatsSyncService", { cluster: lakeCluster, architecture: "arm64", cpu: "0.25 vCPU", - memory: "0.5 GB", + // 0.5 GB caused an OOM crash loop: every restart immediately re-ran the 4 Athena + // stats queries (~$5/pass) every ~5 minutes instead of hourly. + memory: "2 GB", image: { context: ".", dockerfile: "packages/stats/server/Dockerfile", 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 d17f7db337..b5ca7802d5 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-ovrz0pALxRek5VEqSYkVpzb9YeiiVQWxfAHZQ3kX0/0=", - "aarch64-linux": "sha256-+sqBt11Nl1fDrL1JvFAcN8JMWahspfbBPuMeM7YdagU=", - "aarch64-darwin": "sha256-PsZfcmMpz/Xzudjv4CQZu+wPfnyijrlVYeP2x9tRffk=", - "x86_64-darwin": "sha256-4TpIc3Gh9nDaA5Y1zkCq0KraMdWu+PWtYVY3p7/CsJ0=" + "x86_64-linux": "sha256-zAftZieWR0hWQQVkhEa826iueHaRnKxtyRodhNb+Apc=", + "aarch64-linux": "sha256-cTG+gB7CuiFgaYHSFbzXaXUI1C2kiugV3B9rUDRUwlg=", + "aarch64-darwin": "sha256-ap4Xv5ZliourkI4mcD0ktbzSI88jb4oVBv5mMK3WdUs=", + "x86_64-darwin": "sha256-NGRJMMnaMNrf3FkcYwr5PcfVm5thhlNLGIeMmAK0MeE=" } } diff --git a/package.json b/package.json index fd86ad3dec..332d7520a6 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "prepare": "husky", "random": "echo 'Random script'", "sso": "aws sso login --sso-session=opencode --no-browser", + "translate:app": "bun run script/translate-app.ts", "test": "echo 'do not run tests from root' && exit 1" }, "workspaces": { @@ -39,13 +40,14 @@ "@octokit/rest": "22.0.0", "@hono/standard-validator": "0.2.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.3.4", - "@opentui/keymap": "0.3.4", - "@opentui/solid": "0.3.4", - "@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", + "@corvu/drawer": "0.2.4", "@types/luxon": "3.7.1", "@types/node": "24.12.2", "@types/semver": "7.7.1", @@ -68,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", @@ -146,14 +148,13 @@ "@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.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", + "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.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/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index e63445e87d..5095d95db0 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -136,6 +136,9 @@ export async function setupTimeline( }, }), ) + if (settings.newLayoutDesigns === false) { + localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" })) + } }, input.settings ?? {}) if (input.locale) { await page.addInitScript((locale) => { diff --git a/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts b/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts index 4c2e99ffaa..e67545104d 100644 --- a/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts @@ -48,6 +48,40 @@ test("does not reverse visible rows when the user wheels during shell remeasurem await reportVisualStability(testInfo, "wheel-during-resize", trace, rowPairPlan(regions, 1)) }) +test("keeps moving upward while drag-selecting above the timeline", async ({ page }) => { + await setupTimeline(page, { + messages: history(80), + viewport: { width: 1400, height: 700 }, + reducedMotion: true, + }) + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + const text = page.getByText("History 79.", { exact: false }) + await expect(text).toBeVisible() + await scroller.evaluate((element) => { + element.dataset.selectionLength = "0" + document.addEventListener("selectionchange", () => { + element.dataset.selectionLength = String( + Math.max(Number(element.dataset.selectionLength), window.getSelection()?.toString().length ?? 0), + ) + }) + }) + const textBox = await text.boundingBox() + const scrollBox = await scroller.boundingBox() + expect(textBox).not.toBeNull() + expect(scrollBox).not.toBeNull() + if (!textBox || !scrollBox) return + + await page.mouse.move(textBox.x + textBox.width - 10, textBox.y + textBox.height / 2) + await page.mouse.down() + await page.mouse.move(textBox.x + 20, scrollBox.y - 120, { steps: 30 }) + + await expect.poll(() => scroller.evaluate((element) => Number(element.dataset.selectionLength))).toBeGreaterThan(0) + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeGreaterThan(500) + await page.mouse.up() +}) + test("does not pull a keyboard-scrolled user during shell remeasurement", async ({ page }, testInfo) => { const shellID = "prt_keyboard_01_shell" const followingID = "prt_keyboard_02_following" diff --git a/packages/app/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts b/packages/app/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts new file mode 100644 index 0000000000..81de0a055f --- /dev/null +++ b/packages/app/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts @@ -0,0 +1,312 @@ +import type { Page } from "@playwright/test" +import { benchmark, expect } from "../benchmark" +import { setupTimelineBenchmark } from "./session-timeline-benchmark.fixture" + +const changedLinesPerFile = 100 +const linesPerSide = changedLinesPerFile / 2 +const fileCounts = [1, 10, 100, 1_000, 10_000] +const filesPerDirectory = 100 +const readyFrames = 3 +const completionTimeoutMs = Number(process.env.REVIEW_PANE_COMPLETION_TIMEOUT_MS ?? 900_000) + +type ReviewPaneScalingSample = { + observedAtMs: number + logicalRows: number + treeRows: number + fileRows: number + diffLines: number + header: string + ready: boolean +} + +type ReviewPaneScalingProbe = { + startedAt?: number + firstTreeRowMs?: number + logicalTreeReadyMs?: number + firstDiffRenderMs?: number + stableReadyMs?: number + samples: ReviewPaneScalingSample[] + frameTimesMs: number[] + longTasks: { startTime: number; duration: number }[] + stop: () => void +} + +benchmark.describe("performance: review pane scaling", () => { + for (const fileCount of fileCounts) { + const changedLines = fileCount * changedLinesPerFile + + benchmark( + `${changedLines} changed lines across ${fileCount} ${fileCount === 1 ? "file" : "files"}`, + async ({ page, report }) => { + benchmark.setTimeout(1_200_000) + await page.emulateMedia({ reducedMotion: "reduce" }) + + const patchByteLimit = Number(process.env.REVIEW_PANE_PATCH_BYTE_LIMIT ?? Number.POSITIVE_INFINITY) + if (Number.isNaN(patchByteLimit) || patchByteLimit < 0) + throw new Error(`Invalid REVIEW_PANE_PATCH_BYTE_LIMIT: ${process.env.REVIEW_PANE_PATCH_BYTE_LIMIT}`) + const responseBody = JSON.stringify(createScalingDiffs(fileCount, patchByteLimit)) + await setupTimelineBenchmark(page, { + historyTurns: 0, + eventBatch: 1, + newLayoutDesigns: true, + }) + await page.route("**/vcs/diff**", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: responseBody, + }), + ) + + const expectedRows = fileCount + 2 + Math.ceil(fileCount / filesPerDirectory) + const metrics = await measureReviewPaneLoad(page, { + expectedFile: reviewFile(0), + expectedRows, + }) + const search = await measureBroadReviewSearch(page, fileCount) + + expect(metrics.logicalRows).toBe(expectedRows) + expect(metrics.fileRows).toBeGreaterThan(0) + expect(metrics.treeRows).toBeGreaterThan(0) + expect(metrics.diffLines).toBeGreaterThan(0) + expect(search.logicalRows).toBe(fileCount) + expect(search.renderedRows).toBeGreaterThan(0) + report( + { ...metrics, search }, + { + fileCount, + changedLinesPerFile, + changedLines, + additions: changedLines / 2, + deletions: changedLines / 2, + patchLines: changedLines, + patchByteLimit: Number.isFinite(patchByteLimit) ? patchByteLimit : null, + payloadBytes: new TextEncoder().encode(responseBody).byteLength, + expectedRows, + }, + ) + }, + ) + } +}) + +async function measureBroadReviewSearch(page: Page, expectedRows: number) { + const filter = page.getByRole("searchbox", { name: "Filter files" }) + await filter.evaluate((element) => { + element.addEventListener( + "input", + () => { + ;(window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt = performance.now() + }, + { once: true, capture: true }, + ) + }) + await filter.fill("file-") + + return page.evaluate((expectedRows) => { + const startedAt = (window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt! + return new Promise<{ stableMs: number; logicalRows: number; renderedRows: number }>((resolve) => { + let previous = -1 + let streak = 0 + const sample = () => { + const tree = document.querySelector('#review-panel [data-component="file-tree-v2"]') + const rows = [...document.querySelectorAll('#review-panel [data-slot="file-tree-v2-row"]')] + const logicalRows = Number(tree?.dataset.totalRows ?? rows.length) + const ready = + logicalRows === expectedRows && rows.length > 0 && rows.every((row) => row.textContent?.includes("file-")) + streak = ready && rows.length === previous ? streak + 1 : ready ? 1 : 0 + previous = rows.length + if (streak >= 3) { + resolve({ stableMs: performance.now() - startedAt, logicalRows, renderedRows: rows.length }) + return + } + requestAnimationFrame(sample) + } + requestAnimationFrame(sample) + }) + }, expectedRows) +} + +function createScalingDiffs(fileCount: number, patchByteLimit: number) { + const changes = Array.from({ length: linesPerSide }, (_, index) => { + const line = String(index).padStart(3, "0") + return `-export const value_${line} = "before"\n+export const value_${line} = "after"` + }).join("\n") + let patchBytes = 0 + let capped = false + + return Array.from({ length: fileCount }, (_, index) => { + const file = reviewFile(index) + const fullPatch = [ + `diff --git a/${file} b/${file}`, + `--- a/${file}`, + `+++ b/${file}`, + `@@ -1,${linesPerSide} +1,${linesPerSide} @@`, + changes, + ].join("\n") + if (index === 0 && fullPatch.length > patchByteLimit) + throw new Error(`REVIEW_PANE_PATCH_BYTE_LIMIT must include the active patch (${fullPatch.length} bytes)`) + const patch = !capped && patchBytes + fullPatch.length <= patchByteLimit ? fullPatch : emptyReviewPatch(file) + if (patch === fullPatch) patchBytes += fullPatch.length + else capped = true + return { + file, + patch, + additions: linesPerSide, + deletions: linesPerSide, + status: "modified" as const, + } + }) +} + +function emptyReviewPatch(file: string) { + return [`diff --git a/${file} b/${file}`, `--- a/${file}`, `+++ b/${file}`].join("\n") +} + +function reviewFile(index: number) { + return `src/review/d${String(Math.floor(index / filesPerDirectory)).padStart(5, "0")}/file-${String(index).padStart(5, "0")}.ts` +} + +async function measureReviewPaneLoad(page: Page, input: { expectedFile: string; expectedRows: number }) { + const toggle = page.getByRole("button", { name: "Toggle review" }) + await expect(toggle).toBeVisible() + await toggle.evaluate((element) => element.setAttribute("data-review-pane-scaling-toggle", "")) + await installReviewPaneScalingProbe(page, input) + await toggle.click() + await page.waitForFunction( + () => + (window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe + ?.stableReadyMs !== undefined, + undefined, + { timeout: completionTimeoutMs }, + ) + + return page.evaluate(() => { + const probe = (window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe! + probe.stop() + const startedAt = probe.startedAt! + const final = probe.samples.at(-1)! + const resources = performance + .getEntriesByType("resource") + .filter((entry) => entry.name.includes("/vcs/diff")) as PerformanceResourceTiming[] + const resource = resources.at(-1) + const longTasks = probe.longTasks.filter( + (entry) => entry.startTime >= startedAt && entry.startTime <= startedAt + probe.stableReadyMs!, + ) + const frameGaps = probe.frameTimesMs.map((time, index) => time - (probe.frameTimesMs[index - 1] ?? 0)) + + return { + firstTreeRowMs: probe.firstTreeRowMs ?? null, + logicalTreeReadyMs: probe.logicalTreeReadyMs ?? null, + firstDiffRenderMs: probe.firstDiffRenderMs ?? null, + stableReadyMs: probe.stableReadyMs ?? null, + responseStartMs: resource ? resource.responseStart - startedAt : null, + responseEndMs: resource ? resource.responseEnd - startedAt : null, + responseToStableMs: resource ? probe.stableReadyMs! - (resource.responseEnd - startedAt) : null, + treeRows: final.treeRows, + logicalRows: final.logicalRows, + fileRows: final.fileRows, + diffLines: final.diffLines, + samples: probe.samples.length, + maxFrameGapMs: Math.max(0, ...frameGaps), + longTaskCount: longTasks.length, + longTaskTotalMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0), + maxLongTaskMs: Math.max(0, ...longTasks.map((entry) => entry.duration)), + } + }) +} + +async function installReviewPaneScalingProbe(page: Page, input: { expectedFile: string; expectedRows: number }) { + await page.evaluate( + ({ expectedFile, expectedRows, stableFrames }) => { + let running = true + let readyStreak = 0 + const basename = expectedFile.split("/").at(-1)! + const longTaskObserver = PerformanceObserver.supportedEntryTypes.includes("longtask") + ? new PerformanceObserver((list) => { + probe.longTasks.push( + ...list.getEntries().map((entry) => ({ startTime: entry.startTime, duration: entry.duration })), + ) + }) + : undefined + const probe: ReviewPaneScalingProbe = { + samples: [], + frameTimesMs: [], + longTasks: [], + stop: () => { + running = false + longTaskObserver?.disconnect() + }, + } + + const sample = (time: number) => { + if (!running || probe.startedAt === undefined) return + const panel = document.querySelector("#review-panel") + const tree = panel?.querySelector('[data-component="file-tree-v2"]') + const rows = panel?.querySelectorAll('[data-slot="file-tree-v2-row"]') ?? [] + const fileRows = panel?.querySelectorAll('button[data-slot="file-tree-v2-row"]') ?? [] + const header = + panel?.querySelector('[data-slot="session-review-v2-file-header"]')?.textContent?.trim() ?? "" + const viewers = panel + ? [...panel.querySelectorAll('[data-component="file"][data-mode="diff"]')] + : [] + const diffLines = viewers.reduce( + (sum, viewer) => + sum + (viewer.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0), + 0, + ) + const observedAtMs = time - probe.startedAt + const logicalRows = Number(tree?.dataset.totalRows ?? rows.length) + const ready = + logicalRows === expectedRows && + fileRows.length > 0 && + header.includes(basename) && + viewers.length === 1 && + diffLines > 0 + const previous = probe.samples.at(-1) + const stable = + ready && + previous?.ready === true && + previous.logicalRows === logicalRows && + previous.treeRows === rows.length && + previous.fileRows === fileRows.length && + previous.diffLines === diffLines && + previous.header === header + + probe.frameTimesMs.push(observedAtMs) + probe.samples.push({ + observedAtMs, + logicalRows, + treeRows: rows.length, + fileRows: fileRows.length, + diffLines, + header, + ready, + }) + if (probe.firstTreeRowMs === undefined && rows.length > 0) probe.firstTreeRowMs = observedAtMs + if (probe.logicalTreeReadyMs === undefined && logicalRows === expectedRows) + probe.logicalTreeReadyMs = observedAtMs + if (probe.firstDiffRenderMs === undefined && diffLines > 0) probe.firstDiffRenderMs = observedAtMs + readyStreak = !ready ? 0 : stable ? readyStreak + 1 : 1 + if (readyStreak === stableFrames) probe.stableReadyMs = observedAtMs + if (probe.stableReadyMs === undefined) requestAnimationFrame(sample) + } + + longTaskObserver?.observe({ type: "longtask", buffered: true }) + document.addEventListener( + "click", + (event) => { + const toggle = event.target instanceof Element ? event.target.closest("button") : undefined + if (!toggle?.hasAttribute("data-review-pane-scaling-toggle")) return + probe.startedAt = performance.now() + performance.mark("opencode.review-pane-scaling.click") + requestAnimationFrame(sample) + }, + { capture: true, once: true }, + ) + ;(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe = probe + }, + { ...input, stableFrames: readyFrames }, + ) +} 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 new file mode 100644 index 0000000000..2a214831da --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts @@ -0,0 +1,151 @@ +import type { Page } from "@playwright/test" +import { expectSessionTitle } from "../../utils/waits" +import { mockOpenCodeServer } from "../../utils/mock-server" +import { benchmark, expect, withBenchmarkPage } from "../benchmark" +import { fixture } from "./session-timeline-stress.fixture" +import { installStressSessionTabs, stressSessionHref } from "./timeline-test-helpers" +import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe" + +type ParentHydrationBenchmarkMode = "natural" | "candidate" + +const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural" +if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`) +const userID = "msg_parent_hydration_user" +const user = { + ...fixture.messages[fixture.targetID][0]!, + info: { ...fixture.messages[fixture.targetID][0]!.info, id: userID, time: { created: 1700001000000 } }, + parts: fixture.messages[fixture.targetID][0]!.parts.map((part, index) => ({ + ...part, + id: `prt_parent_hydration_user_${index}`, + messageID: userID, + })), +} +const assistantSeed = fixture.messages[fixture.targetID][3]! +const assistants = Array.from({ length: 14 }, (_, index) => { + const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}` + return { + ...assistantSeed, + info: { + ...assistantSeed.info, + id: messageID, + parentID: userID, + time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 }, + }, + parts: assistantSeed.parts.map((part, partIndex) => ({ + ...part, + id: `prt_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}`, + messageID, + })), + } +}) +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 + +benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => { + benchmark.setTimeout(180_000) + const results = [] as Awaited>[] + for (let run = 0; run < 5; run++) { + results.push( + await withBenchmarkPage( + browser, + `session-parent-hydration-${mode}-${run}`, + (page) => trial(page, mode), + testInfo, + ), + ) + } + const timing = results.map((result) => result.metrics.firstCorrectObservedMs!).sort((a, b) => a - b) + report( + { + results: results.map((result) => ({ ...result.metrics, historyGateCount: result.historyGateCount })), + summary: { + firstCorrectObservedMs: { min: timing[0], median: timing[2], max: timing.at(-1) }, + blankSamples: results.map((result) => result.metrics.blankSamples), + requestCounts: { + list: results.map((result) => result.requestCounts.list), + parent: results.map((result) => result.requestCounts.parent), + }, + historyGateCount: results.map((result) => result.historyGateCount), + }, + }, + { mode }, + ) +}) + +async function trial(page: Page, mode: ParentHydrationBenchmarkMode) { + const requests: { type: "list" | "parent"; before?: string }[] = [] + const history = mode === "candidate" ? Promise.withResolvers() : undefined + let historyGates = 0 + await mockOpenCodeServer(page, { + sessions: fixture.sessions.filter((session) => session.id === fixture.sourceID), + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + messageDelay: 50, + onMessages: (request) => { + if (request.sessionID === fixture.targetID && request.phase === "start") + requests.push({ type: "list", before: request.before }) + }, + beforeMessagesResponse: (request) => { + if (mode !== "candidate" || request.sessionID !== fixture.targetID || !request.before) return Promise.resolve() + historyGates++ + return history!.promise + }, + onMessage: (request) => { + if (request.sessionID === fixture.targetID && request.messageID === userID) requests.push({ type: "parent" }) + }, + message: (sessionID, messageID) => { + if (sessionID !== fixture.targetID || messageID !== userID) return + return user + }, + pageMessages: (sessionID, limit, before) => { + const items = sessionID === fixture.targetID ? messages : fixture.messages[fixture.sourceID] + const end = before ? items.findIndex((message) => message.info.id === before) : items.length + const start = Math.max(0, end - limit) + 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 installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] }) + await page.goto(stressSessionHref(fixture.sourceID)) + await expectSessionTitle(page, fixture.expected.sourceTitle) + await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!) + + const href = stressSessionHref(fixture.targetID) + await page.evaluate( + ({ href, title }) => { + const link = document.createElement("a") + link.id = "parent-hydration-target" + link.href = href + link.textContent = title + document.body.append(link) + }, + { href, title: target.title }, + ) + const metrics = await measureSessionSwitch(page, { + destinationIDs: messages.map((message) => message.info.id), + sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.info.id), + lastID, + requiredPartID: lastPartID, + requireBottomAnchor: false, + href, + switch: async () => { + await page.locator("#parent-hydration-target").click() + await expectSessionTitle(page, target.title) + }, + }).finally(() => history?.resolve()) + expect(metrics.firstCorrectObservedMs).not.toBeNull() + const requestCounts = { + list: requests.filter((request) => request.type === "list").length, + parent: requests.filter((request) => request.type === "parent").length, + } + if (mode === "candidate") { + expect(requestCounts.parent).toBe(1) + expect(historyGates).toBe(1) + } + return { metrics, requestCounts, historyGateCount: historyGates } +} diff --git a/packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts b/packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts index 05a0dae496..5298b9b0b7 100644 --- a/packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts +++ b/packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts @@ -4,6 +4,8 @@ export type SessionSwitchSample = { source: string[] hasVisibleRows: boolean last: boolean + requiredPartVisible?: boolean + bottomAnchorRequired?: boolean bottomErrorPx?: number review?: { fileHost: boolean @@ -41,7 +43,8 @@ export function isCorrectDestination(sample: SessionSwitchSample) { sample.destination.length > 0 && sample.source.length === 0 && sample.last && - Math.abs(sample.bottomErrorPx ?? Infinity) <= 1 + sample.requiredPartVisible !== false && + (sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1) ) } diff --git a/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts b/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts index f61160f7a7..955da80367 100644 --- a/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts +++ b/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts @@ -8,9 +8,16 @@ type SessionSwitchProbe = { async function installSessionSwitchProbe( page: Page, - input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string }, + input: { + destinationIDs: string[] + sourceIDs: string[] + lastID: string + requiredPartID?: string + requireBottomAnchor?: boolean + href: string + }, ) { - await page.evaluate(({ destinationIDs, sourceIDs, lastID, href }) => { + await page.evaluate(({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, href }) => { const destination = new Set(destinationIDs) const source = new Set(sourceIDs) const samples: SessionSwitchSample[] = [] @@ -66,6 +73,13 @@ async function installSessionSwitchProbe( const rect = element.getBoundingClientRect() return rect.bottom > view.top && rect.top < view.bottom }) + const requiredPartVisible = requiredPartID + ? [...root.querySelectorAll("[data-timeline-part-id]")].some((element) => { + if (element.dataset.timelinePartId !== requiredPartID) return false + const rect = element.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom + }) + : undefined const spacer = root.querySelector('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect() samples.push({ observedAtMs, @@ -73,11 +87,22 @@ async function installSessionSwitchProbe( source: visible.filter((id) => source.has(id)), hasVisibleRows, last: visible.includes(lastID), + requiredPartVisible, + bottomAnchorRequired: requireBottomAnchor !== false, bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined, review, }) } else { - samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false, review }) + samples.push({ + observedAtMs, + destination: [], + source: [], + hasVisibleRows: false, + last: false, + requiredPartVisible: requiredPartID ? false : undefined, + bottomAnchorRequired: requireBottomAnchor !== false, + review, + }) } requestAnimationFrame(sample) }, 0) @@ -117,7 +142,8 @@ async function waitForStableSessionSwitch(page: Page) { sample.destination.length > 0 && sample.source.length === 0 && sample.last && - Math.abs(sample.bottomErrorPx ?? Infinity) <= 1, + sample.requiredPartVisible !== false && + (sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1), ) ) }) @@ -135,13 +161,27 @@ async function collectSessionSwitchResult(page: Page) { export async function measureSessionSwitch( page: Page, - input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string; switch: () => Promise }, + input: { + destinationIDs: string[] + sourceIDs: string[] + lastID: string + requiredPartID?: string + requireBottomAnchor?: boolean + href: string + switch: () => Promise + }, ) { const { switch: run, ...probe } = input await installSessionSwitchProbe(page, probe) - await run() - await waitForStableSessionSwitch(page) - return collectSessionSwitchResult(page) + try { + await run() + await waitForStableSessionSwitch(page) + return await collectSessionSwitchResult(page) + } finally { + await page.evaluate(() => { + ;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.stop() + }) + } } export async function waitForStableTimeline(page: Page, lastID: string) { diff --git a/packages/app/e2e/performance/unit/mock-server.test.ts b/packages/app/e2e/performance/unit/mock-server.test.ts new file mode 100644 index 0000000000..83308c0a86 --- /dev/null +++ b/packages/app/e2e/performance/unit/mock-server.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test" +import type { Page, Route } from "@playwright/test" +import { mockOpenCodeServer } from "../../utils/mock-server" + +test("applies message latency after a list response gate is released", async () => { + const events: string[] = [] + const gate = Promise.withResolvers() + let handler: ((route: Route) => Promise) | undefined + const page = { + route: (_url: string, callback: (route: Route) => Promise) => { + handler = callback + return Promise.resolve() + }, + } as unknown as Page + await mockOpenCodeServer(page, { + provider: {}, + directory: "C:/OpenCode", + project: {}, + sessions: [{ id: "session" }], + messageDelay: 25, + beforeMessagesResponse: () => { + events.push("before") + return gate.promise + }, + onMessages: (request) => events.push(request.phase), + pageMessages: () => { + events.push("page") + return { items: [] } + }, + }) + + const response = handler!({ + request: () => ({ url: () => "http://127.0.0.1:4096/session/session/message" }), + fulfill: () => { + events.push("fulfill") + return Promise.resolve() + }, + } as unknown as Route) + expect(events).toEqual(["start", "before"]) + + const released = performance.now() + gate.resolve() + await response + expect(performance.now() - released).toBeGreaterThanOrEqual(20) + expect(events).toEqual(["start", "before", "page", "end", "fulfill"]) +}) diff --git a/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts b/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts index dd771b7d57..4b824d9dfb 100644 --- a/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts +++ b/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts @@ -52,3 +52,35 @@ test("reports missing correctness without throwing", () => { expect(result.firstCorrectObservedMs).toBeNull() expect(result.stableObservedMs).toBeNull() }) + +test("requires an explicitly tracked part to be visible", () => { + const result = classifySessionSwitch([ + { + observedAtMs: 16, + destination: ["destination"], + source: [], + hasVisibleRows: true, + last: true, + requiredPartVisible: false, + bottomErrorPx: 0, + }, + ]) + + expect(result.firstCorrectObservedMs).toBeNull() +}) + +test("can measure content correctness without requiring a bottom anchor", () => { + const result = classifySessionSwitch([ + { + observedAtMs: 16, + destination: ["destination"], + source: [], + hasVisibleRows: true, + last: true, + requiredPartVisible: true, + bottomAnchorRequired: false, + }, + ]) + + expect(result.firstCorrectObservedMs).toBe(16) +}) diff --git a/packages/app/e2e/performance/unit/session-tab-switch-probe.test.ts b/packages/app/e2e/performance/unit/session-tab-switch-probe.test.ts new file mode 100644 index 0000000000..d5d7f09a69 --- /dev/null +++ b/packages/app/e2e/performance/unit/session-tab-switch-probe.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test" +import type { Page } from "@playwright/test" +import { measureSessionSwitch } from "../timeline/session-tab-switch-probe" + +function testPage(waitFailure?: Error) { + const stops: unknown[] = [] + const page = { + evaluate: async (_callback: unknown, input?: unknown) => { + if (input) return + stops.push(undefined) + }, + waitForFunction: async () => { + if (waitFailure) throw waitFailure + }, + } as unknown as Page + return { page, stops } +} + +function input(run: () => Promise) { + return { + destinationIDs: ["destination"], + sourceIDs: ["source"], + lastID: "destination", + href: "/session/destination", + switch: run, + } +} + +test("stops sampling when the session switch fails", async () => { + const failure = new Error("switch failed") + const context = testPage() + + await expect( + measureSessionSwitch( + context.page, + input(async () => Promise.reject(failure)), + ), + ).rejects.toBe(failure) + + expect(context.stops).toHaveLength(1) +}) + +test("stops sampling when the stable wait fails", async () => { + const failure = new Error("stable wait failed") + const context = testPage(failure) + + await expect( + measureSessionSwitch( + context.page, + input(async () => {}), + ), + ).rejects.toBe(failure) + + expect(context.stops).toHaveLength(1) +}) 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 new file mode 100644 index 0000000000..45cd64adcc --- /dev/null +++ b/packages/app/e2e/regression/legacy-new-session.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { mockOpenCodeServer } from "../utils/mock-server" + +const draftID = "draft_legacy_new_session" +const directory = "C:/OpenCode/LegacyNewSession" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + +test("redirects a draft to the legacy new-session route", async ({ page }) => { + await mockOpenCodeServer(page, { + directory, + project: { + id: "proj_legacy_new_session", + worktree: directory, + vcs: "git", + name: "legacy-new-session", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { all: [], connected: [], default: {} }, + sessions: [], + pageMessages: () => ({ items: [] }), + }) + 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 }]), + ) + }, + { directory, draftID, server }, + ) + + await page.goto(`/new-session?draftId=${draftID}`) + + await expect(page).toHaveURL(`/${base64Encode(directory)}/session`) + await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible() + await expect(page.locator('[data-component="prompt-input"]')).toBeVisible() +}) 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..c17ae5c1c6 --- /dev/null +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -0,0 +1,270 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page, type Route } from "@playwright/test" +import { installSseTransport } from "../utils/sse-transport" + +const serverA = "http://127.0.0.1:4096" +const serverB = "http://127.0.0.1:4097" +const directoryA = "C:/server-a" +const directoryB = "/home/server-b" +const sessionA = session("ses_server_a", directoryA, "Server A session") +const childSessionA = { ...session("ses_server_a_child", directoryA, "Server A child session"), parentID: sessionA.id } +const sessionB = session("ses_server_b", directoryB, "Server B session") + +test("session settings use the remote server context", async ({ page }) => { + const permissionRequests: string[] = [] + await mockServers(page, permissionRequests) + await configureServers(page) + + await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`) + await expect(page.getByText(sessionB.title).first()).toBeVisible() + await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,") + + const dialog = page.locator(".settings-v2-dialog") + const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]') + const input = autoAccept.getByRole("switch") + await expect(autoAccept).toBeVisible() + await expect(input).toBeEnabled() + permissionRequests.length = 0 + await autoAccept.locator('[data-slot="switch-control"]').click() + await expect(input).toBeChecked() + await expect + .poll(() => + permissionRequests.some((request) => { + const url = new URL(request) + return url.origin === serverB && url.searchParams.get("directory") === directoryB + }), + ) + .toBe(true) + expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true) + + await dialog.getByRole("tab", { name: "Models" }).click() + await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled() + await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0) +}) + +test("auto-accept responds for an unfocused server session", async ({ page }) => { + const permissionRequests: string[] = [] + const permissionResponses: PermissionResponse[] = [] + const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { + server: serverA, + retry: 20, + }) + await mockServers(page, permissionRequests, permissionResponses) + await configureServers(page, [ + { type: "session", server: serverA, sessionId: sessionA.id }, + { type: "session", server: serverB, sessionId: sessionB.id }, + ]) + + const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` + await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`) + await expect(page.getByText(sessionA.title).first()).toBeVisible() + await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,") + const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]') + await autoAccept.locator('[data-slot="switch-control"]').click() + await expect(autoAccept.getByRole("switch")).toBeChecked() + await expect + .poll(() => + permissionRequests.some((request) => { + const url = new URL(request) + return url.origin === serverA && url.searchParams.get("directory") === directoryA + }), + ) + .toBe(true) + await page.keyboard.press("Escape") + + await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click() + await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) + await expect(page.getByText(sessionB.title).first()).toBeVisible() + await transport.waitForConnection() + + await transport.send({ + directory: directoryA, + payload: { + id: "event-permission-background-a", + type: "permission.asked", + properties: { + id: "permission-background-a", + sessionID: sessionA.id, + permission: "bash", + patterns: ["git status"], + metadata: {}, + always: [], + }, + }, + }) + + await expect + .poll(() => permissionResponses) + .toEqual([ + { + origin: serverA, + directory: directoryA, + sessionID: sessionA.id, + permissionID: "permission-background-a", + body: { response: "once" }, + }, + ]) + + await transport.send({ + directory: directoryA, + payload: { + id: "event-permission-background-a-child", + type: "permission.asked", + properties: { + id: "permission-background-a-child", + sessionID: childSessionA.id, + permission: "bash", + patterns: ["git diff"], + metadata: {}, + always: [], + }, + }, + }) + + await expect + .poll(() => permissionResponses) + .toEqual([ + { + origin: serverA, + directory: directoryA, + sessionID: sessionA.id, + permissionID: "permission-background-a", + body: { response: "once" }, + }, + { + origin: serverA, + directory: directoryA, + sessionID: childSessionA.id, + permissionID: "permission-background-a-child", + body: { response: "once" }, + }, + ]) +}) + +type PermissionResponse = { + origin: string + directory?: string + sessionID: string + permissionID: string + body: unknown +} + +async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) { + await page.addInitScript( + ({ serverB, tabs }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) + localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(tabs)) + }, + { serverB, tabs }, + ) +} + +async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) { + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()) + if (url.origin !== serverA && url.origin !== serverB) return route.fallback() + const remote = url.origin === serverB + const directory = remote ? directoryB : directoryA + const sessions = remote ? [sessionB] : [sessionA, childSessionA] + const requestDirectory = url.searchParams.get("directory") + const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/) + if (route.request().method() === "POST" && response) { + permissionResponses.push({ + origin: url.origin, + directory: requestDirectory ?? undefined, + sessionID: response[1]!, + permissionID: response[2]!, + body: route.request().postDataJSON(), + }) + return json(route, true) + } + if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) + if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) + if (url.pathname === "/global/health") return json(route, { healthy: true }) + if (url.pathname === "/session/status") return json(route, {}) + if (url.pathname === "/session") return json(route, sessions) + const current = sessions.find((session) => url.pathname === `/session/${session.id}`) + if (current) return json(route, current) + if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) + if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, []) + if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) + if (url.pathname === "/permission") { + permissionRequests.push(url.toString()) + return json(route, []) + } + if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname)) + return json(route, []) + if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) + if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a")) + if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) + if (url.pathname === "/project" || url.pathname === "/project/current") { + const project = { + id: remote ? sessionB.projectID : "project-server-a", + worktree: directory, + vcs: "git", + time: { created: 1, updated: 1 }, + sandboxes: [], + } + return json(route, url.pathname === "/project" ? [project] : project) + } + if (url.pathname === "/path") + return json(route, { + state: directory, + config: directory, + worktree: directory, + directory, + home: directory, + }) + if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + return json(route, {}) + }) +} + +function session(id: string, directory: string, title: string) { + return { + id, + slug: id, + projectID: `project-${id}`, + directory, + title, + version: "dev", + time: { created: 1, updated: 1 }, + } +} + +function provider(id: string) { + const name = id === "server-b" ? "Server B" : "Server A" + return { + all: [ + { + id, + name: `${name} Provider`, + models: { + [id]: { + id, + name: `${name} Model`, + family: id, + release_date: "2026-01-01", + limit: { context: 200_000 }, + }, + }, + }, + ], + connected: [id], + default: { providerID: id, modelID: id }, + } +} + +function json(route: Route, body: unknown, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify(body), + }) +} + +function sse(route: Route) { + return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) +} diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts new file mode 100644 index 0000000000..25ebd3a37a --- /dev/null +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -0,0 +1,163 @@ +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/ReviewOpenFile" +const projectID = "proj_review_open_file" +const sessionID = "ses_review_open_file" +const title = "Review open file" +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("opens and searches project files inline", async ({ page }) => { + const searches: { query: string; dirs?: string; limit?: number }[] = [] + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "open-file-project", + 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: [fileDiff("src/changed.ts")], + fileList: (path) => { + if (path) return [] + return [ + fileNode("README.md"), + { name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false }, + ] + }, + fileContent: (path) => ({ type: "text", content: `contents:${path}` }), + findFiles: (input) => { + searches.push(input) + return input.query === "nested" ? ["src/nested.ts"] : [] + }, + 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: false, 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") + const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]') + const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" }) + const contextButton = page.getByRole("button", { name: "View context usage" }) + await contextButton.click() + await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") + await panel.getByRole("button", { name: "Open file" }).click() + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + await expect(sidebarToggle).toBeDisabled() + await expect(sidebar).toBeVisible() + await contextButton.click() + await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") + await expect(sidebar).toBeHidden() + await panel.getByRole("button", { name: "Open file" }).click() + const filter = panel.getByRole("combobox", { name: "Filter files" }) + await expect(filter).toBeFocused() + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible() + + 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) + + await panel.getByRole("button", { name: "Open file" }).click() + await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0) + await expect(sidebar).toBeVisible() + await filter.fill("nested") + const result = panel.getByRole("option", { name: /nested\.ts/ }) + await expect(result).toBeVisible() + const resultID = await result.getAttribute("id") + expect(resultID).toBeTruthy() + 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.getByRole("tab", { name: /Review/ }).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", "") +}) + +function fileNode(path: string) { + return { + name: path, + path, + absolute: `${directory}/${path}`, + type: "file", + ignored: false, + } +} + +function fileDiff(file: string) { + return { + file, + before: "before\n", + after: "after\n", + additions: 1, + deletions: 1, + status: "modified", + } +} diff --git a/packages/app/e2e/regression/review-state-persistence.spec.ts b/packages/app/e2e/regression/review-state-persistence.spec.ts new file mode 100644 index 0000000000..aa42f1bb51 --- /dev/null +++ b/packages/app/e2e/regression/review-state-persistence.spec.ts @@ -0,0 +1,152 @@ +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/ReviewStatePersistence" +const projectID = "proj_review_state_persistence" +const sessionA = "ses_review_state_a" +const sessionB = "ses_review_state_b" +const titleA = "Alpha review state" +const titleB = "Beta review state" +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("restores review mode and selected file per session", async ({ page }) => { + await setup(page) + await page.goto(sessionHref(sessionA)) + await expectSessionTitle(page, titleA) + await page.getByRole("button", { name: "Toggle review" }).click() + + await selectMode(page, "Git changes", "Branch changes") + await selectFile(page, "beta.ts") + + await switchSession(page, titleB) + await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible() + await selectFile(page, "gamma.ts") + + await switchSession(page, titleA) + await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible() + await expectSelectedFile(page, "beta.ts") + await selectMode(page, "Branch changes", "Git changes") + await expectSelectedFile(page, "alpha.ts") + await selectMode(page, "Git changes", "Branch changes") + await expectSelectedFile(page, "beta.ts") + + await page.reload() + await expectSessionTitle(page, titleA) + await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible() + await expectSelectedFile(page, "beta.ts") + + await switchSession(page, titleB) + await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible() + await expectSelectedFile(page, "gamma.ts") +}) + +async function selectMode(page: Page, current: string, next: string) { + await page.getByRole("button", { name: current }).click() + await page.getByRole("option", { name: next }).click() +} + +async function selectFile(page: Page, file: string) { + await page.getByRole("button", { name: file }).click() + await expectSelectedFile(page, file) +} + +async function expectSelectedFile(page: Page, file: string) { + await expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText(file) +} + +async function switchSession(page: Page, title: string) { + await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click() + await expectSessionTitle(page, title) +} + +async function setup(page: Page) { + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "review-state-persistence", + 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: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], + pageMessages: () => ({ items: [] }), + }) + await page.route(/\/vcs(?:\?.*)?$/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ branch: "feature", default_branch: "dev" }), + }), + ) + await page.route("**/vcs/diff**", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + new URL(route.request().url()).searchParams.get("mode") === "branch" + ? [diff("src/alpha.ts"), diff("src/beta.ts")] + : [diff("src/alpha.ts"), diff("src/gamma.ts")], + ), + }), + ) + await page.addInitScript( + ({ directory, server, sessions }) => { + 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.window.browser.dat:tabs", + JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))), + ) + }, + { directory, server, sessions: [sessionA, sessionB] }, + ) +} + +function session(id: string, title: string, created: number) { + return { + id, + slug: id, + projectID, + directory, + title, + version: "dev", + time: { created, updated: created }, + } +} + +function diff(file: string) { + return { + file, + additions: 1, + deletions: 1, + status: "modified", + patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`, + } +} + +function sessionHref(sessionID: string) { + return `/server/${base64Encode(server)}/session/${sessionID}` +} diff --git a/packages/app/e2e/regression/review-tab-switch.spec.ts b/packages/app/e2e/regression/review-tab-switch.spec.ts index 92bf12d51c..8634166e51 100644 --- a/packages/app/e2e/regression/review-tab-switch.spec.ts +++ b/packages/app/e2e/regression/review-tab-switch.spec.ts @@ -10,6 +10,9 @@ const sessionB = "ses_review_tab_b" const titleA = "Alpha session" const titleB = "Beta session" const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +const diffs = Array.from({ length: 2_740 }, (_, index) => + fileDiff(`src/generated-${String(index).padStart(4, "0")}.ts`), +) // Marks the review pane DOM node so a remount (fresh node) is detectable. const PROBE = "original" @@ -25,20 +28,34 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac await expectSessionTitle(page, titleA) await page.getByRole("button", { name: "Toggle review" }).click() + const reviewTab = page.locator("#session-side-panel-review-tab") + const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel") + 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"]') await expectAppVisible(review) - await expectAppVisible(page.getByRole("button", { name: /example\.ts/ })) + await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" })) await writeProbe(page) await switchTab(page, titleB) await expectSessionTitle(page, titleB) await expectAppVisible(review) + await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" })) expect(await readProbe(page)).toBe(PROBE) await switchTab(page, titleA) await expectSessionTitle(page, titleA) await expectAppVisible(review) + await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" })) expect(await readProbe(page)).toBe(PROBE) + + const viewport = page.locator('#review-panel [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) + await expect(page.getByRole("button", { name: "generated-2739.ts" })).toBeVisible() }) type Probed = HTMLElement & { __e2eProbe?: string } @@ -80,16 +97,7 @@ async function setup(page: Page) { default: { providerID: "opencode", modelID: "test" }, }, sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], - vcsDiff: [ - { - file: "src/example.ts", - additions: 1, - deletions: 1, - status: "modified", - patch: - "diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n", - }, - ], + vcsDiff: diffs, pageMessages: () => ({ items: [] }), }) @@ -127,3 +135,13 @@ function session(id: string, title: string, created: number) { function sessionHref(sessionID: string) { return `/server/${base64Encode(server)}/session/${sessionID}` } + +function fileDiff(file: string) { + return { + file, + additions: 1, + deletions: 1, + status: "modified", + patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`, + } +} diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts new file mode 100644 index 0000000000..154bab48c4 --- /dev/null +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -0,0 +1,263 @@ +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/ReviewTerminalStacked" +const projectID = "proj_review_terminal_stacked" +const sessionID = "ses_review_terminal_stacked" +const title = "Review terminal stacked" +const branchDiffs = [ + fileDiff(".github/actions/setup-bun/action.yml", 7), + ...Array.from({ length: 2_739 }, (_, index) => + fileDiff( + `src/branch/d${String(Math.floor(index / 100)).padStart(5, "0")}/generated-${String(index).padStart(4, "0")}.ts`, + 100, + false, + ), + ), +] + +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 }> = [] + let detailVersion = 1 + let detailFailures = 1 + await page.setViewportSize({ width: 1400, height: 900 }) + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "review-terminal-stacked", + 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: "review-terminal-stacked", + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + sessionStatus: { [sessionID]: { type: "idle" } }, + pageMessages: () => ({ items: [] }), + events: () => events.splice(0, 1), + eventRetry: 16, + }) + await page.route(/\/vcs(?:\?.*)?$/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }), + }), + ) + await page.route("**/vcs/diff**", (route) => { + const url = new URL(route.request().url()) + const scope = url.searchParams.get("directory")?.replaceAll("\\", "/") + const detail = scope?.endsWith("/src/branch/d00027") + if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" }) + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + url.searchParams.get("mode") === "branch" + ? detail + ? branchDiffs + .filter((diff) => diff.file.startsWith("src/branch/d00027/")) + .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion)) + : branchDiffs + : Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)), + ), + }) + }) + await page.route("**/pty", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }), + }), + ) + await page.route("**/pty/pty_review_terminal", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), + ) + await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined) + await page.addInitScript(() => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:layout", + JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), + ) + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + await expect(page.locator("#review-panel")).toBeVisible() + await expectTree(page, 8, "git-0.ts") + + await selectMode(page, "Git changes", "Branch changes") + await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740") + await page.keyboard.press("Control+Backquote") + await expect(page.locator("#terminal-panel")).toBeVisible() + await expectTree(page, 2_773, "action.yml") + await expectStackGeometry(page) + + const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport') + await treeViewport.hover() + await page.mouse.wheel(0, 100_000) + await expect + .poll(() => treeViewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) + const lastFile = page.getByRole("button", { name: "generated-2738.ts" }) + await expect(lastFile).toBeVisible() + const bottomGap = await lastFile.evaluate((element) => { + const viewport = element.closest(".scroll-view__viewport")!.getBoundingClientRect() + return viewport.bottom - element.getBoundingClientRect().bottom + }) + expect(bottomGap).toBeGreaterThanOrEqual(0) + expect(bottomGap).toBeLessThanOrEqual(16) + const lazyDiff = page.waitForRequest((request) => { + const url = new URL(request.url()) + return ( + url.pathname === "/vcs/diff" && + url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true + ) + }) + await lastFile.click() + await lazyDiff + const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]') + await expect(preview).toContainText("after-1") + detailVersion = 2 + events.push(statusEvent("busy")) + await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() + const refreshedDiff = page.waitForRequest((request) => { + const url = new URL(request.url()) + return ( + url.pathname === "/vcs/diff" && + url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true + ) + }) + events.push(statusEvent("idle")) + await refreshedDiff + await expect(preview).toContainText("after-2") + await selectMode(page, "Branch changes", "Git changes") + await expectTree(page, 8, "git-0.ts") + await page.getByRole("button", { name: "git-0.ts" }).click() + await selectMode(page, "Git changes", "Branch changes") + await expectTree(page, 2_773, "action.yml") + + const filter = page.getByRole("searchbox", { name: "Filter files" }) + await filter.fill("generated-2738") + await expectTree(page, 1, "generated-2738.ts") + await filter.fill("") + await expectTree(page, 2_773, "action.yml") + + await page.getByRole("button", { name: "Toggle file tree" }).click() + await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0) + await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0) + await page.getByRole("button", { name: "Toggle file tree" }).click() + await expectTree(page, 2_773, "action.yml") + + await page.keyboard.press("Control+Backquote") + await expect(page.locator("#terminal-panel")).toHaveCount(0) + await expectTree(page, 2_773, "action.yml") + await page.keyboard.press("Control+Backquote") + await expect(page.locator("#terminal-panel")).toBeVisible() + await expectTree(page, 2_773, "action.yml") + + await page.getByRole("button", { name: "Toggle review" }).click() + await expect(page.locator("#review-panel")).toHaveCount(0) + await page.getByRole("button", { name: "Toggle review" }).click() + await expectTree(page, 2_773, "action.yml") + await page.setViewportSize({ width: 1_000, height: 700 }) + await expectTree(page, 2_773, "action.yml") + await expectStackGeometry(page) + await page.setViewportSize({ width: 1_000, height: 120 }) + await page.setViewportSize({ width: 1_400, height: 900 }) + await expectTree(page, 2_773, "action.yml") + await expectStackGeometry(page) +}) + +async function selectMode(page: Page, current: string, next: string) { + await page.getByRole("button", { name: current }).click() + const option = page.getByRole("option", { name: next }) + await expect(option).toBeVisible() + await option.click() +} + +async function expectTree(page: Page, total: number, file: string) { + await expectMountedTree(page, total) + await expect(page.getByRole("button", { name: file })).toBeVisible() +} + +async function expectMountedTree(page: Page, total: number) { + const tree = page.locator('#review-panel [data-component="file-tree-v2"]') + await expect(tree).toHaveAttribute("data-total-rows", String(total)) + await expect + .poll(() => tree.evaluate((element) => element.querySelectorAll('[data-slot="file-tree-v2-row"]').length)) + .toBeGreaterThan(0) + const state = await tree.evaluate((element) => ({ + root: element.getBoundingClientRect().height, + viewport: element.closest(".scroll-view__viewport")!.getBoundingClientRect().height, + rows: element.querySelectorAll('[data-slot="file-tree-v2-row"]').length, + })) + expect(state.viewport).toBeGreaterThan(0) + expect(state.root).toBeGreaterThan(0) + expect(state.rows).toBeGreaterThan(0) + expect(state.rows).toBeLessThanOrEqual(60) +} + +async function expectStackGeometry(page: Page) { + const geometry = await page.evaluate(() => { + const review = document.querySelector("#review-panel")! + const terminal = document.querySelector("#terminal-panel")! + const reviewParent = review.parentElement!.getBoundingClientRect() + const terminalParent = terminal.parentElement!.getBoundingClientRect() + return { + review: review.getBoundingClientRect().height, + reviewParent: reviewParent.height, + terminal: terminal.getBoundingClientRect().height, + terminalParent: terminalParent.height, + } + }) + expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1) + expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1) +} + +function base64Encode(value: string) { + return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") +} + +function statusEvent(type: "busy" | "idle") { + return { + directory, + payload: { type: "session.status", properties: { sessionID, status: { type } } }, + } +} + +function fileDiff(file: string, additions: number, loaded = true, version = 1) { + return { + file, + additions, + deletions: 0, + status: "modified", + patch: loaded + ? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after-${version}'\n` + : `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}`, + } +} diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index 036eaaef42..714d6ca96f 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -1,6 +1,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" +import { installSseTransport } from "../utils/sse-transport" import { expectSessionTitle } from "../utils/waits" const directory = "C:/OpenCode/RequestDocks" @@ -100,6 +101,67 @@ test("shows a pending permission dock", async ({ page }) => { expect(request.postDataJSON()).toEqual({ response: "once" }) }) +test("restores the draft caret before typing after a request dock closes", async ({ page }) => { + const transport = await installSseTransport(page, { + server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, + retry: 20, + }) + await mockServer(page, { questions: [] }) + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await transport.waitForConnection() + await expectSessionTitle(page, title) + + const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]') + const draft = "keep the caret at the end" + await editor.fill(draft) + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))) + for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft") + const cursor = draft.length - 4 + await expect + .poll(() => + editor.evaluate((element) => { + const selection = window.getSelection() + if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1 + const range = selection.getRangeAt(0).cloneRange() + range.selectNodeContents(element) + range.setEnd(selection.anchorNode!, selection.anchorOffset) + return range.toString().length + }), + ) + .toBe(cursor) + await transport.send({ + directory, + payload: { + type: "question.asked", + properties: { + id: "question-caret", + sessionID, + questions: [ + { + header: "Continue", + question: "Continue?", + options: [{ label: "Yes", description: "Continue the session" }], + }, + ], + tool: { messageID: "message-caret", callID: "call-caret" }, + }, + }, + }) + const question = page.locator('[data-component="dock-prompt"][data-kind="question"]') + await expect(question).toBeVisible() + await expect(editor).toHaveCount(0) + + await transport.send({ + directory, + payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } }, + }) + await expect(question).toHaveCount(0) + await expect(editor).toBeVisible() + await page.keyboard.press("x") + + await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`) +}) + async function mockServer( page: Page, requests: { diff --git a/packages/app/e2e/regression/session-timeline-history-root.spec.ts b/packages/app/e2e/regression/session-timeline-history-root.spec.ts new file mode 100644 index 0000000000..e5ef7998ea --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-history-root.spec.ts @@ -0,0 +1,242 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { + assistantMessage, + directory, + messageUpdated, + project, + session, + sessionID, + status, + textPart, + title, + userID, + userMessage, +} from "../performance/timeline-stability/fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { installSseTransport } from "../utils/sse-transport" +import { expectSessionTitle } from "../utils/waits" + +const initialPageSize = 20 +const historyPageSize = 200 +const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) => + assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { + id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, + parentID: userID, + created: 1700000001000 + index * 1_000, + completed: index < initialPageSize, + }), +) +const messages = [userMessage(), ...assistants] +const lastAssistant = assistants.at(-1)! +const lastPartID = assistants.at(-1)!.parts[0]!.id +const userPartID = `prt_${userID}_text` +const completed = { + ...lastAssistant.info, + time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 }, +} +const scenarios = [ + { name: "completion", info: completed, idleFirst: false, interrupted: false }, + { + name: "interruption", + info: { ...completed, error: { name: "MessageAbortedError", data: { message: "Stopped" } } }, + idleFirst: true, + interrupted: true, + }, +] as const + +test.use({ viewport: { width: 646, height: 1385 } }) + +for (const scenario of scenarios) { + 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 }[] = [] + const sequence: string[] = [] + const history = Promise.withResolvers() + const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { + server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, + retry: 20, + }) + await mockOpenCodeServer(page, { + directory, + project: project(), + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { + "claude-opus-4-6": { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + limit: { context: 200_000 }, + }, + }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "claude-opus-4-6" }, + }, + sessions: [session()], + sessionStatus: { [sessionID]: { type: "busy" } }, + beforeMessagesResponse: (request) => (request.before ? history.promise : Promise.resolve()), + onMessages: (request) => { + requests.push(request) + sequence.push(`messages:${request.phase}:${request.before ?? "latest"}`) + }, + onMessage: (request) => { + roots.push(request) + sequence.push(`message:${request.messageID}`) + }, + message: (requestedSessionID, messageID) => { + if (requestedSessionID !== sessionID) return + return messages.find((item) => item.info.id === messageID) + }, + pageMessages: (_, limit, before) => { + pages.push({ before, limit }) + const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length + const start = Math.max(0, end - limit) + return { + items: messages.slice(start, end), + cursor: start > 0 ? messages[start]!.info.id : undefined, + } + }, + }) + 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 (!virtual || state.visibleParts.length === 0 || state.visibleParts.some((partID) => !visible(partID))) + state.hidden = true + state.samples++ + } + if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0)) + } + requestAnimationFrame(() => setTimeout(sample, 0)) + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await transport.waitForConnection() + await expectSessionTitle(page, title) + await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible() + await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible() + await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2) + expect(requests.filter((request) => request.phase === "end")).toHaveLength(1) + expect(sequence.slice(0, 4)).toEqual([ + "messages:start:latest", + "messages:end:latest", + `message:${userID}`, + `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?: { arm(): void } + } + ).__historyRootProbe!.arm() + }) + await waitForProbeSamples(page, 0) + 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(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).toEqual([ + { before: undefined, limit: initialPageSize }, + { before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize }, + ]) + expect(roots).toEqual([{ sessionID, messageID: userID }]) + + const message = messageUpdated(scenario.info) + const idle = status("idle") + for (const event of scenario.idleFirst ? [idle, message] : [message, idle]) { + const beforeEvent = await probeSamples(page) + await transport.send(event) + if (event === idle) await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0) + if (event === message && scenario.interrupted) + await expect(page.getByText("Interrupted", { exact: true })).toBeVisible() + await waitForProbeSamples(page, beforeEvent) + const current = await timelineState(page) + expect(current, JSON.stringify(current)).toMatchObject({ virtual: true }) + expect(current.rows, JSON.stringify(current)).toBeGreaterThan(0) + } + + expect(requests[0]).toEqual({ before: undefined, phase: "start", sessionID }) + expect(requests[1]).toEqual({ before: undefined, phase: "end", sessionID }) + await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0) + await expect(page.locator('[data-timeline-row="bottom-spacer"]')).toBeVisible() + if (scenario.interrupted) await expect(page.getByText("Interrupted", { exact: true })).toBeVisible() + expect( + await page.evaluate(() => { + const state = (window as Window & { __historyRootProbe?: { hidden: boolean; stop: boolean } }) + .__historyRootProbe! + state.stop = true + return state.hidden + }), + ).toBe(false) + }) +} + +function timelineState(page: Page) { + return page.evaluate(() => ({ + virtual: !!document.querySelector("[data-timeline-virtual-content]"), + rows: document.querySelectorAll("[data-timeline-key]").length, + })) +} + +function probeSamples(page: Page) { + return page.evaluate( + () => (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples, + ) +} + +async function waitForProbeSamples(page: Page, after: number) { + await page.waitForFunction( + (after) => + (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples >= after + 3, + after, + ) +} + +function visibleContentHidden(page: Page) { + return page.evaluate( + () => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden, + ) +} 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-shell-outline.spec.ts b/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts new file mode 100644 index 0000000000..54139cc371 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts @@ -0,0 +1,228 @@ +import { expect, test, type Locator, type Page } from "@playwright/test" +import { + assistantMessage, + setupTimeline, + shell, + textPart, + toolPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +for (const deviceScaleFactor of [1.25, 1.5]) { + test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => { + const shellID = "prt_shell_outline" + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])], + settings: { newLayoutDesigns: true, shellToolPartsExpanded: true }, + reducedMotion: true, + deviceScaleFactor, + }) + const part = page.locator(`[data-timeline-part-id="${shellID}"]`) + const output = part.locator('[data-component="bash-output"]') + const row = page.locator("[data-timeline-key]", { has: part }) + await expect(output).toBeVisible() + await timeline.settle() + + const geometry = await row.evaluate((element) => { + const output = element.querySelector('[data-component="bash-output"]') + if (!output) throw new Error("Shell output is unavailable") + const rowRect = element.getBoundingClientRect() + const outputRect = output.getBoundingClientRect() + // Match a rounded-down measurement at a fractional device-pixel phase. + element.style.height = `${outputRect.bottom - rowRect.top - 0.49}px` + element.style.transform = "translateY(0.25px)" + output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)") + output.style.setProperty("background", "rgb(0, 0, 0)", "important") + const style = getComputedStyle(output) + return { + outputWidth: outputRect.width, + outputHeight: outputRect.height, + borderColor: style.borderTopColor, + boxShadow: style.boxShadow, + clipMargin: getComputedStyle(element).overflowClipMargin, + } + }) + await timeline.settle() + + const clipped = await row.evaluate((element) => { + const output = element.querySelector('[data-component="bash-output"]')! + return output.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom + }) + expect(clipped).toBeCloseTo(0.49, 1) + + expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor) + const edges = await captureCardEdges(page, output) + + expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2) + expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2) + expect(geometry.borderColor).toBe("rgb(255, 0, 255)") + expect(geometry.boxShadow).toBe("none") + expect(geometry.clipMargin).toBe("0.5px") + expect(edges.magenta.top).toBeGreaterThan(0.75) + expect(edges.magenta.bottom).toBeGreaterThan(0.75) + expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2) + }) +} + +test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => { + const patchID = "prt_patch_outline" + const file = { + filePath: "src/outline.ts", + relativePath: "src/outline.ts", + type: "update", + additions: 1, + deletions: 1, + before: "const outline = false\n", + after: "const outline = true\n", + } + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }), + ]), + ], + settings: { editToolPartsExpanded: true, newLayoutDesigns: true }, + reducedMotion: true, + }) + const part = page.locator(`[data-timeline-part-id="${patchID}"]`) + const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]') + const row = page.locator("[data-timeline-key]", { has: part }) + await expect(card).toBeVisible() + await timeline.settle() + + const geometry = await row.evaluate((element) => { + const card = element.querySelector('[data-component="accordion"][data-scope="apply-patch"]') + if (!card) throw new Error("Patch card is unavailable") + const rowRect = element.getBoundingClientRect() + const cardRect = card.getBoundingClientRect() + element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px` + const clipMargin = getComputedStyle(element).overflowClipMargin + const bottom = element.getBoundingClientRect().bottom + return { + overflow: card.getBoundingClientRect().bottom - bottom, + paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin), + clipMargin, + cardWidth: cardRect.width, + cardHeight: cardRect.height, + } + }) + await timeline.settle() + + expect(geometry.overflow).toBeCloseTo(0.49, 1) + expect(geometry.paintOverflow).toBeLessThanOrEqual(0) + const edges = await captureCardEdges(page, card) + expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2) + expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2) + expect(edges.luminance.top).toBeLessThan(245) + expect(edges.luminance.bottom).toBeLessThan(245) + expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10) + expect(geometry.clipMargin).toBe("0.5px") +}) + +test("allows paint rounding for every framed row but not fixed turn gaps", async ({ page }) => { + const secondUserID = "msg_outline_second_user" + await setupTimeline(page, { + messages: [ + userMessage(undefined, { + summary: { + diffs: [ + { + file: "src/summary.ts", + additions: 1, + deletions: 1, + patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2", + }, + ], + }, + }), + assistantMessage([textPart("prt_outline_text", "Assistant text")]), + userMessage(undefined, { id: secondUserID, created: 1700000010000 }), + assistantMessage([], { + id: "msg_outline_second_assistant", + parentID: secondUserID, + created: 1700000011000, + }), + ], + }) + await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible() + await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible() + + const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) => + elements.map((element) => ({ + tag: element.querySelector("[data-timeline-row]")?.dataset.timelineRow, + clipMargin: getComputedStyle(element).overflowClipMargin, + })), + ) + expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true) + expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }]) +}) + +async function captureCardEdges(page: Page, card: Locator) { + const box = await card.boundingBox() + if (!box) throw new Error("Tool card bounds are unavailable") + const viewport = page.viewportSize() + if (!viewport) throw new Error("Viewport bounds are unavailable") + const screenshot = await page.screenshot() + return page.evaluate( + async ({ source, box, viewport }) => { + const image = new Image() + image.src = source + await image.decode() + const canvas = document.createElement("canvas") + canvas.width = image.naturalWidth + canvas.height = image.naturalHeight + const context = canvas.getContext("2d") + if (!context) throw new Error("2D canvas is unavailable") + context.drawImage(image, 0, 0) + const scale = { + x: image.naturalWidth / viewport.width, + y: image.naturalHeight / viewport.height, + } + const rows = (candidates: number[]) => { + const left = Math.floor((box.x + 8) * scale.x) + const width = Math.floor((box.width - 16) * scale.x) + return candidates.map((row) => { + const pixels = context.getImageData(left, row, width, 1).data + const indexes = Array.from({ length: width }, (_, index) => index * 4) + return { + luminance: + indexes + .map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3) + .reduce((sum, value) => sum + value, 0) / width, + magenta: + indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200) + .length / width, + } + }) + } + const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data + const columns = new Uint32Array(image.naturalWidth) + for (let index = 0; index < pixels.length; index += 4) { + if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue + columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1 + } + const top = box.y * scale.y + const bottom = (box.y + box.height) * scale.y + const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)]) + const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1]) + return { + box, + luminance: { + top: Math.min(...topRows.map((row) => row.luminance)), + bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance, + }, + magenta: { + top: Math.max(...topRows.map((row) => row.magenta)), + bottom: Math.max(...bottomRows.map((row) => row.magenta)), + vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length, + }, + } + }, + { + source: `data:image/png;base64,${screenshot.toString("base64")}`, + viewport, + box, + }, + ) +} diff --git a/packages/app/e2e/regression/terminal-composer-focus.spec.ts b/packages/app/e2e/regression/terminal-composer-focus.spec.ts index 2c2801d4b5..f672602782 100644 --- a/packages/app/e2e/regression/terminal-composer-focus.spec.ts +++ b/packages/app/e2e/regression/terminal-composer-focus.spec.ts @@ -1,5 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test } from "@playwright/test" +import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" @@ -7,10 +7,11 @@ const directory = "C:/OpenCode/TerminalComposerFocus" const projectID = "proj_terminal_composer_focus" const sessionID = "ses_terminal_composer_focus" const ptyID = "pty_terminal_composer_focus" +const newPtyID = "pty_terminal_composer_focus_new" test.use({ viewport: { width: 1440, height: 900 } }) -test("routes typing to the composer unless the open terminal is focused", async ({ page }) => { +test.beforeEach(async ({ page }) => { await mockOpenCodeServer(page, { directory, project: { @@ -67,7 +68,9 @@ test("routes typing to the composer unless the open terminal is focused", async await page.addInitScript(() => { localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) }) +}) +test("routes typing to the composer unless the open terminal is focused", async ({ page }) => { await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await expectSessionTitle(page, "Terminal composer focus") @@ -87,3 +90,120 @@ test("routes typing to the composer unless the open terminal is focused", async await expect(composer).toBeFocused() await expect(composer).toHaveText("a") }) + +test("keeps composer focus when a cached terminal finishes mounting", async ({ page }) => { + const ghostty = Promise.withResolvers() + const release = Promise.withResolvers() + const created = { count: 0 } + await page.route("**/pty", (route) => { + created.count += 1 + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ id: ptyID, title: "Terminal 1" }), + }) + }) + await page.route(/ghostty-web/, async (route) => { + ghostty.resolve() + await release.promise + await route.continue() + }) + await seedCachedTerminal(page) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" }) + await expectSessionTitle(page, "Terminal composer focus") + + const composer = page.locator('[data-component="prompt-input"]') + const terminal = page.locator('[data-component="terminal"]') + await expect(terminal).toBeVisible() + expect(created.count).toBe(0) + await ghostty.promise + await composer.click() + await expect(composer).toBeFocused() + + release.resolve() + await expect(terminal.locator("textarea")).toHaveCount(1) + await page.waitForTimeout(300) + await expect(composer).toBeFocused() +}) + +test("keeps newer composer focus while an explicit terminal open finishes", async ({ page }) => { + const ghostty = Promise.withResolvers() + const release = Promise.withResolvers() + await page.route(/ghostty-web/, async (route) => { + ghostty.resolve() + await release.promise + await route.continue() + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, "Terminal composer focus") + + const composer = page.locator('[data-component="prompt-input"]') + const terminal = page.locator('[data-component="terminal"]') + await page.keyboard.press("Control+Backquote") + await expect(terminal).toBeVisible() + await ghostty.promise + await composer.click() + await expect(composer).toBeFocused() + + release.resolve() + await expect(terminal.locator("textarea")).toHaveCount(1) + await page.waitForTimeout(50) + await expect(composer).toBeFocused() +}) + +test("focuses a terminal created from the new-terminal button", async ({ page }) => { + const created = { count: 0 } + await page.route("**/pty", (route) => { + created.count += 1 + const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(next), + }) + }) + await page.route(`**/pty/${newPtyID}`, (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), + ) + await page.route(`**/pty/${newPtyID}/connect-token*`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify({ ticket: "e2e-ticket" }), + }), + ) + await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, "Terminal composer focus") + + const composer = page.locator('[data-component="prompt-input"]') + const terminal = page.locator('[data-component="terminal"]') + await page.keyboard.press("Control+Backquote") + await expect(terminal.locator("textarea")).toHaveCount(1) + await composer.click() + await expect(composer).toBeFocused() + + await page.getByRole("button", { name: "New terminal" }).click() + await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true") + await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true) +}) + +function seedCachedTerminal(page: Page) { + return page.addInitScript( + ({ terminalKey, ptyID }) => { + localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } })) + localStorage.setItem( + terminalKey, + JSON.stringify({ + active: ptyID, + all: [{ id: ptyID, title: "Terminal 1", titleNumber: 1 }], + }), + ) + }, + { terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID }, + ) +} diff --git a/packages/app/e2e/regression/terminal-hidden.spec.ts b/packages/app/e2e/regression/terminal-hidden.spec.ts index 357d9ac203..73821580af 100644 --- a/packages/app/e2e/regression/terminal-hidden.spec.ts +++ b/packages/app/e2e/regression/terminal-hidden.spec.ts @@ -7,7 +7,7 @@ const projectID = "proj_hidden_terminal_regression" const sessionID = "ses_hidden_terminal_regression" const title = "Hidden terminal regression" -test("unmounts the terminal renderer while the pane is hidden", async ({ page }) => { +test("unmounts the terminal panel while it is hidden", async ({ page }) => { await page.setViewportSize({ width: 1400, height: 900 }) await mockOpenCodeServer(page, { directory, @@ -64,13 +64,14 @@ test("unmounts the terminal renderer while the pane is hidden", async ({ page }) await expect(page.locator('[data-component="terminal"]')).toBeVisible() await page.keyboard.press("Control+Backquote") - await expect(panel).toHaveAttribute("aria-hidden", "true") + await expect(panel).toHaveCount(0) await expect(page.locator('[data-component="terminal"]')).toHaveCount(0) await page.setViewportSize({ width: 1200, height: 700 }) await expect(page.locator('[data-component="terminal"]')).toHaveCount(0) await page.keyboard.press("Control+Backquote") + await expect(panel).toBeVisible() await expect(page.locator('[data-component="terminal"]')).toBeVisible() }) diff --git a/packages/app/e2e/reproduction/timeline-suspense/index.html b/packages/app/e2e/reproduction/timeline-suspense/index.html new file mode 100644 index 0000000000..995008a9f7 --- /dev/null +++ b/packages/app/e2e/reproduction/timeline-suspense/index.html @@ -0,0 +1,34 @@ + + + + + + Timeline Suspense Reproduction + + + +
+ + + diff --git a/packages/app/e2e/reproduction/timeline-suspense/main.tsx b/packages/app/e2e/reproduction/timeline-suspense/main.tsx new file mode 100644 index 0000000000..cdcd809be3 --- /dev/null +++ b/packages/app/e2e/reproduction/timeline-suspense/main.tsx @@ -0,0 +1,315 @@ +import { createResource, createSignal, For, onMount, Suspense } from "solid-js" +import { render } from "solid-js/web" +import { createVirtualizer, observeElementOffset, observeElementRect } from "@tanstack/solid-virtual" +import { observeElementOffsetReconnectAware } from "../../../src/pages/session/timeline/observe-element-offset" + +const rowCount = 2_000 +const rowHeight = 40 +const parameters = new URLSearchParams(location.search) +const resourceMode = parameters.get("resource") === "guard" ? "guard" : "baseline" +const reconnectMode = parameters.get("reconnect") === "candidate" ? "candidate" : "baseline" + +type MutationEvent = { + kind: "removed" | "added" + callbackTime: number + callbackFrame: number + routeConnectedInCallback: boolean + nativeOffsetInCallback: number +} + +type Snapshot = { + mode: { + resource: "baseline" | "guard" + reconnect: "baseline" | "candidate" + } + operation: { + sequence: number + phase: string + time: number + frame: number + } + resourceState: string + routeConnected: boolean + viewportConnected: boolean + viewportOwnedByRoute: boolean + sameRoute: boolean + sameViewport: boolean + sameSurface: boolean + sameMountedRows: boolean + nativeOffset: number + coreOffset: number + rangeStart: number + rangeEnd: number + indexes: number[] + domIndexes: number[] + logicalSurfaceHeight: number + renderedSurfaceHeight: number + viewportClientHeight: number + viewportScrollHeight: number + visibleRows: number + minimumRowTop: number + domScrollEvents: number + lastScrollTrusted: boolean + coreOffsetCallbackCalls: number + offsetCallbackSources: "observer"[] + rectObserverCallbacks: number + ignoredDetachedZeroRects: number + syntheticScrollDispatches: number + mutationEvents: MutationEvent[] +} + +declare global { + interface Window { + timelineSuspense: { + prepare: () => Promise + trigger: () => void + resolve: () => void + frames: (count?: number) => Promise + snapshot: () => Snapshot + } + } +} + +function App() { + const [refresh, setRefresh] = createSignal(false) + let resolveResource: (() => void) | undefined + const [resource] = createResource( + refresh, + (version) => + new Promise((resolve) => { + resolveResource = () => resolve(`settled-${version}`) + }), + { initialValue: "settled" }, + ) + + function Route() { + let route: HTMLElement | undefined + let viewport: HTMLDivElement | undefined + let surface: HTMLDivElement | undefined + let initialRoute: HTMLElement | undefined + let initialViewport: HTMLDivElement | undefined + let initialSurface: HTMLDivElement | undefined + let initialRows: HTMLElement[] = [] + let phase = "mounting" + let browserFrame = 0 + let snapshotSequence = 0 + let domScrollEvents = 0 + let lastScrollTrusted = false + let coreOffsetCallbackCalls = 0 + let rectObserverCallbacks = 0 + let ignoredDetachedZeroRects = 0 + const offsetCallbackSources: "observer"[] = [] + const mutationEvents: MutationEvent[] = [] + const virtualizer = createVirtualizer({ + count: rowCount, + getScrollElement: () => viewport ?? null, + estimateSize: () => rowHeight, + initialRect: { width: 900, height: 600 }, + overscan: 2, + observeElementRect: (instance, callback) => + observeElementRect(instance, (rect) => { + rectObserverCallbacks++ + // A fixed 600px viewport has no usable geometry while detached. Keep the last connected rect. + if (!instance.scrollElement?.isConnected && rect.height === 0) { + ignoredDetachedZeroRects++ + return + } + callback(rect) + }), + observeElementOffset: (instance, callback) => { + const deliver = (offset: number, isScrolling: boolean) => { + coreOffsetCallbackCalls++ + offsetCallbackSources.push("observer") + callback(offset, isScrolling) + } + if (reconnectMode === "candidate") return observeElementOffsetReconnectAware(instance, deliver) + return observeElementOffset(instance, deliver) + }, + }) + + const frames = async (count = 2) => { + for (let index = 0; index < count; index++) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + } + } + const mountedRows = () => [...(surface?.querySelectorAll("[data-row-index]") ?? [])] + const snapshot = (): Snapshot => { + const rows = mountedRows() + const view = viewport?.getBoundingClientRect() + const visibleRows = + viewport?.isConnected && view + ? rows.filter((row) => { + const rect = row.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }).length + : 0 + return { + mode: { resource: resourceMode, reconnect: reconnectMode }, + operation: { + sequence: ++snapshotSequence, + phase, + time: performance.now(), + frame: browserFrame, + }, + resourceState: resource.state, + routeConnected: route?.isConnected ?? false, + viewportConnected: viewport?.isConnected ?? false, + viewportOwnedByRoute: !!route && !!viewport && route.contains(viewport), + sameRoute: route === initialRoute, + sameViewport: viewport === initialViewport, + sameSurface: surface === initialSurface, + sameMountedRows: + initialRows.length > 0 && + initialRows.length === rows.length && + initialRows.every((row, index) => row === rows[index]), + nativeOffset: viewport?.scrollTop ?? -1, + coreOffset: virtualizer.scrollOffset ?? -1, + rangeStart: virtualizer.range?.startIndex ?? -1, + rangeEnd: virtualizer.range?.endIndex ?? -1, + indexes: virtualizer.getVirtualItems().map((item) => item.index), + domIndexes: rows.map((row) => Number(row.dataset.rowIndex)), + logicalSurfaceHeight: Number.parseFloat(surface?.style.height ?? "-1"), + renderedSurfaceHeight: surface?.getBoundingClientRect().height ?? -1, + viewportClientHeight: viewport?.clientHeight ?? -1, + viewportScrollHeight: viewport?.scrollHeight ?? -1, + visibleRows, + minimumRowTop: + rows.length && view ? Math.min(...rows.map((row) => row.getBoundingClientRect().top - view.top)) : -1, + domScrollEvents, + lastScrollTrusted, + coreOffsetCallbackCalls, + offsetCallbackSources: [...offsetCallbackSources], + rectObserverCallbacks, + ignoredDetachedZeroRects, + syntheticScrollDispatches: 0, + mutationEvents: mutationEvents.map((event) => ({ ...event })), + } + } + + onMount(() => { + if (!route || !viewport || !surface) throw new Error("Timeline fixture did not mount") + const routeRoot = route.parentElement + if (!routeRoot) throw new Error("Timeline route root did not mount") + initialRoute = route + initialViewport = viewport + initialSurface = surface + viewport.addEventListener("scroll", (event) => { + domScrollEvents++ + lastScrollTrusted = event.isTrusted + }) + const countFrames = () => { + browserFrame++ + requestAnimationFrame(countFrames) + } + requestAnimationFrame(countFrames) + new MutationObserver((records) => { + const callbackTime = performance.now() + records.forEach((record) => { + ;([...(record.removedNodes ?? [])] as Node[]).forEach((node) => { + if (node !== route) return + phase = "detached" + mutationEvents.push({ + kind: "removed", + callbackTime, + callbackFrame: browserFrame, + routeConnectedInCallback: route.isConnected, + nativeOffsetInCallback: viewport.scrollTop, + }) + }) + ;([...(record.addedNodes ?? [])] as Node[]).forEach((node) => { + if (node !== route) return + phase = "reinserted" + mutationEvents.push({ + kind: "added", + callbackTime, + callbackFrame: browserFrame, + routeConnectedInCallback: route.isConnected, + nativeOffsetInCallback: viewport.scrollTop, + }) + }) + }) + }).observe(routeRoot, { childList: true }) + window.timelineSuspense = { + prepare: async () => { + phase = "preparing" + await frames(2) + viewport.scrollTop = viewport.scrollHeight + await frames(3) + await new Promise((resolve) => setTimeout(resolve, 200)) + await frames(2) + initialRows = mountedRows() + phase = "prepared" + return snapshot() + }, + trigger: () => { + phase = "triggering" + setRefresh(true) + }, + resolve: () => { + if (!resolveResource) throw new Error("Resource is not pending") + phase = "resolving" + resolveResource() + }, + frames, + snapshot, + } + }) + + return ( +
+ +
+
+ + {(item) => ( +
+ logical row {item.index} +
+ )} +
+
+
+
+ ) + } + + return ( +
+ + + +
+ ) +} + +render(() => , document.getElementById("root")!) diff --git a/packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts b/packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts new file mode 100644 index 0000000000..7fff1737ae --- /dev/null +++ b/packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from "@playwright/test" + +const port = Number(process.env.PLAYWRIGHT_TIMELINE_SUSPENSE_PORT ?? 4317) + +export default defineConfig({ + testDir: ".", + testMatch: "timeline-suspense.repro.ts", + outputDir: "../../test-results/timeline-suspense", + fullyParallel: false, + workers: 1, + retries: 0, + reporter: "line", + timeout: 30_000, + expect: { + timeout: 10_000, + }, + webServer: { + command: `bunx vite --config vite.config.ts --host 127.0.0.1 --port ${port} --strictPort`, + cwd: import.meta.dirname, + url: `http://127.0.0.1:${port}`, + reuseExistingServer: false, + }, + use: { + baseURL: `http://127.0.0.1:${port}`, + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}) diff --git a/packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts b/packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts new file mode 100644 index 0000000000..3edae33a9b --- /dev/null +++ b/packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts @@ -0,0 +1,179 @@ +import { expect, test, type Page } from "@playwright/test" + +test.beforeEach(async ({ page }) => { + page.on("pageerror", (error) => console.error(error)) + await page.goto("/") + await expect.poll(() => page.evaluate(() => !!window.timelineSuspense)).toBe(true) +}) + +test("desired: preserves visible timeline continuity across descendant resource suspension", async ({ page }) => { + await page.goto("/?reconnect=candidate") + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.reconnect)).toBe("candidate") + const before = await prepare(page) + await triggerBaselineSuspension(page) + const pending = await page.evaluate(() => window.timelineSuspense.snapshot()) + expect(pending.nativeOffset).toBe(0) + expect(pending.coreOffset).toBe(before.coreOffset) + expect(pending.indexes).toEqual(before.indexes) + expect(pending.sameRoute).toBe(true) + expect(pending.sameViewport).toBe(true) + expect(pending.sameSurface).toBe(true) + expect(pending.sameMountedRows).toBe(true) + + await resolveSuspension(page) + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().coreOffset)).toBe(0) + await page.waitForTimeout(250) + await page.evaluate(() => window.timelineSuspense.frames(2)) + const after = await page.evaluate(() => window.timelineSuspense.snapshot()) + expect(after.sameRoute).toBe(true) + expect(after.sameViewport).toBe(true) + expect(after.sameSurface).toBe(true) + expect(after.nativeOffset).toBe(0) + expect(after.coreOffset).toBe(0) + expect(after.rangeStart).toBeLessThan(10) + expect(after.visibleRows, diagnostic({ before, pending, after })).toBeGreaterThan(0) + expect(after.domScrollEvents).toBe(before.domScrollEvents) + expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls + 1) + expect(after.offsetCallbackSources.at(-1)).toBe("observer") + expect(after.syntheticScrollDispatches).toBe(0) +}) + +test("forensic: proves detached same-node viewport leaves TanStack's bottom range blank until a real scroll", async ({ + page, +}) => { + const before = await prepare(page) + const beforeRows = before.domIndexes + expect(before.mode).toEqual({ resource: "baseline", reconnect: "baseline" }) + expect(before.logicalSurfaceHeight).toBe(80_000) + expect(before.renderedSurfaceHeight).toBe(80_000) + expect(before.viewportClientHeight).toBe(600) + expect(before.viewportScrollHeight).toBe(80_000) + expect(before.rangeStart).toBeGreaterThan(1_900) + expect(before.nativeOffset).toBe(before.coreOffset) + expect(before.visibleRows).toBeGreaterThan(0) + + await triggerBaselineSuspension(page) + const pending = await page.evaluate(() => window.timelineSuspense.snapshot()) + expect(pending.resourceState).toBe("refreshing") + expect(pending.routeConnected).toBe(false) + expect(pending.viewportConnected).toBe(false) + expect(pending.viewportOwnedByRoute).toBe(true) + expect(pending.nativeOffset).toBe(0) + expect(pending.coreOffset).toBe(before.coreOffset) + expect(pending.rangeStart).toBe(before.rangeStart) + expect(pending.rangeEnd).toBe(before.rangeEnd) + expect(pending.indexes).toEqual(before.indexes) + expect(pending.domIndexes).toEqual(beforeRows) + expect(pending.sameMountedRows).toBe(true) + expect(pending.domScrollEvents).toBe(before.domScrollEvents) + expect(pending.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls) + expect(pending.ignoredDetachedZeroRects).toBeGreaterThan(before.ignoredDetachedZeroRects) + expect(pending.mutationEvents).toHaveLength(1) + expect(pending.mutationEvents[0]).toMatchObject({ + kind: "removed", + routeConnectedInCallback: false, + nativeOffsetInCallback: 0, + }) + expect(pending.mutationEvents[0]!.callbackTime).toBeLessThanOrEqual(pending.operation.time) + expect(pending.mutationEvents[0]!.callbackFrame).toBeLessThanOrEqual(pending.operation.frame) + + const after = await resolveSuspension(page) + expect(after.resourceState).toBe("ready") + expect(after.routeConnected).toBe(true) + expect(after.viewportConnected).toBe(true) + expect(after.viewportOwnedByRoute).toBe(true) + expect(after.sameRoute).toBe(true) + expect(after.sameViewport).toBe(true) + expect(after.sameSurface).toBe(true) + expect(after.sameMountedRows).toBe(true) + expect(after.nativeOffset).toBe(0) + expect(after.coreOffset).toBe(before.coreOffset) + expect(after.rangeStart).toBe(before.rangeStart) + expect(after.rangeEnd).toBe(before.rangeEnd) + expect(after.indexes).toEqual(before.indexes) + expect(after.domIndexes).toEqual(beforeRows) + expect(after.domScrollEvents).toBe(before.domScrollEvents) + expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls) + expect(after.mutationEvents).toHaveLength(2) + expect(after.mutationEvents[1]).toMatchObject({ + kind: "added", + routeConnectedInCallback: true, + nativeOffsetInCallback: 0, + }) + expect(after.mutationEvents[1]!.callbackTime).toBeLessThanOrEqual(after.operation.time) + expect(after.mutationEvents[1]!.callbackFrame).toBeLessThanOrEqual(after.operation.frame) + expect(after.visibleRows).toBe(0) + expect(after.minimumRowTop).toBeGreaterThan(50_000) + expect(after.syntheticScrollDispatches).toBe(0) + + await page.locator("[data-viewport]").hover() + await page.mouse.wheel(0, 80) + await expect + .poll(() => + page.evaluate(() => { + const value = window.timelineSuspense.snapshot() + return value.nativeOffset > 0 && value.coreOffset === value.nativeOffset + }), + ) + .toBe(true) + await page.evaluate(() => window.timelineSuspense.frames(2)) + const recovered = await page.evaluate(() => window.timelineSuspense.snapshot()) + expect(recovered.domScrollEvents).toBeGreaterThan(after.domScrollEvents) + expect(recovered.coreOffsetCallbackCalls).toBeGreaterThan(after.coreOffsetCallbackCalls) + expect(recovered.offsetCallbackSources.at(-1)).toBe("observer") + expect(recovered.lastScrollTrusted).toBe(true) + expect(recovered.rangeStart).toBeLessThan(10) + expect(recovered.visibleRows).toBeGreaterThan(0) +}) + +test("matrix: fixture-only settled-resource guard keeps the route connected", async ({ page }) => { + await page.goto("/?resource=guard") + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.resource)).toBe("guard") + const before = await prepare(page) + + await page.evaluate(() => window.timelineSuspense.trigger()) + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing") + await page.evaluate(() => window.timelineSuspense.frames(3)) + const pending = await page.evaluate(() => window.timelineSuspense.snapshot()) + expect(pending.routeConnected).toBe(true) + expect(pending.mutationEvents).toEqual([]) + expect(pending.nativeOffset).toBe(before.nativeOffset) + expect(pending.coreOffset).toBe(before.coreOffset) + expect(pending.visibleRows).toBeGreaterThan(0) + + const after = await resolveSuspension(page) + expect(after.routeConnected).toBe(true) + expect(after.nativeOffset).toBe(before.nativeOffset) + expect(after.coreOffset).toBe(before.coreOffset) + expect(after.visibleRows).toBeGreaterThan(0) +}) + +async function prepare(page: Page) { + const before = await page.evaluate(() => window.timelineSuspense.prepare()) + expect(before.routeConnected).toBe(true) + expect(before.viewportConnected).toBe(true) + expect(before.viewportOwnedByRoute).toBe(true) + expect(before.sameMountedRows).toBe(true) + expect(before.rangeStart).toBeGreaterThan(1_900) + expect(before.nativeOffset).toBe(before.coreOffset) + return before +} + +async function triggerBaselineSuspension(page: Page) { + await page.evaluate(() => window.timelineSuspense.trigger()) + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing") + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(false) + await page.evaluate(() => window.timelineSuspense.frames(3)) +} + +async function resolveSuspension(page: Page) { + await page.evaluate(() => window.timelineSuspense.resolve()) + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("ready") + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(true) + await page.evaluate(() => window.timelineSuspense.frames(3)) + return page.evaluate(() => window.timelineSuspense.snapshot()) +} + +function diagnostic(value: unknown) { + return JSON.stringify(value, null, 2) +} diff --git a/packages/app/e2e/reproduction/timeline-suspense/vite.config.ts b/packages/app/e2e/reproduction/timeline-suspense/vite.config.ts new file mode 100644 index 0000000000..efb75ab731 --- /dev/null +++ b/packages/app/e2e/reproduction/timeline-suspense/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite" +import solid from "vite-plugin-solid" + +export default defineConfig({ + root: import.meta.dirname, + plugins: [solid()], +}) diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index a73cc0ccdd..bdf3f55bdc 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -736,5 +736,5 @@ async function switchTitlebarSession(page: Page, sessionID: string, title: strin } async function expectSessionReady(page: Page) { - await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i })) + await expectAppVisible(page.getByRole("textbox", { name: "Prompt" })) } diff --git a/packages/app/e2e/tsconfig.json b/packages/app/e2e/tsconfig.json index 53aacbda02..4a6046e4fa 100644 --- a/packages/app/e2e/tsconfig.json +++ b/packages/app/e2e/tsconfig.json @@ -10,6 +10,9 @@ "./performance/timeline-stability/fixture.test.ts", "./performance/timeline-stability/fixture.ts", "./performance/unit/visual-stability.test.ts", + "./reproduction/timeline-suspense/**/*.ts", + "./reproduction/timeline-suspense/**/*.tsx", + "../src/pages/session/timeline/observe-element-offset.ts", "./regression/new-session-panel-corner.spec.ts", "./regression/session-timeline-context-resize.spec.ts", "./utils/**/*.ts" diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index dc0a8d0c41..2bfba5871a 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -1,7 +1,7 @@ import type { Page, Route } from "@playwright/test" const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"]) -const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp"]) +const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"]) export interface MockServerConfig { provider: unknown @@ -11,7 +11,10 @@ export interface MockServerConfig { pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string } vcsDiff?: unknown[] messageDelay?: number + beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void + message?: (sessionID: string, messageID: string) => unknown + onMessage?: (input: { sessionID: string; messageID: string }) => void events?: () => unknown[] eventRetry?: number todos?: (sessionID: string) => unknown[] @@ -19,6 +22,7 @@ export interface MockServerConfig { questions?: unknown[] | (() => unknown[]) fileList?: (path: string) => unknown | Promise fileContent?: (path: string) => unknown | Promise + findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown sessionStatus?: unknown } @@ -52,6 +56,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const path = url.pathname if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry) if (path === "/global/health") return json(route, { healthy: true }) + if (path === "/api/session") + return json(route, { + data: config.sessions.map((session) => v2Session(session, config.directory)), + cursor: {}, + }) + if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false }) if (path === "/permission") return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) if (path === "/question") @@ -62,6 +72,23 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { return json(route, await config.fileList(url.searchParams.get("path") ?? "")) if (path === "/file/content" && config.fileContent) return json(route, await config.fileContent(url.searchParams.get("path") ?? "")) + if (path === "/find/file" && config.findFiles) + return json( + route, + await config.findFiles({ + query: url.searchParams.get("query") ?? "", + dirs: url.searchParams.get("dirs") ?? undefined, + limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined, + }), + ) + if (path === "/api/reference") + return json(route, { + location: { + directory: config.directory, + project: { id: (config.project as { id?: string }).id, directory: config.directory }, + }, + data: [], + }) if (emptyObject.has(path)) return json(route, {}) if (emptyList.has(path)) return json(route, []) if (path in staticRoutes) return json(route, staticRoutes[path]) @@ -72,6 +99,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { return json(route, session ?? {}) } + const projectMatch = path.match(/^\/project\/([^/]+)$/) + if (projectMatch) return json(route, config.project) + + const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/) + if (messageMatch) { + config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! }) + if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) + const message = config.message?.(messageMatch[1]!, messageMatch[2]!) + if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404) + return json(route, message) + } + const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/) if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? []) if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) @@ -82,7 +121,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const before = token ? cursors.get(token) : undefined if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400) config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" }) - if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) + await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before }) + if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) const limit = Number(url.searchParams.get("limit") ?? 80) const pageData = config.pageMessages(messagesMatch[1], limit, before) config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" }) @@ -97,6 +137,30 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }) } +function v2Session(session: { id: string } & Record, fallbackDirectory: string) { + const time = session.time && typeof session.time === "object" ? session.time : {} + return { + id: session.id, + parentID: session.parentID, + projectID: session.projectID ?? "project", + cost: session.cost ?? 0, + tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { + created: "created" in time && typeof time.created === "number" ? time.created : 0, + updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0, + ...(session.time && typeof session.time === "object" && "archived" in session.time + ? { archived: session.time.archived } + : {}), + }, + title: session.title ?? session.id, + location: { + directory: typeof session.directory === "string" ? session.directory : fallbackDirectory, + ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}), + }, + ...(typeof session.path === "string" ? { subpath: session.path } : {}), + } +} + function json(route: Route, body: unknown, headers?: Record, status = 200) { return route.fulfill({ status, diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index 186962998d..55420485f3 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -162,7 +162,7 @@ export async function installSseTransport( const request = new Request(input, init) const url = new URL(request.url) if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event")) - return originalFetch(input, init) + return originalFetch(request) const id = ++nextConnectionID const record = { diff --git a/packages/app/package.json b/packages/app/package.json index 766f00aeb9..482fe7ff85 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.17.13", + "version": "1.18.3", "description": "", "type": "module", "exports": { @@ -47,6 +47,7 @@ "vite-plugin-solid": "catalog:" }, "dependencies": { + "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", "@dnd-kit/dom": "0.5.0", "@dnd-kit/helpers": "0.5.0", @@ -88,6 +89,7 @@ "shiki": "catalog:", "solid-js": "catalog:", "solid-list": "catalog:", + "solid-presence": "0.2.0", "tailwindcss": "catalog:" } } diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index c7cdc73f9b..b4496ba7de 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -9,9 +9,19 @@ 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, 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" import { type Component, createEffect, @@ -28,11 +38,12 @@ 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" import { ServerSDKProvider } from "@/context/server-sdk" -import { ServerSyncProvider } from "@/context/server-sync" +import { ServerSyncProvider, useServerSync } from "@/context/server-sync" import { GlobalProvider, useGlobal } from "@/context/global" import { HighlightsProvider } from "@/context/highlights" import { LanguageProvider, type Locale, useLanguage } from "@/context/language" @@ -52,9 +63,10 @@ import LegacyLayout from "@/pages/layout" import NewLayout from "@/pages/layout-new" import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" -import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route" +import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route" +import { createSessionLineage } from "@/pages/session/session-lineage" -import { SessionPage, TargetSessionRouteContent } from "@/pages/session" +import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" import { NewHome, LegacyHome } from "@/pages/home" const NewSession = lazy(() => import("@/pages/new-session")) @@ -88,10 +100,14 @@ const SessionRoute = () => { tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt) }) - return + return ( + + + + ) } -const TargetSessionRoute = () => { +function TargetServerRoute(props: ParentProps) { const params = useParams<{ serverKey: string; id: string }>() const global = useGlobal() const conn = createMemo(() => { @@ -105,17 +121,49 @@ const TargetSessionRoute = () => { // re-resolves reactively instead); both rely on this key for server changes. - - - + {props.children} ) } +const TargetSessionRoute = () => ( + + + +) + +function LegacyTargetSessionRoute() { + const params = useParams<{ serverKey: string; id: string }>() + return ( + + + + + + ) +} + +function LegacyTargetSessionRedirect() { + const params = useParams<{ id: string }>() + const navigate = useNavigate() + const sync = useServerSync() + const current = createSessionLineage( + () => params.id, + () => sync().session.lineage, + ) + + createEffect(() => { + const directory = current()?.session.directory + if (!directory) return + navigate(legacySessionHref(directory, params.id), { replace: true }) + }) + + return null +} + // 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 ( @@ -126,16 +174,17 @@ function SelectedServerProviders(props: ParentProps) { ) } -function LegacyServerLayout(props: ParentProps) { +function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) { return ( - {props.children} + {props.children} ) } function DraftRoute() { const [search] = useSearchParams<{ draftId?: string }>() + const settings = useSettings() const tabs = useTabs() return ( @@ -144,7 +193,14 @@ function DraftRoute() { keyed fallback={} > - {(draft) => } + {(draft) => ( + } + > + + + )} ) @@ -160,7 +216,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) { - + @@ -168,7 +224,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) { - + @@ -186,7 +242,7 @@ declare global { deepLinks?: string[] } api?: { - setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise + setTitlebar?: (theme: { mode: "light" | "dark"; scheme?: "system" | "light" | "dark" }) => Promise exportDebugLogs?: () => Promise } } @@ -262,45 +318,36 @@ 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.children} - - + + {props.serverScoped} + {props.children} + ) } function LegacyServerScopedShell(props: ServerScopedShellProps) { return ( - + {props.children} ) } -function NewAppLayout(props: ParentProps) { +function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) { return ( - + {props.children} ) } -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) { @@ -318,8 +365,8 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { { - void window.api?.setTitlebar?.({ mode }) + onThemeApplied={(_, mode, scheme) => { + void window.api?.setTitlebar?.({ mode, scheme }) }} > @@ -347,7 +394,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { ) } -function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { +function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise }>) { const server = useServer() const checkServerHealth = useCheckServerHealth() @@ -376,34 +423,45 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { const checking = createMemo( () => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state), ) + const [startup] = createResource(async () => { + if (!props.startup) return true + await props.startup.catch((error) => { + console.error("[startup] startup gate failed", error) + }) + return true + }) + const startupChecking = createMemo( + () => startupHealthCheck.latest === true && ["unresolved", "pending"].includes(startup.state), + ) + const loading = createMemo(() => checking() || startupChecking()) return ( - + <> + + { + if (checkMode() === "background") void healthCheckActions.refetch() + }} + onServerSelected={(key) => { + setCheckMode("blocking") + server.setActive(key) + void healthCheckActions.refetch() + }} + /> + } + > + {props.children} + + + +
- } - > - { - if (checkMode() === "background") void healthCheckActions.refetch() - }} - onServerSelected={(key) => { - setCheckMode("blocking") - server.setActive(key) - void healthCheckActions.refetch() - }} - /> - } - > - {props.children} -
+ ) } @@ -470,6 +528,8 @@ export function AppInterface(props: { servers?: Array router?: Component disableHealthCheck?: boolean + startup?: Promise + serverScoped?: JSX.Element }) { // The visual new layout lives in the router root so it remains mounted across // route changes. Draft and session routes override only their server-bound data @@ -491,23 +551,25 @@ export function AppInterface(props: { > - + ( - - - - {routerProps.children} - - - + + + + + {routerProps.children} + + + + )} > - + @@ -517,13 +579,24 @@ export function AppInterface(props: { ) } -function Routes() { +function Routes(props: { serverScoped?: JSX.Element }) { const settings = useSettings() return ( <> - - {} + ( + {routeProps.children} + )} + > + + { + <> + + + + } + } /> @@ -531,15 +604,15 @@ function Routes() { - + + - ) } -function LegacyTargetSessionRoute() { +function NewLayoutLegacySessionRedirect() { const server = useServer() const tabs = useTabs() const params = useParams<{ id: string }>() 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 new file mode 100644 index 0000000000..fd46be1f46 Binary files /dev/null and b/packages/app/src/assets/help/introducing-tabs.mp4 differ diff --git a/packages/app/src/assets/help/placeholder.png b/packages/app/src/assets/help/placeholder.png new file mode 100644 index 0000000000..c651d06cc9 Binary files /dev/null and b/packages/app/src/assets/help/placeholder.png 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 new file mode 100644 index 0000000000..59d3cbd1da --- /dev/null +++ b/packages/app/src/components/command-palette.ts @@ -0,0 +1,279 @@ +import { getFilename } from "@opencode-ai/core/util/path" +import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createMemo, onCleanup } from "solid-js" +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, 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" + +export type CommandPaletteEntry = { + id: string + type: "command" | "file" | "session" + title: string + description?: string + keybind?: string + category: string + option?: CommandOption + path?: string + directory?: string + sessionID?: string + server?: ServerConnection.Key + project?: LocalProject + archived?: number + updated?: number +} + +const ENTRY_LIMIT = 5 +const COMMON_COMMAND_IDS = [ + "session.new", + "workspace.new", + "session.previous", + "session.next", + "terminal.toggle", + "review.toggle", +] as const + +export function uniqueCommandPaletteEntries(items: CommandPaletteEntry[]) { + const seen = new Set() + return items.filter((item) => { + if (seen.has(item.id)) return false + seen.add(item.id) + return true + }) +} + +export function createCommandPaletteFileEntry(path: string, category: string): CommandPaletteEntry { + return { + id: "file:" + path, + type: "file", + title: path, + category, + path, + } +} + +export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => void) { + const file = useFile() + const layout = useLayout() + const { tabs, view } = useSessionLayout() + + return (path: string) => { + const value = file.tab(path) + void tabs().open(value) + void file.load(path) + if (!view().reviewPanel.opened()) view().reviewPanel.open() + layout.fileTree.setTab("all") + onOpenFile?.(path) + tabs().setActive(value) + } +} + +export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) { + const command = useCommand() + const global = useGlobal() + const language = useLanguage() + const file = useFile() + const dialog = useDialog() + const serverSDK = useServerSDK()() + 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 commandPaletteOptions(command.options) + }) + const commandEntries = createMemo(() => { + const category = language.t("palette.group.commands") + return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category)) + }) + const preferredCommandEntries = createMemo(() => { + const all = allowedCommands() + const order = new Map(COMMON_COMMAND_IDS.map((id, index) => [id, index])) + const picked = all.filter((option) => order.has(option.id)) + const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT) + const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base + const category = language.t("palette.group.commands") + return sorted.map((option) => createCommandPaletteCommandEntry(option, category)) + }) + + const tabState = createSessionTabs({ + tabs: sessionTabs, + pathFromTab: file.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), + }) + const recentFileEntries = createMemo(() => { + const all = tabState.openedTabs() + const active = tabState.activeFileTab() + const order = active ? [active, ...all.filter((item) => item !== active)] : all + const seen = new Set() + const category = language.t("palette.group.files") + return order + .map((item) => file.pathFromTab(item)) + .filter((path): path is string => { + if (!path || seen.has(path)) return false + seen.add(path) + return true + }) + .slice(0, ENTRY_LIMIT) + .map((path) => createCommandPaletteFileEntry(path, category)) + }) + const rootFileEntries = createMemo(() => { + const category = language.t("palette.group.files") + return file.tree + .children("") + .filter((node) => node.type === "file") + .map((node) => node.path) + .sort((a, b) => a.localeCompare(b)) + .slice(0, ENTRY_LIMIT) + .map((path) => createCommandPaletteFileEntry(path, category)) + }) + + const sessions = createServerSessionEntries({ + server: ServerConnection.key(serverSDK.server), + opened: serverCtx.projects.list, + stored: () => serverCtx.sync.data.project, + load: (search, signal) => + serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }), + untitled: () => language.t("command.session.new"), + category: () => language.t("command.category.session"), + }) + + const highlight = (item: CommandPaletteEntry | undefined) => { + state.cleanup?.() + state.cleanup = undefined + if (item?.type !== "command") return + state.cleanup = item.option?.onHighlight?.() + } + + const select = (item: CommandPaletteEntry | undefined) => { + if (!item) return + state.committed = true + state.cleanup = undefined + dialog.close() + if (item.type === "command") { + item.option?.onSelect?.("palette") + return + } + if (item.type === "session") { + 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 + openFile(item.path) + } + + onCleanup(() => { + if (state.committed) return + state.cleanup?.() + }) + + return { + language, + file, + commandEntries, + preferredCommandEntries, + recentFileEntries, + rootFileEntries, + sessions, + highlight, + select, + close: () => dialog.close(), + } +} + +export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry { + return { + id: "command:" + option.id, + type: "command", + title: option.title, + description: option.description, + keybind: option.keybind, + category, + option, + } +} + +export function createServerSessionEntries(props: { + server: ServerConnection.Key + opened: () => LocalProject[] + stored: () => Project[] + load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }> + untitled: () => string + category: () => string +}) { + let abort: AbortController | undefined + + onCleanup(() => abort?.abort()) + + return async (text: string): Promise => { + const search = text.trim() + if (!search) { + abort?.abort() + return [] + } + abort?.abort() + const current = new AbortController() + abort = current + await new Promise((resolve) => { + const timer = setTimeout(resolve, 100) + current.signal.addEventListener( + "abort", + () => { + clearTimeout(timer) + resolve() + }, + { once: true }, + ) + }) + if (current.signal.aborted) return [] + const opened = props.opened() + const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : []))) + const stored = props.stored().map((project) => ({ ...project, expanded: false })) + const storedByID = new Map(stored.map((project) => [project.id, project] as const)) + return props + .load(search, current.signal) + .then((result) => + (result.data ?? []) + .filter((session) => !session.time.archived) + .map((session) => { + const project = + projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID) + return { + id: `session:${props.server}:${session.id}`, + type: "session" as const, + title: session.title || props.untitled(), + description: project ? displayName(project) : session.project?.name || getFilename(session.directory), + category: props.category(), + directory: session.directory, + sessionID: session.id, + server: props.server, + project, + updated: session.time.updated, + } + }), + ) + .catch(() => [] as CommandPaletteEntry[]) + } +} diff --git a/packages/app/src/components/debug-bar.tsx b/packages/app/src/components/debug-bar.tsx index e55e128b82..67c9a89929 100644 --- a/packages/app/src/components/debug-bar.tsx +++ b/packages/app/src/components/debug-bar.tsx @@ -5,6 +5,7 @@ import { makeEventListener } from "@solid-primitives/event-listener" import { Tooltip } from "@opencode-ai/ui/tooltip" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" type Mem = Performance & { memory?: { @@ -107,8 +108,45 @@ function Cell(props: { ) } +function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) { + const content = () => ( + + ) + + if (props.inline) { + return ( + + {content()} + + ) + } + + return ( + + {content()} + + ) +} + export function DebugBar(props: { inline?: boolean } = {}) { const language = useLanguage() + const platform = usePlatform() const location = useLocation() const routing = useIsRouting() const [state, setState] = createStore({ @@ -116,6 +154,7 @@ export function DebugBar(props: { inline?: boolean } = {}) { delay: undefined as number | undefined, fps: undefined as number | undefined, gap: undefined as number | undefined, + focus: false, heap: { limit: undefined as number | undefined, used: undefined as number | undefined, @@ -142,6 +181,16 @@ export function DebugBar(props: { inline?: boolean } = {}) { } const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`) const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na())) + const toggleFocus = async () => { + if (!platform.setForceFocus) return + const enabled = !state.focus + await platform.setForceFocus(enabled) + setState("focus", enabled) + } + + onCleanup(() => { + if (state.focus) void platform.setForceFocus?.(false).catch(() => undefined) + }) let prev = "" let start = 0 @@ -490,8 +539,11 @@ export function DebugBar(props: { inline?: boolean } = {}) { bad={bad(heap(), 0.8)} dim={state.heap.used === undefined} inline={props.inline} - wide + wide={!platform.setForceFocus} /> + {platform.setForceFocus && ( + void toggleFocus()} /> + )} ) diff --git a/packages/app/src/components/dialog-command-palette-v2.css b/packages/app/src/components/dialog-command-palette-v2.css new file mode 100644 index 0000000000..a4019928aa --- /dev/null +++ b/packages/app/src/components/dialog-command-palette-v2.css @@ -0,0 +1,220 @@ +.command-palette-v2 { + overflow: hidden; +} + +/* Anchor to the top edge of where a centered 480px-tall dialog would sit, so the + top stays put while the content-driven height grows and shrinks. */ +[data-component="dialog-v2"]:has(.command-palette-v2) { + align-items: flex-start; +} + +[data-component="dialog-v2"]:has(.command-palette-v2) [data-slot="dialog-container"] { + width: min(calc(100vw - 24px), 640px); + height: auto; + min-height: 280px; + max-height: min(calc(100vh - 96px), 480px); + margin-top: max(48px, calc((100vh - 480px) / 2)); + border-radius: 12px; + background: var(--v2-background-bg-base); + box-shadow: var(--v2-elevation-floating); +} + +.command-palette-v2-body { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 0; + padding: 0; +} + +.command-palette-v2-search { + flex-shrink: 0; + padding: 6px; +} + +.command-palette-v2-search [data-component="text-input-v2"] { + width: 100%; + height: 36px; + border-radius: 6px; + outline: 0; + background: color-mix(in srgb, var(--v2-background-bg-layer-02) 60%, transparent); + box-shadow: none; + transition: + background-color 120ms ease-in-out, + box-shadow 120ms ease-in-out; +} + +.command-palette-v2-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]), +.command-palette-v2-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) { + background: var(--v2-background-bg-layer-02); + box-shadow: none; +} + +.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] { + border-radius: 6px; + background: transparent; + gap: 8px; +} + +.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] { + padding-left: 12px; +} + +.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] { + border-radius: 6px; + background: transparent; +} + +.command-palette-v2-scroll { + min-height: 0; + flex: 1; +} + +.command-palette-v2-results { + display: flex; + flex-direction: column; + gap: 16px; + padding: 6px 6px 8px; +} + +.command-palette-v2-group { + display: flex; + flex-direction: column; + gap: 1px; +} + +.command-palette-v2-group-title { + margin: 6px 0; + padding: 0 12px; + color: var(--v2-text-text-muted); + font-size: 13px; + font-weight: 440; + line-height: 16px; + letter-spacing: -0.04px; + user-select: none; +} + +.command-palette-v2-row { + display: flex; + width: 100%; + height: 36px; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 12px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--v2-text-text-base); + text-align: left; + cursor: default; + scroll-margin: 6px 0; +} + +.command-palette-v2-row[data-active] { + background: var(--v2-overlay-simple-overlay-hover); +} + +.command-palette-v2-row:focus-visible { + background: var(--v2-overlay-simple-overlay-hover); + outline: none; +} + +.command-palette-v2-row-main { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + gap: 8px; +} + +.command-palette-v2-row-icon { + flex-shrink: 0; + color: var(--v2-icon-icon-muted); +} + +.command-palette-v2-row-text { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; +} + +.command-palette-v2-title { + min-width: 0; + overflow: hidden; + color: var(--v2-text-text-base); + font-size: 13px; + font-weight: 530; + line-height: 16px; + letter-spacing: -0.04px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.command-palette-v2-description, +.command-palette-v2-meta { + min-width: 0; + overflow: hidden; + color: var(--v2-text-text-muted); + font-size: 13px; + font-weight: 440; + line-height: 16px; + letter-spacing: -0.04px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.command-palette-v2-meta { + flex-shrink: 0; +} + +.command-palette-v2-file-path { + display: flex; + min-width: 0; + align-items: baseline; + font-size: 13px; + line-height: 16px; + letter-spacing: -0.04px; +} + +.command-palette-v2-file-dir { + min-width: 0; + overflow: hidden; + color: var(--v2-text-text-muted); + font-weight: 440; + text-overflow: ellipsis; + white-space: nowrap; +} + +.command-palette-v2-file-name { + flex-shrink: 0; + color: var(--v2-text-text-base); + font-weight: 530; + white-space: nowrap; +} + +.command-palette-v2-state { + display: grid; + min-height: 120px; + place-items: center; + color: var(--v2-text-text-muted); + font-size: 13px; + font-weight: 440; + line-height: 16px; + letter-spacing: -0.04px; +} + +@media (max-width: 640px) { + .command-palette-v2-row-text { + flex-direction: column; + align-items: flex-start; + gap: 1px; + } + + .command-palette-v2-description { + max-width: 100%; + } +} diff --git a/packages/app/src/components/dialog-command-palette-v2.tsx b/packages/app/src/components/dialog-command-palette-v2.tsx new file mode 100644 index 0000000000..85ca44ae69 --- /dev/null +++ b/packages/app/src/components/dialog-command-palette-v2.tsx @@ -0,0 +1,343 @@ +import { getDirectory, getFilename } from "@opencode-ai/core/util/path" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { ScrollView } from "@opencode-ai/ui/scroll-view" +import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js" +import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command" +import { useGlobal } from "@/context/global" +import { 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" +import "./dialog-command-palette-v2.css" + +function groups(entries: CommandPaletteEntry[]) { + const map = new Map() + for (const entry of entries) map.set(entry.category, [...(map.get(entry.category) ?? []), entry]) + return Array.from(map.entries()).map(([category, entries]) => ({ category, entries })) +} + +function matchesEntry(entry: CommandPaletteEntry, query: string) { + const value = query.toLowerCase() + return [entry.title, entry.description, entry.category].some((text) => text?.toLowerCase().includes(value)) +} + +export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) { + const palette = createCommandPaletteModel(props) + const loadItems = async (text: string) => { + const q = text.trim() + if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()] + + const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))]) + const category = palette.language.t("palette.group.files") + return [ + ...palette.commandEntries().filter((entry) => matchesEntry(entry, q)), + ...nextSessions, + ...files.map((path) => createCommandPaletteFileEntry(path, category)), + ] + } + + return ( + + ) +} + +export function DialogHomeCommandPaletteV2(props: { + server: ServerConnection.Any + onSelectSession: (entry: CommandPaletteEntry) => void +}) { + const command = useCommand() + const dialog = useDialog() + const global = useGlobal() + const language = useLanguage() + const serverCtx = global.ensureServerCtx(props.server) + const state = { cleanup: undefined as (() => void) | void, committed: false } + const commandEntries = createMemo(() => { + const category = language.t("palette.group.commands") + return commandPaletteOptions(command.options).map((option) => createCommandPaletteCommandEntry(option, category)) + }) + const sessions = createServerSessionEntries({ + server: ServerConnection.key(props.server), + opened: serverCtx.projects.list, + stored: () => serverCtx.sync.data.project, + load: (search, signal) => + serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }), + untitled: () => language.t("command.session.new"), + category: () => language.t("command.category.session"), + }) + + const highlight = (item: CommandPaletteEntry | undefined) => { + state.cleanup?.() + state.cleanup = undefined + if (item?.type !== "command") return + state.cleanup = item.option?.onHighlight?.() + } + const select = (item: CommandPaletteEntry | undefined) => { + if (!item) return + state.committed = true + state.cleanup = undefined + dialog.close() + if (item.type === "command") { + item.option?.onSelect?.("palette") + return + } + if (item.type === "session") props.onSelectSession(item) + } + const loadItems = async (text: string) => { + const query = text.trim() + if (!query) return commandEntries().slice(0, 5) + return [...commandEntries().filter((entry) => matchesEntry(entry, query)), ...(await sessions(query))] + } + + onCleanup(() => { + if (state.committed) return + state.cleanup?.() + }) + + return ( + dialog.close()} + /> + ) +} + +function CommandPaletteView(props: { + placeholder: string + loadItems: (text: string) => CommandPaletteEntry[] | Promise + highlight: (item: CommandPaletteEntry | undefined) => void + select: (item: CommandPaletteEntry | undefined) => void + close: () => void +}) { + const language = useLanguage() + const tabs = useTabs() + const [query, setQuery] = createSignal("") + const [active, setActive] = createSignal(0) + + const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] }) + // Render stale results while a new query loads to avoid flashing "Loading" per keystroke. + const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? [])) + const groupedEntries = createMemo(() => groups(visibleEntries())) + const activeEntry = createMemo(() => visibleEntries()[active()]) + const openSessions = createMemo( + () => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))), + ) + + createEffect(() => { + query() + visibleEntries() + setActive(0) + }) + + createEffect(() => { + props.highlight(activeEntry()) + }) + + let resultsRef: HTMLDivElement | undefined + + const move = (delta: -1 | 1) => { + const count = visibleEntries().length + if (count === 0) return + setActive((index) => (index + delta + count) % count) + requestAnimationFrame(() => { + resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" }) + }) + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowDown") { + event.preventDefault() + move(1) + return + } + if (event.key === "ArrowUp") { + event.preventDefault() + move(-1) + return + } + if (event.key === "Enter") { + event.preventDefault() + props.select(activeEntry()) + return + } + if (event.key === "Escape") { + event.preventDefault() + props.close() + } + } + + return ( + + + + (resultsRef = el)}> +
+ 0} + fallback={ +
+ {entries.loading ? language.t("common.loading") : language.t("palette.empty")} +
+ } + > + + {(group) => ( +
+ +
{group.category}
+
+ + {(item) => ( + setActive(visibleEntries().findIndex((entry) => entry.id === item.id))} + onSelect={() => props.select(item)} + /> + )} + +
+ )} +
+
+
+
+
+
+ ) +} + +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 ( + + ) +} diff --git a/packages/app/src/components/dialog-connect-provider.stories.tsx b/packages/app/src/components/dialog-connect-provider.stories.tsx new file mode 100644 index 0000000000..3aec6c9de3 --- /dev/null +++ b/packages/app/src/components/dialog-connect-provider.stories.tsx @@ -0,0 +1,73 @@ +// @ts-nocheck +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" +import { mockProviderAuth } from "@/context/server-sync" +import { onCleanup, onMount } from "solid-js" +import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider" + +function ConnectProviderDialogStory() { + const dialog = useDialog() + const open = () => dialog.show(() => ) + + onMount(open) + + 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 3a57769b5a..4c58857249 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -7,29 +7,399 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { List, type ListRef } from "@opencode-ai/ui/list" 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, createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" +import { + type Accessor, + type Component, + createEffect, + createMemo, + createResource, + createUniqueId, + For, + Match, + onCleanup, + onMount, + Show, + Switch, +} from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" -import { useProviders } from "@/hooks/use-providers" +import { useSettings } from "@/context/settings" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { CustomProviderForm } from "./dialog-custom-provider" -export function DialogConnectProvider(props: { provider: string; directory?: Accessor }) { +const CUSTOM_ID = "_custom" + +export function useProviderConnectController(options: { onBack?: () => void } = {}) { + const [store, setStore] = createStore({ selected: undefined as string | undefined }) + const reset = () => setStore("selected", undefined) + + return { + selected: () => store.selected, + select: (provider?: string) => setStore("selected", provider), + back: options.onBack ?? reset, + } +} + +export const DialogConnectProvider: Component<{ + directory?: Accessor + controller?: ReturnType +}> = (props) => { + const fallback = useProviderConnectController() + const controller = props.controller ?? fallback + const language = useLanguage() + const settings = useSettings() + const newLayout = settings.general.newLayoutDesigns + const reset = controller.back + const back = { current: reset } + let focusHost: HTMLDivElement | undefined + const holdFocus = () => focusHost?.focus({ preventScroll: true }) + const select = (provider?: string) => { + back.current = reset + controller.select(provider) + } + + function Content() { + return ( + + + + + + {(provider) => ( + (back.current = handler)} + /> + )} + + + + + + ) + } + + return ( + + back.current()} + aria-label={language.t("common.goBack")} + /> + + } + > + + + } + > + + + {language.t("command.provider.connect")}} + > + + + + +
+ +
+
+
+
+ ) +} + +function ProviderPicker(props: { + directory?: Accessor + onSelect: (provider: string) => void + onPrepare?: () => void +}) { + const settings = useSettings() + if (settings.general.newLayoutDesigns()) + return + const providers = useProviders(props.directory) + const language = useLanguage() + const popularGroup = () => language.t("dialog.provider.group.popular") + const otherGroup = () => language.t("dialog.provider.group.other") + const customLabel = () => language.t("settings.providers.tag.custom") + const note = (id: string) => { + if (id === "anthropic") return language.t("dialog.provider.anthropic.note") + if (id === "openai") return language.t("dialog.provider.openai.note") + if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note") + if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline") + return undefined + } + + return ( + x?.id} + items={() => { + language.locale() + return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()] + }} + filterKeys={["id", "name"]} + groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} + sortBy={(a, b) => { + if (a.id === CUSTOM_ID) return -1 + if (b.id === CUSTOM_ID) return 1 + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + sortGroupsBy={(a, b) => { + const popular = popularGroup() + if (a.category === popular && b.category !== popular) return -1 + if (b.category === popular && a.category !== popular) return 1 + return 0 + }} + onSelect={(x) => { + if (!x) return + props.onSelect(x.id) + }} + > + {(i) => ( +
+ + {i.name} + +
{language.t("dialog.provider.opencode.tagline")}
+
+ + {language.t("settings.providers.tag.custom")} + + + {language.t("dialog.provider.tag.recommended")} + + {(value) =>
{value()}
}
+ + {language.t("dialog.provider.tag.recommended")} + +
+ )} +
+ ) +} + +function ProviderPickerV2(props: { + directory?: Accessor + onSelect: (provider: string) => void + onPrepare?: () => void +}) { + const providers = useProviders(props.directory) + const language = useLanguage() + const serverSync = useServerSync() + const serverSDK = useServerSDK() + const [store, setStore] = createStore({ + filter: "", + active: undefined as string | undefined, + connecting: undefined as string | undefined, + }) + const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"] + const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") }) + const all = createMemo(() => { + language.locale() + const query = store.filter.trim().toLowerCase() + const values = [custom(), ...providers.all().values()] + if (!query) return values + return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query)) + }) + const popular = createMemo(() => + all() + .filter((provider) => featured.includes(provider.id)) + .sort((a, b) => featured.indexOf(a.id) - featured.indexOf(b.id)), + ) + const other = createMemo(() => + all() + .filter((provider) => !featured.includes(provider.id)) + .sort((a, b) => { + if (a.id === CUSTOM_ID) return -1 + if (b.id === CUSTOM_ID) return 1 + return a.name.localeCompare(b.name) + }), + ) + const rows = createMemo(() => [...popular(), ...other()]) + let picker: HTMLDivElement | undefined + let search: HTMLInputElement | undefined + + onMount(() => search?.focus({ preventScroll: true })) + + const connect = (provider: string) => { + props.onPrepare?.() + if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) { + props.onSelect(provider) + return + } + if (store.connecting) return + setStore("connecting", provider) + void serverSDK() + .client.provider.auth() + .then((response) => { + serverSync().set("provider_auth", response.data ?? {}) + props.onSelect(provider) + }) + .catch(() => props.onSelect(provider)) + } + + const move = (event: KeyboardEvent, direction: number) => { + const items = rows() + if (items.length === 0) return + const index = items.findIndex((provider) => provider.id === store.active) + const next = index < 0 ? (direction > 0 ? 0 : items.length - 1) : (index + direction + items.length) % items.length + setStore("active", items[next].id) + picker + ?.querySelector(`[data-provider-id="${CSS.escape(items[next].id)}"]`) + ?.focus({ preventScroll: true }) + event.preventDefault() + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowDown") return move(event, 1) + if (event.key === "ArrowUp") return move(event, -1) + if (event.key !== "Enter" || !store.active) return + connect(store.active) + event.preventDefault() + } + + return ( +
+
+ } + placeholder={language.t("dialog.provider.search.placeholder")} + value={store.filter} + onInput={(event) => { + setStore({ filter: event.currentTarget.value, active: undefined }) + }} + /> +
+
+
+ + {(group) => ( + 0}> +
+
+ {group.title} +
+ + {(provider) => ( + + )} + +
+
+ )} +
+ +
+ {language.t("dialog.provider.empty")} +
+
+
+
+
+
+ ) +} + +function ProviderConnection(props: { + provider: string + directory?: Accessor + onBack: () => void + setBack: (handler: () => void) => void +}) { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() const language = useLanguage() + const settings = useSettings() + const newLayout = settings.general.newLayoutDesigns const providers = useProviders(props.directory) - const all = () => { - void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) - }) - } - const alive = { value: true } const timer = { current: undefined as ReturnType | undefined } @@ -62,11 +432,19 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc ) const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider]) const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()) + const cachedMethods = serverSync().data.provider_auth[props.provider] + const directMethod = + cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined const [store, setStore] = createStore({ - methodIndex: undefined as undefined | number, + methodIndex: directMethod as undefined | number, authorization: undefined as undefined | ProviderAuthAuthorization, promptInputs: undefined as undefined | Record, - state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", + state: (directMethod === undefined ? "pending" : undefined) as + | undefined + | "pending" + | "complete" + | "error" + | "prompt", error: undefined as string | undefined, }) @@ -134,6 +512,16 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc return value.label ?? "" } + const methodDetails = (value?: { type?: string; label?: string }) => { + const label = methodLabel(value) + const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i) + const hint = suffix?.[1] + return { + label: suffix ? label.slice(0, -suffix[0].length) : label, + hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "api" ? "Browser" : undefined, + } + } + function formatError(value: unknown, fallback: string): string { if (value && typeof value === "object" && "data" in value) { const data = (value as { data?: { message?: unknown } }).data @@ -364,22 +752,47 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc } function goBack() { - if (methods().length === 1) { - all() - return - } - if (store.authorization) { + if (methods().length > 1 && store.methodIndex !== undefined) { dispatch({ type: "method.reset" }) return } - if (store.methodIndex !== undefined) { - dispatch({ type: "method.reset" }) - return - } - all() + props.onBack() } + props.setBack(goBack) + function MethodSelection() { + if (newLayout()) + return ( +
+
+ {language.t("provider.connect.selectMethod", { provider: provider().name })} +
+
+ + {(item, index) => { + const details = () => methodDetails(item) + return ( + + ) + }} + +
+
+ ) + return ( <>
@@ -413,11 +826,18 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc } 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() @@ -442,6 +862,58 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc 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 (
@@ -466,7 +938,8 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
{ + if (!newLayout()) return + codeInput?.focus({ preventScroll: true }) + }) + async function handleSubmit(e: SubmitEvent) { e.preventDefault() @@ -518,6 +998,46 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc 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 (
@@ -527,7 +1047,8 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
+
+ - } - > -
-
- -
- - - {language.t("provider.connect.title.anthropicProMax")} - - {language.t("provider.connect.title", { provider: provider().name })} - -
-
-
-
- - -
-
- - {language.t("provider.connect.status.inProgress")} -
-
-
- - - - -
-
- - {language.t("provider.connect.status.inProgress")} -
-
-
- - - - -
-
- - {language.t("provider.connect.status.failed", { error: store.error ?? "" })} -
-
-
- - - - - - - - - - - - - -
-
+
+ + + {language.t("provider.connect.title.anthropicProMax")} + + {language.t("provider.connect.title", { provider: provider().name })} +
- +
+
+ + +
+
+ + {language.t("provider.connect.status.inProgress")} +
+
+
+ + + + +
+
+ + {language.t("provider.connect.status.inProgress")} +
+
+
+ + + + +
+
+ + {language.t("provider.connect.status.failed", { error: store.error ?? "" })} +
+
+
+ + + + + + + + + + + + + +
+
+
+
) } diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 647e5002a2..9e04cd83ad 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -6,21 +6,41 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { useMutation } from "@tanstack/solid-query" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@/utils/toast" -import { type Accessor, batch, For } from "solid-js" +import { batch, For } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form" -import { DialogSelectProvider } from "./dialog-select-provider" type Props = { - back?: "providers" | "close" - directory?: Accessor + onBack: () => void } export function DialogCustomProvider(props: Props) { + const language = useLanguage() + + return ( + + } + transition + > + + + ) +} + +export function CustomProviderForm(props: { autofocus?: boolean } = {}) { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() @@ -36,14 +56,6 @@ export function DialogCustomProvider(props: Props) { err: {}, }) - const goBack = () => { - if (props.back === "close") { - dialog.close() - return - } - dialog.show(() => ) - } - const addModel = () => { setForm( "models", @@ -163,168 +175,155 @@ export function DialogCustomProvider(props: Props) { } return ( - - } - transition - > -
-
- -
{language.t("provider.custom.title")}
+
+
+ +
{language.t("provider.custom.title")}
+
+ + +

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

+ +
+ setField("providerID", v)} + validationState={form.err.providerID ? "invalid" : undefined} + error={form.err.providerID} + /> + setField("name", v)} + validationState={form.err.name ? "invalid" : undefined} + error={form.err.name} + /> + setField("baseURL", v)} + validationState={form.err.baseURL ? "invalid" : undefined} + error={form.err.baseURL} + /> + setField("apiKey", v)} + />
- -

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

- -
- setField("providerID", v)} - validationState={form.err.providerID ? "invalid" : undefined} - error={form.err.providerID} - /> - setField("name", v)} - validationState={form.err.name ? "invalid" : undefined} - error={form.err.name} - /> - setField("baseURL", v)} - validationState={form.err.baseURL ? "invalid" : undefined} - error={form.err.baseURL} - /> - setField("apiKey", v)} - /> -
- -
- - - {(m, i) => ( -
-
- setModel(i(), "id", v)} - validationState={m.err.id ? "invalid" : undefined} - error={m.err.id} - /> -
-
- setModel(i(), "name", v)} - validationState={m.err.name ? "invalid" : undefined} - error={m.err.name} - /> -
- removeModel(i())} - disabled={form.models.length <= 1} - aria-label={language.t("provider.custom.models.remove")} +
+ + + {(m, i) => ( +
+
+ setModel(i(), "id", v)} + validationState={m.err.id ? "invalid" : undefined} + error={m.err.id} />
- )} - - -
- -
- - - {(h, i) => ( -
-
- setHeader(i(), "key", v)} - validationState={h.err.key ? "invalid" : undefined} - error={h.err.key} - /> -
-
- setHeader(i(), "value", v)} - validationState={h.err.value ? "invalid" : undefined} - error={h.err.value} - /> -
- removeHeader(i())} - disabled={form.headers.length <= 1} - aria-label={language.t("provider.custom.headers.remove")} +
+ setModel(i(), "name", v)} + validationState={m.err.name ? "invalid" : undefined} + error={m.err.name} />
- )} - - -
- -
+ )} +
+ - -
-
+
+ +
+ + + {(h, i) => ( +
+
+ setHeader(i(), "key", v)} + validationState={h.err.key ? "invalid" : undefined} + error={h.err.key} + /> +
+
+ setHeader(i(), "value", v)} + validationState={h.err.value ? "invalid" : undefined} + error={h.err.value} + /> +
+ removeHeader(i())} + disabled={form.headers.length <= 1} + aria-label={language.t("provider.custom.headers.remove")} + /> +
+ )} +
+ +
+ + + +
) } diff --git a/packages/app/src/components/dialog-edit-project-v2.tsx b/packages/app/src/components/dialog-edit-project-v2.tsx new file mode 100644 index 0000000000..dc9714aa06 --- /dev/null +++ b/packages/app/src/components/dialog-edit-project-v2.tsx @@ -0,0 +1,156 @@ +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2" +import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" +import { Field } from "@opencode-ai/ui/v2/field-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2" +import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { For, Show } from "solid-js" +import { useLanguage } from "@/context/language" +import { getProjectAvatarVariant, type LocalProject } from "@/context/layout" +import { ServerConnection } from "@/context/server" +import { getProjectAvatarSource } from "@/pages/layout/helpers" +import { createEditProjectModel } from "./edit-project" + +export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) { + const language = useLanguage() + const model = createEditProjectModel(props) + + return ( + +
+ + {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-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx index 0238f543c4..d6c5e99186 100644 --- a/packages/app/src/components/dialog-manage-models.tsx +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -16,7 +16,7 @@ import { useLocal } from "@/context/local" import { popularProviders } from "@/hooks/use-providers" import { useLanguage } from "@/context/language" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { DialogSelectProvider } from "./dialog-select-provider" +import { DialogConnectProvider } from "./dialog-connect-provider" import { decode64 } from "@/utils/base64" import { SettingsListV2 } from "./settings-v2/parts/list" import { SettingsRowV2 } from "./settings-v2/parts/row" @@ -31,7 +31,7 @@ export const DialogManageModels: Component = () => { const directory = () => decode64(local.slug()) const handleConnectProvider = () => { - dialog.show(() => ) + void dialog.show(() => ) } const providerRank = (id: string) => popularProviders.indexOf(id) const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) @@ -123,7 +123,7 @@ export const DialogManageModelsV2: Component = () => { const directory = () => decode64(local.slug()) const handleConnectProvider = () => { - dialog.show(() => ) + void dialog.show(() => ) } const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) const providerVisible = (providerID: string) => diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 49a0d5961a..905abe1e2d 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -1,329 +1,81 @@ -import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" import { Keybind } from "@opencode-ai/ui/keybind" import { List } from "@opencode-ai/ui/list" -import { base64Encode } from "@opencode-ai/core/util/encode" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" -import { useNavigate } from "@solidjs/router" -import { createMemo, createSignal, lazy, Match, onCleanup, Show, Switch } from "solid-js" -import { formatKeybind, useCommand, type CommandOption } from "@/context/command" -import { useServerSDK, type ServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" -import { useLayout } from "@/context/layout" -import { useFile } from "@/context/file" +import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js" +import { formatKeybind } from "@/context/command" +import { useServerSDK } from "@/context/server-sdk" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" import { useSessionLayout } from "@/pages/session/session-layout" -import { createSessionTabs } from "@/pages/session/helpers" import { decode64 } from "@/utils/base64" import { getRelativeTime } from "@/utils/time" +import { + createCommandPaletteFileEntry, + createCommandPaletteFileOpener, + createCommandPaletteModel, + uniqueCommandPaletteEntries, + type CommandPaletteEntry, +} from "./command-palette" +import { DialogCommandPaletteV2 } from "./dialog-command-palette-v2" const DialogSelectFileV2 = lazy(() => import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })), ) - -type EntryType = "command" | "file" | "session" - -type Entry = { - id: string - type: EntryType - title: string - description?: string - keybind?: string - category: string - option?: CommandOption - path?: string - directory?: string - sessionID?: string - archived?: number - updated?: number -} - type DialogSelectFileMode = "all" | "files" -const ENTRY_LIMIT = 5 -const COMMON_COMMAND_IDS = [ - "session.new", - "workspace.new", - "session.previous", - "session.next", - "terminal.toggle", - "review.toggle", -] as const - -const uniqueEntries = (items: Entry[]) => { - const seen = new Set() - const out: Entry[] = [] - for (const item of items) { - if (seen.has(item.id)) continue - seen.add(item.id) - out.push(item) - } - return out -} - -const createCommandEntry = (option: CommandOption, category: string): Entry => ({ - id: "command:" + option.id, - type: "command", - title: option.title, - description: option.description, - keybind: option.keybind, - category, - option, -}) - -const createFileEntry = (path: string, category: string): Entry => ({ - id: "file:" + path, - type: "file", - title: path, - category, - path, -}) - -const createSessionEntry = ( - input: { - directory: string - id: string - title: string - description: string - archived?: number - updated?: number - }, - category: string, -): Entry => ({ - 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, -}) - -function createCommandEntries(props: { - filesOnly: () => boolean - command: ReturnType - language: ReturnType -}) { - const allowed = createMemo(() => { - if (props.filesOnly()) return [] - return props.command.options.filter( - (option) => - !option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open", - ) - }) - - const list = createMemo(() => { - const category = props.language.t("palette.group.commands") - return allowed().map((option) => createCommandEntry(option, category)) - }) - - const picks = createMemo(() => { - const all = allowed() - const order = new Map(COMMON_COMMAND_IDS.map((id, index) => [id, index])) - const picked = all.filter((option) => order.has(option.id)) - 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 = props.language.t("palette.group.commands") - return sorted.map((option) => createCommandEntry(option, category)) - }) - - return { allowed, list, picks } -} - -function createFileEntries(props: { - file: ReturnType - tabs: () => ReturnType["tabs"]> - language: ReturnType -}) { - const tabState = createSessionTabs({ - tabs: props.tabs, - pathFromTab: props.file.pathFromTab, - normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab), - }) - const recent = createMemo(() => { - const all = tabState.openedTabs() - const active = tabState.activeFileTab() - const order = active ? [active, ...all.filter((item) => item !== active)] : all - const seen = new Set() - const category = props.language.t("palette.group.files") - const items: Entry[] = [] - - for (const item of order) { - const path = props.file.pathFromTab(item) - if (!path) continue - if (seen.has(path)) continue - seen.add(path) - items.push(createFileEntry(path, category)) - } - - return items.slice(0, ENTRY_LIMIT) - }) - - const root = createMemo(() => { - const category = props.language.t("palette.group.files") - const nodes = props.file.tree.children("") - const paths = nodes - .filter((node) => node.type === "file") - .map((node) => node.path) - .sort((a, b) => a.localeCompare(b)) - return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category)) - }) - - return { recent, root } -} - -function createSessionEntries(props: { - workspaces: () => string[] - label: (directory: string) => string - serverSDK: ServerSDK - language: ReturnType -}) { - const state: { - token: number - inflight: Promise | undefined - cached: Entry[] | undefined - } = { - token: 0, - inflight: undefined, - cached: undefined, - } - - const sessions = (text: string) => { - const query = text.trim() - if (!query) { - state.token += 1 - state.inflight = undefined - state.cached = undefined - return [] as Entry[] - } - - 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 Entry[] - - state.inflight = Promise.all( - dirs.map((directory) => { - const description = props.label(directory) - return props.serverSDK.client.session - .list({ directory, roots: true }) - .then((x) => - (x.data ?? []) - .filter((s) => !!s?.id) - .map((s) => ({ - id: s.id, - title: s.title ?? props.language.t("command.session.new"), - description, - directory, - archived: s.time?.archived, - updated: s.time?.updated, - })), - ) - .catch( - () => - [] as { - id: string - title: string - description: string - directory: string - archived?: number - updated?: number - }[], - ) - }), - ) - .then((results) => { - if (state.token !== current) return [] as Entry[] - const seen = new Set() - const category = props.language.t("command.category.session") - 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, category)) - state.cached = next - return next - }) - .catch(() => [] as Entry[]) - .finally(() => { - state.inflight = undefined - }) - - return state.inflight - } - - return { sessions } -} - export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { - const command = useCommand() - const language = useLanguage() const platform = usePlatform() const settings = useSettings() - const layout = useLayout() - const file = useFile() - const dialog = useDialog() - const navigate = useNavigate() - const serverSDK = useServerSDK() - const serverSync = useServerSync() - const { params, tabs, view } = useSessionLayout() const filesOnly = () => props.mode === "files" - const state = { cleanup: undefined as (() => void) | void, committed: false } - const [grouped, setGrouped] = createSignal(false) - const commandEntries = createCommandEntries({ filesOnly, command, language }) - const fileEntries = createFileEntries({ file, tabs, language }) - const projectDirectory = createMemo(() => decode64(params.dir) ?? "") - const project = createMemo(() => { - const directory = projectDirectory() - if (!directory) return - return layout.projects.list().find((p) => p.worktree === directory || p.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 label = (directory: string) => { - 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}` + if (!filesOnly() && settings.general.newLayoutDesigns()) { + return } - const { sessions } = createSessionEntries({ workspaces, label, serverSDK: serverSDK(), language }) + if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) { + return + } + + return +} + +function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void }) { + const language = useLanguage() + const serverSDK = useServerSDK() + const { params } = useSessionLayout() + const projectDirectory = createMemo(() => decode64(params.dir) ?? "") + const openFile = createCommandPaletteFileOpener(props.onOpenFile) + + return ( + { + if (typeof result !== "string") return + openFile(result) + }} + /> + ) +} + +function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?: (path: string) => void }) { + const palette = createCommandPaletteModel(props) + const [grouped, setGrouped] = createSignal(false) const items = async (text: string) => { const query = text.trim() setGrouped(query.length > 0) - if (!query && filesOnly()) { - const loaded = file.tree.state("")?.loaded - const pending = loaded ? Promise.resolve() : file.tree.list("") - const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()]) + if (!query && props.filesOnly()) { + const loaded = palette.file.tree.state("")?.loaded + const pending = loaded ? Promise.resolve() : palette.file.tree.list("") + const next = uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()]) if (loaded || next.length > 0) { void pending @@ -331,79 +83,24 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil } await pending - return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()]) + return uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()]) } - if (!query) return [...commandEntries.picks(), ...fileEntries.recent()] + if (!query) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()] - if (filesOnly()) { - const files = await file.searchFiles(query) - const category = language.t("palette.group.files") - return files.map((path) => createFileEntry(path, category)) + if (props.filesOnly()) { + const files = await palette.file.searchFiles(query) + const category = palette.language.t("palette.group.files") + return files.map((path) => createCommandPaletteFileEntry(path, category)) } - const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))]) - const category = language.t("palette.group.files") - const entries = files.map((path) => createFileEntry(path, category)) - return [...commandEntries.list(), ...nextSessions, ...entries] - } - - const handleMove = (item: Entry | undefined) => { - state.cleanup?.() - if (!item) return - if (item.type !== "command") return - state.cleanup = item.option?.onHighlight?.() - } - - const open = (path: string) => { - const value = file.tab(path) - void tabs().open(value) - void file.load(path) - if (!view().reviewPanel.opened()) view().reviewPanel.open() - layout.fileTree.setTab("all") - props.onOpenFile?.(path) - tabs().setActive(value) - } - - const handleSelect = (item: Entry | 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") { - if (!item.directory || !item.sessionID) return - navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`) - return - } - - if (!item.path) return - open(item.path) - } - - onCleanup(() => { - if (state.committed) return - state.cleanup?.() - }) - - if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) { - return ( - { - if (typeof result !== "string") return - open(result) - }} - /> - ) + const [files, nextSessions] = await Promise.all([ + palette.file.searchFiles(query), + Promise.resolve(palette.sessions(query)), + ]) + const category = palette.language.t("palette.group.files") + const entries = files.map((path) => createCommandPaletteFileEntry(path, category)) + return [...palette.commandEntries(), ...nextSessions, ...entries] } return ( @@ -411,21 +108,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil item.id} filterKeys={["title", "description", "category"]} skipFilter={(item) => item.type === "file"} groupBy={grouped() ? (item) => item.category : () => ""} - onMove={handleMove} - onSelect={handleSelect} + onMove={(item: CommandPaletteEntry | undefined) => palette.highlight(item)} + onSelect={(item: CommandPaletteEntry | undefined) => palette.select(item)} > {(item) => (
- {formatKeybind(item.keybind ?? "", language.t)} + {formatKeybind(item.keybind ?? "", palette.language.t)}
@@ -479,7 +176,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
- {getRelativeTime(new Date(item.updated!).toISOString(), language.t)} + {getRelativeTime(new Date(item.updated!).toISOString(), palette.language.t)}
diff --git a/packages/app/src/components/dialog-select-model-unpaid-v2.stories.tsx b/packages/app/src/components/dialog-select-model-unpaid-v2.stories.tsx new file mode 100644 index 0000000000..bd4beb2ec9 --- /dev/null +++ b/packages/app/src/components/dialog-select-model-unpaid-v2.stories.tsx @@ -0,0 +1,55 @@ +// @ts-nocheck +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createSignal, onMount } from "solid-js" +import { DialogSelectModelUnpaidV2 } from "./dialog-select-model-unpaid-v2" + +const names = [ + "MiMo V2.5 Free", + "Nemotron 3 Ultra Free", + "Deepseek V4 Flash Free", + "North Mini Code Free", + "Hy3 Free", + "Big Pickle", +] + +function SelectModelWithoutProviders() { + const dialog = useDialog() + const models = names.map((name, index) => ({ + id: name.toLowerCase().replaceAll(" ", "-"), + name, + provider: { id: "opencode", name: "OpenCode" }, + cost: { input: 0, output: 0 }, + limit: { context: 128_000 }, + capabilities: { + reasoning: index !== 5, + input: { text: true, image: false, audio: false, video: false, pdf: false }, + }, + })) + const [current, setCurrent] = createSignal(models[2]) + const model = { + list: () => models, + current, + set(value) { + setCurrent(models.find((item) => item.id === value?.modelID)) + }, + } + const open = () => dialog.show(() => ) + + onMount(open) + + return ( + + ) +} + +export default { + title: "App/Dialogs/Select Model", + id: "app-dialog-select-model", +} + +export const WithoutProviders = { + render: () => , +} diff --git a/packages/app/src/components/dialog-select-model-unpaid-v2.tsx b/packages/app/src/components/dialog-select-model-unpaid-v2.tsx new file mode 100644 index 0000000000..de86485ef9 --- /dev/null +++ b/packages/app/src/components/dialog-select-model-unpaid-v2.tsx @@ -0,0 +1,175 @@ +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 { Tag } from "@opencode-ai/ui/v2/badge-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useTheme } from "@opencode-ai/ui/theme" +import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js" +import { useLocal } from "@/context/local" +import { useProviders } from "@/hooks/use-providers" +import { 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() + const modelKey = (item: ReturnType[number]) => `${item.provider.id}:${item.id}` + const currentKey = createMemo(() => { + const c = model.current() + return c ? `${c.provider.id}:${c.id}` : undefined + }) + 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) => { + const controller = x.useProviderConnectController() + controller.select(provider) + void dialog.show(() => ) + }) + } + + const selectModel = (item: ReturnType[number]) => { + model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true }) + dialog.close() + } + + // Focus starts on the dialog's close button, outside the list, so listen at the + // document level while the dialog is mounted instead of on the list container. + let listEl: HTMLDivElement | undefined + onMount(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return + if (!listEl) return + const buttons = Array.from(listEl.querySelectorAll("button")) + if (buttons.length === 0) return + const index = buttons.indexOf(document.activeElement as HTMLButtonElement) + const next = + index < 0 ? (e.key === "ArrowDown" ? 0 : buttons.length - 1) : index + (e.key === "ArrowDown" ? 1 : -1) + buttons[(next + buttons.length) % buttons.length]?.focus() + e.preventDefault() + } + document.addEventListener("keydown", handleKeyDown) + onCleanup(() => document.removeEventListener("keydown", handleKeyDown)) + }) + + return ( + + + {language.t("dialog.model.select.title")} + + +
+
+
+
+ {language.t("dialog.model.unpaid.freeModels.title")} +
+
+ + {(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-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx index bcb4a2fbcb..4611a36c95 100644 --- a/packages/app/src/components/dialog-select-model-unpaid.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -22,17 +22,16 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props const providers = useProviders(directory) const language = useLanguage() - const connect = (provider: string) => { + const openProviders = (provider?: string) => { void import("./dialog-connect-provider").then((x) => { - dialog.show(() => ) + const controller = x.useProviderConnectController() + controller.select(provider) + void dialog.show(() => ) }) } - const all = () => { - void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) - }) - } + const connect = (provider: string) => openProviders(provider) + const all = () => openProviders() let listRef: ListRef | undefined const handleKeyDown = (e: KeyboardEvent) => { diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index f026f371ea..4fb7891ec9 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -24,6 +24,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { Icon } from "@opencode-ai/ui/v2/icon" import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { ModelTooltip } from "./model-tooltip" import { useLanguage } from "@/context/language" import { decode64 } from "@/utils/base64" @@ -92,6 +93,7 @@ const ModelList: Component<{ class="w-full" placement="right-start" gutter={12} + openDelay={0} value={} > {node} @@ -155,8 +157,8 @@ export function ModelSelectorPopover(props: { const handleConnectProvider = () => { close("provider") - void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) + void import("./dialog-connect-provider").then((x) => { + void dialog.show(() => ) }) } const language = useLanguage() @@ -446,26 +448,41 @@ export function ModelSelectorPopoverV2(props: { {(item) => ( - { - setStore("active", modelKey(item)) - setTimeout(() => searchRef?.focus()) - }} - onSelect={() => selectModel(item)} + + } > - {item.name} - - {language.t("model.tag.free")} - - - {language.t("model.tag.latest")} - - + { + setStore("active", modelKey(item)) + setTimeout(() => searchRef?.focus()) + }} + onSelect={() => selectModel(item)} + > + {item.name} + + {language.t("model.tag.free")} + + + {language.t("model.tag.latest")} + + + )} @@ -503,8 +520,8 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat const directory = () => decode64(local.slug()) const provider = () => { - void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) + void import("./dialog-connect-provider").then((x) => { + void dialog.show(() => ) }) } diff --git a/packages/app/src/components/dialog-select-provider.tsx b/packages/app/src/components/dialog-select-provider.tsx deleted file mode 100644 index ff0ab5d2bf..0000000000 --- a/packages/app/src/components/dialog-select-provider.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { type Accessor, Component, Show } from "solid-js" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { popularProviders, useProviders } from "@/hooks/use-providers" -import { Dialog } from "@opencode-ai/ui/dialog" -import { List } from "@opencode-ai/ui/list" -import { Tag } from "@opencode-ai/ui/tag" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { DialogConnectProvider } from "./dialog-connect-provider" -import { useLanguage } from "@/context/language" -import { DialogCustomProvider } from "./dialog-custom-provider" - -const CUSTOM_ID = "_custom" - -export const DialogSelectProvider: Component<{ directory?: Accessor }> = (props) => { - const dialog = useDialog() - const providers = useProviders(props.directory) - const language = useLanguage() - - const popularGroup = () => language.t("dialog.provider.group.popular") - const otherGroup = () => language.t("dialog.provider.group.other") - const customLabel = () => language.t("settings.providers.tag.custom") - const note = (id: string) => { - if (id === "anthropic") return language.t("dialog.provider.anthropic.note") - if (id === "openai") return language.t("dialog.provider.openai.note") - if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note") - if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline") - } - - return ( - - x?.id} - items={() => { - language.locale() - return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()] - }} - filterKeys={["id", "name"]} - groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} - sortBy={(a, b) => { - if (a.id === CUSTOM_ID) return -1 - if (b.id === CUSTOM_ID) return 1 - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - sortGroupsBy={(a, b) => { - const popular = popularGroup() - if (a.category === popular && b.category !== popular) return -1 - if (b.category === popular && a.category !== popular) return 1 - return 0 - }} - onSelect={(x) => { - if (!x) return - if (x.id === CUSTOM_ID) { - dialog.show(() => ) - return - } - dialog.show(() => ) - }} - > - {(i) => ( -
- - {i.name} - -
{language.t("dialog.provider.opencode.tagline")}
-
- - {language.t("settings.providers.tag.custom")} - - - {language.t("dialog.provider.tag.recommended")} - - {(value) =>
{value()}
}
- - {language.t("dialog.provider.tag.recommended")} - -
- )} -
-
- ) -} diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index 20d71f4bfd..b554fa79a8 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -1,22 +1,35 @@ -import { Component } from "solid-js" +import { Component, createSignal, startTransition } from "solid-js" import { Dialog } from "@opencode-ai/ui/dialog" import { Tabs } from "@opencode-ai/ui/tabs" import { Icon } from "@opencode-ai/ui/icon" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" +import { useDialog } from "@opencode-ai/ui/context/dialog" import { SettingsGeneral } from "./settings-general" import { SettingsKeybinds } from "./settings-keybinds" import { SettingsProviders } from "./settings-providers" import { SettingsModels } from "./settings-models" import { SettingsServers } from "./settings-servers" -export const DialogSettings: Component = () => { +export const DialogSettings: Component<{ defaultValue?: string }> = (props) => { const language = useLanguage() const platform = usePlatform() + const dialog = useDialog() + const [tab, setTab] = createSignal(props.defaultValue ?? "general") + + const showProviders = () => { + void dialog.show(() => ) + } return ( - + void startTransition(() => setTab(value))} + class="h-full settings-dialog" + >
@@ -70,7 +83,7 @@ export const DialogSettings: Component = () => { - + diff --git a/packages/app/src/components/edit-project.ts b/packages/app/src/components/edit-project.ts new file mode 100644 index 0000000000..3ec999da06 --- /dev/null +++ b/packages/app/src/components/edit-project.ts @@ -0,0 +1,119 @@ +import { getFilename } from "@opencode-ai/core/util/path" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useMutation } from "@tanstack/solid-query" +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") { + await serverCtx().sdk.client.project.update({ + projectID: props.project.id, + directory: props.project.worktree, + name, + icon: { color: store.color || "", override: store.iconOverride || "" }, + commands: { start }, + }) + serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined) + dialog.close() + return + } + + 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/file-tree-v2-model.test.ts b/packages/app/src/components/file-tree-v2-model.test.ts new file mode 100644 index 0000000000..f2a63fe86d --- /dev/null +++ b/packages/app/src/components/file-tree-v2-model.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test" +import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model" +import type { FileNode } from "@opencode-ai/sdk/v2" + +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) + expect(flattenFileTreeV2(model, () => true).map((row) => [row.node.path, row.node.type, row.level])).toEqual([ + ["docs", "directory", 0], + ["docs/guide.md", "file", 1], + ["src", "directory", 0], + ["src/lib", "directory", 1], + ["src/lib/a.ts", "file", 2], + ["src/lib/b.ts", "file", 2], + ["src/z.ts", "file", 1], + ["README.md", "file", 0], + ]) + }) + + 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([ + "src", + "src/lib", + "src/z.ts", + ]) + }) + + 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(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"]) + expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts") + }) + + test("handles deeply nested paths", () => { + const file = Array.from({ length: 130 }, (_, index) => `d${index}`).join("/") + "/leaf.ts" + 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 new file mode 100644 index 0000000000..27783a9bb1 --- /dev/null +++ b/packages/app/src/components/file-tree-v2-model.ts @@ -0,0 +1,107 @@ +import type { FileNode } from "@opencode-ai/sdk/v2" + +export type FileTreeV2Model = { + children: ReadonlyMap + total: number +} + +export type FileTreeV2Node = FileNode & { originalPath: string } + +export type FileTreeV2Row = { + node: FileTreeV2Node + level: number +} + +export function normalizeFileTreeV2Path(value: string) { + return value + .replaceAll("\\", "/") + .replace(/^\/+|\/+$/g, "") + .replace(/\/{2,}/g, "/") +} + +export function buildFileTreeV2Model(paths: readonly string[]): FileTreeV2Model { + const nodes = new Map() + + paths.forEach((value) => { + const file = normalizeFileTreeV2Path(value) + if (!file) return + + const parts = file.split("/") + parts.forEach((name, index) => { + const path = parts.slice(0, index + 1).join("/") + if (nodes.has(path)) return + nodes.set(path, { + name, + path, + absolute: path, + type: index === parts.length - 1 ? "file" : "directory", + ignored: false, + originalPath: index === parts.length - 1 ? value : path, + }) + }) + }) + + const children = new Map() + nodes.forEach((node) => { + const index = node.path.lastIndexOf("/") + const parent = index === -1 ? "" : node.path.slice(0, index) + const list = children.get(parent) + if (list) list.push(node) + else children.set(parent, [node]) + }) + children.forEach((nodes) => + nodes.sort((a, b) => { + if (a.type !== b.type) return a.type === "directory" ? -1 : 1 + return a.name.localeCompare(b.name) + }), + ) + + return { children, total: nodes.size } +} + +export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: string) => boolean) { + const rows: FileTreeV2Row[] = [] + const stack = (model.children.get("") ?? []).toReversed().map((node) => ({ 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 children = model.children.get(row.node.path) ?? [] + for (let index = children.length - 1; index >= 0; index--) { + stack.push({ node: children[index]!, level: row.level + 1 }) + } + } + + 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 302d9b58a3..26cf49d411 100644 --- a/packages/app/src/components/file-tree-v2.tsx +++ b/packages/app/src/components/file-tree-v2.tsx @@ -1,95 +1,32 @@ import { useFile } from "@/context/file" -import { Collapsible } from "@opencode-ai/ui/collapsible" import { FileIcon } from "@opencode-ai/ui/file-icon" import "@opencode-ai/ui/v2/file-tree-v2.css" import { createEffect, createMemo, + createSignal, For, - Match, - on, Show, splitProps, - Switch, - untrack, type ComponentProps, type ParentProps, } from "solid-js" import { Dynamic } from "solid-js/web" 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 { - dirsToExpand, - pathToFileUrl, - shouldListRoot, - visibleKind, - withFileDragImage, - type Filter, - type Kind, -} from "@/components/file-tree" + 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" -const MAX_DEPTH = 128 - -function visibleNodesForPath(path: string, children: (dir: string) => FileNode[], current: Filter | undefined) { - const nodes = children(path) - if (!current) return nodes - - const parent = (item: string) => { - const idx = item.lastIndexOf("/") - if (idx === -1) return "" - return item.slice(0, idx) - } - - const leaf = (item: string) => { - const idx = item.lastIndexOf("/") - return idx === -1 ? item : item.slice(idx + 1) - } - - const out = nodes.filter((node) => { - if (node.type === "file") return current.files.has(node.path) - return current.dirs.has(node.path) - }) - - const seen = new Set(out.map((node) => node.path)) - - for (const dir of current.dirs) { - if (parent(dir) !== path) continue - if (seen.has(dir)) continue - out.push({ - name: leaf(dir), - path: dir, - absolute: dir, - type: "directory", - ignored: false, - }) - seen.add(dir) - } - - for (const item of current.files) { - if (parent(item) !== path) continue - if (seen.has(item)) continue - out.push({ - name: leaf(item), - path: item, - absolute: item, - type: "file", - ignored: false, - }) - seen.add(item) - } - - out.sort((a, b) => { - if (a.type !== b.type) { - return a.type === "directory" ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) - - return out -} - const INDENT_STEP = 16 function rowPaddingLeft(level: number, type: FileNode["type"]) { @@ -105,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) => { @@ -123,7 +60,6 @@ const FileTreeNodeV2 = ( active?: string draggable: boolean kinds?: ReadonlyMap - marks?: Set as?: "div" | "button" }, ) => { @@ -133,18 +69,18 @@ const FileTreeNodeV2 = ( "active", "draggable", "kinds", - "marks", "as", "children", "class", "classList", ]) - const kind = () => visibleKind(local.node, local.kinds, local.marks) + const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path)) return ( + {(_, index) =>
} + + ) +} + export default function FileTreeV2(props: { - path: string active?: string - level?: number allowed?: readonly string[] kinds?: ReadonlyMap draggable?: boolean onFileClick?: (file: FileNode) => void - - _filter?: Filter - _marks?: Set - _deeps?: Map - _kinds?: ReadonlyMap - _chain?: readonly string[] + onFileDoubleClick?: (file: FileNode) => void }) { const file = useFile() - const level = props.level ?? 0 + const live = () => props.allowed === undefined const draggable = () => props.draggable ?? true - - const key = (p: string) => - file - .normalize(p) - .replace(/[\\/]+$/, "") - .replaceAll("\\", "/") - const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)] - - const filter = createMemo(() => { - if (props._filter) return props._filter - - const allowed = props.allowed - if (!allowed) return - - const files = new Set(allowed) - const dirs = new Set() - - for (const item of allowed) { - const parts = item.split("/") - const parents = parts.slice(0, -1) - for (const [idx] of parents.entries()) { - const dir = parents.slice(0, idx + 1).join("/") - if (dir) dirs.add(dir) - } - } - - return { files, dirs } + const active = () => normalizeFileTreeV2Path(props.active ?? "") + const model = createMemo(() => (live() ? undefined : buildFileTreeV2Model(props.allowed ?? []))) + const expanded = (path: string) => file.tree.state(path)?.expanded ?? !live() + const rows = createMemo(() => { + if (live()) return flattenLiveFileTreeV2((path) => file.tree.children(path), expanded) + return flattenFileTreeV2(model()!, expanded) }) - - const marks = createMemo(() => { - if (props._marks) return props._marks - - const out = new Set(props.kinds?.keys() ?? []) - if (out.size === 0) return - return out - }) - - const kinds = createMemo(() => { - if (props._kinds) return props._kinds - return props.kinds - }) - - const deeps = createMemo(() => { - if (props._deeps) return props._deeps - - const out = new Map() - - const root = props.path - if (!(file.tree.state(root)?.expanded ?? false)) return out - - const seen = new Set() - const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = [] - - const push = (dir: string, lvl: number) => { - const id = key(dir) - if (seen.has(id)) return - seen.add(id) - - const kids = file.tree - .children(dir) - .filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false)) - .map((node) => node.path) - - stack.push({ dir, lvl, i: 0, kids, max: lvl }) - } - - push(root, level - 1) - - while (stack.length > 0) { - const top = stack[stack.length - 1]! - - if (top.i < top.kids.length) { - const next = top.kids[top.i]! - top.i++ - push(next, top.lvl + 1) - continue - } - - out.set(top.dir, top.max) - stack.pop() - - const parent = stack[stack.length - 1] - if (!parent) continue - parent.max = Math.max(parent.max, top.max) - } - - return out + const [root, setRoot] = createSignal() + const [focused, setFocused] = createSignal() + const virtualizer = createVirtualizer({ + get count() { + return rows().length + }, + getScrollElement: () => virtualScrollElement(root()), + initialRect: { width: 0, height: 600 }, + estimateSize: () => 28, + gap: 2, + overscan: 10, + get getItemKey() { + const current = rows() + return (index: number) => current[index]?.node.path ?? index + }, + rangeExtractor: (range) => { + const indexes = defaultRangeExtractor(range) + const path = focused() + const index = path ? rows().findIndex((row) => row.node.path === path) : -1 + if (index < 0 || indexes.includes(index)) return indexes + return [...indexes, index].sort((a, b) => a - b) + }, }) createEffect(() => { - const current = filter() - const dirs = dirsToExpand({ - level, - filter: current, - expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false, - }) - // Nodes come from the `allowed` filter; skip listing so directories that only - // exist on the diff's base branch do not each fail with an error toast. - for (const dir of dirs) file.tree.expand(dir, { list: false }) + if (!live()) return + void file.tree.list("") }) - createEffect( - on( - () => props.path, - (path) => { - const dir = untrack(() => file.tree.state(path)) - if (!shouldListRoot({ level, dir })) return - void file.tree.list(path) - }, - { defer: false }, - ), - ) + // Only scroll when the active path changes (or first appears in the tree). + // Do not re-scroll when expand/collapse reshuffles `rows()`. + let scrolledActive: string | undefined + createEffect(() => { + const path = active() + if (!path) { + scrolledActive = undefined + return + } + const index = rows().findIndex((row) => row.node.path === path) + if (index < 0) return + if (scrolledActive === path) return + scrolledActive = path + queueMicrotask(() => { + const next = rows().findIndex((row) => row.node.path === path) + if (next < 0) return + if (virtualizer.range && next >= virtualizer.range.startIndex && next <= virtualizer.range.endIndex) return + virtualizer.scrollToIndex(next, { align: "auto" }) + }) + }) - const nodes = createMemo(() => visibleNodesForPath(props.path, file.tree.children, filter())) + 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)), + ) + const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key)) return ( - // group/file-tree-v2 scopes the group-hover guide lines below; hosts may add - // an outer group with the same name to widen the hover area. -
- - {(node) => { - const expanded = () => file.tree.state(node.path)?.expanded ?? false - const deep = () => deeps().get(node.path) ?? -1 - const hasChildren = () => visibleNodesForPath(node.path, file.tree.children, filter()).length > 0 - return ( - - - - open ? file.tree.expand(node.path, { list: false }) : file.tree.collapse(node.path) - } - > - - -
- -
-
-
- - -
- ...
} - > - + + {(key) => ( + + {(item) => ( +
+ + {(row) => ( + - - - - - - - props.onFileClick?.(node)} - > - 0}> -
- - } - > - - - - - - - - - ) - }} + as="button" + type="button" + class="relative" + onFocus={() => setFocused(row().node.path)} + onBlur={() => setFocused(undefined)} + onClick={() => selectFile(row().node, props.onFileClick)} + onDblClick={() => selectFile(row().node, props.onFileDoubleClick)} + > + + 0}> +
+ + + + + + + } + > + setFocused(row().node.path)} + onBlur={() => setFocused(undefined)} + aria-expanded={expanded(row().node.path)} + onClick={() => toggleDirectory(row().node.path, row().node.originalPath)} + > + +
+ +
+
+ + )} + +
+ )} +
+ )}
) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 93ff46d5c1..718e2f3493 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -201,6 +201,7 @@ export default function FileTree(props: { kinds?: ReadonlyMap draggable?: boolean onFileClick?: (file: FileNode) => void + onFileDoubleClick?: (file: FileNode) => void _filter?: Filter _marks?: Set @@ -440,6 +441,7 @@ export default function FileTree(props: { active={props.active} draggable={props.draggable} onFileClick={props.onFileClick} + onFileDoubleClick={props.onFileDoubleClick} _filter={filter()} _marks={marks()} _deeps={deeps()} @@ -462,6 +464,7 @@ export default function FileTree(props: { as="button" type="button" onClick={() => props.onFileClick?.(node)} + onDblClick={() => props.onFileDoubleClick?.(node)} >
diff --git a/packages/app/src/components/help-button.tsx b/packages/app/src/components/help-button.tsx index 9109418fcf..23a955a837 100644 --- a/packages/app/src/components/help-button.tsx +++ b/packages/app/src/components/help-button.tsx @@ -1,54 +1,119 @@ -import { Icon } from "@opencode-ai/ui/v2/icon" -import { Popover } from "@opencode-ai/ui/popover" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { createSignal, Show } from "solid-js" -import { createStore } from "solid-js/store" +import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer" +import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" +import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4" +import homeImage from "@/assets/help/home.png" +import tabsImage from "@/assets/help/tabs.png" -export function HelpButton() { - if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null +// TODO: wire to changelog / seen-state when available +const showPopover = () => true - const [state, setState] = /* persisted(Persist.global("help-button"), */ createStore({ dismissed: false }) /* ) */ - const [shown, setShown] = createSignal(false) +// can remove this after the tabs rollout has been out for a while +export function TabsInfoPopup() { + const settings = useSettings() + const platform = usePlatform() + const [drawerOpen, setDrawerOpen] = createSignal(false) return ( - -