diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 09cab61bf2..fd2f6cf062 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -117,7 +117,7 @@ from the squash message first, or use a plain merge. | `packages/app/index.html` | `` → "CoDev Code" | Web UI tab title. | Keep our title. | | `packages/app/src/pages/layout.tsx`, `packages/app/src/pages/layout-new.tsx` | Dropped the `<TabsInfoPopup />` mount and its import from both layouts (added upstream in v1.17.18); `HelpButton` stays. | `TabsInfoPopup` is a dev-channel-only "Introducing Tabs" promo whose `muted autoplay loop` `<video>` mounts on every layout render. The fork's Windows e2e runs on GPU-less GitHub-hosted runners (software GPU — see the runner-swap row), where continuous video decode wedges the GPU command buffer (`kTransientFailure`), making the whole app-e2e suite flaky and ~3–5× slower. It never ships (release builds use a non-`dev` `CODEV_CHANNEL`), so unmounting it costs users nothing. | Keep ours: don't re-add the `<TabsInfoPopup />` mount or restore it to the import. | | `packages/app/src/components/help-button.tsx` | Removed the `TabsInfoPopup` component, its `introducing-tabs.mp4` import, and the imports only it used; `HelpButton` stays. (The file's `VITE_OPENCODE_CHANNEL` → `VITE_CODEV_CHANNEL` gates follow the env-var rename row.) | The popup is upstream's temporary, OpenCode-branded "Introducing Tabs" promo (upstream's own comment says it's removable after the rollout), already unmounted per the row above; removing the definition lets the 13 MB video be deleted (row below). | Keep our removal; take upstream's changes to `HelpButton`. If upstream mounts `TabsInfoPopup` somewhere new, drop that mount per the row above. When upstream deletes the popup itself, this row and the two rows around it retire. | -| `packages/app/src/assets/help/` (introducing-tabs.mp4, placeholder.png — whole directory) | Deleted | The 13 MB promo video's only consumer was the removed `TabsInfoPopup` (row above) and it bloats every clone; `placeholder.png` is referenced by nothing. | Keep deleted (`git rm` the upstream side of modify/delete conflicts). | +| `packages/app/src/assets/help/` (introducing-tabs.mp4, placeholder.png, home.png, tabs.png — whole directory) | Deleted | The 13 MB promo video's and the v1.18 home/tabs screenshots' only consumer was the removed `TabsInfoPopup` (row above) and they bloat every clone; `placeholder.png` is referenced by nothing. | Keep deleted (`git rm` the upstream side of modify/delete conflicts); delete new upstream assets under this directory whose only consumer is the removed popup. | | `packages/app/src/desktop-menu.ts`, `packages/app/src/components/windows-app-menu.tsx` | App-menu label "OpenCode" → "CoDev Code"; "OpenCode Documentation" item → "CoDev Code Documentation" (href `opencode.ai/docs` stays — real URL); trigger `aria-label` "OpenCode menu" → "CoDev Code menu" (×2) | Visible menu branding, incl. screen-reader labels. | Keep our labels; take upstream's surrounding menu changes. Rebrand any new "OpenCode" label/aria-label. | | Hardcoded product-string rebrand in TUI + web app: `packages/tui/src/app.tsx`, `packages/tui/src/routes/session/permission.tsx`, `packages/tui/src/attention.ts` (+ `packages/opencode/test/cli/cmd/tui/attention.test.ts`), `packages/app/src/wsl/settings-model.ts` (+ `.test.ts`), `packages/app/src/components/help-button.tsx`, `packages/ui/src/components/favicon.tsx` | User-visible "OpenCode" strings outside the i18n dicts → fork brand: update-complete toast "Successfully updated to OpenCode v…" → "CoDev Code"; permission dialog "…until OpenCode is restarted" → "CoDev Code" (the sibling patterns line was already rebranded); OS-notification fallback title `opencode` → `codev` and built-in sound-pack display name "OpenCode Default" → "CoDev Code Default" (pack id `opencode.default` stays — internal identifier; the three default-title expectations in `packages/opencode/test/cli/cmd/tui/attention.test.ts` follow, while its explicit-title pass-through fixture stays `opencode`); WSL settings actions "Install/Update OpenCode" → "…CoDev Code" (+ test expectations/titles; fixture paths `/usr/local/bin/opencode` stay); help-button `aria-label` → "Open the CoDev Code website" (href repointed at the fork docs site — see the docs-site links row); PWA `apple-mobile-web-app-title` "OpenCode" → "CoDev Code" (matches the rebranded `site.webmanifest`, favicon-assets row) | These render in surfaces the fork ships (TUI + embedded web UI); the i18n-dict strings remain a known gap (UI branding bullet below). | Keep our strings; take upstream's surrounding changes. Rebrand any new hardcoded user-visible "OpenCode" string in these surfaces; leave identifiers, URLs, and fixture paths. | | Browser-opened docs-site links: `packages/tui/src/app.tsx`, `packages/app/src/desktop-menu.ts`, `packages/app/src/components/{help-button,settings-general,settings-v2/general,dialog-custom-provider}.tsx`, `packages/app/src/pages/{home,layout,error}.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/opencode/src/cli/cmd/providers.ts` | Links the app opens in the user's browser repointed from `opencode.ai` to the fork's hosted docs, base `https://netmind.viettel.vn/codev-landing-page/en/docs` (no trailing slash on the root; deep links keep their paths): TUI "Open docs" command and app Documentation menu item + help button → docs root; settings theme "Learn more" (v1 + v2) → `…/docs/themes/`; custom-provider dialog link → `…/docs/providers/#custom-provider`; CLI Cloudflare-gateway hint → `…/docs/providers/#cloudflare-ai-gateway`; home/layout sidebar Help buttons `opencode.ai/desktop-feedback` → docs root. The error page's report button `opencode.ai/desktop-feedback` → `github.com/quickbeard/codev-code/issues` instead (it's a report action, not a docs lookup), icon `discord` → `github`, and the en/es `error.page.report.*` strings → "…CoDev Code team" / "on GitHub". Help submenu (rendered by the shipped web UI's Windows app menu too): "Support Forum" (upstream Discord invite) deleted — the fork has no forum; "Share Feedback"/"Report a Bug" → `github.com/quickbeard/codev-code/issues/new?template=feature-request.yml`/`bug-report.yml` (the fork's template names are hyphenated, unlike upstream's underscored ones). | Fork users should land on the fork's docs and report issues to the fork, not upstream's site/Discord. NOT repointed: Zen/Go product URLs (`opencode.ai/zen`, `/go`, `/auth`), `$schema`/theme-schema ids, API/share/changelog endpoints, MCP OAuth `client_uri`, prompt-file mentions. | Keep our URLs, icon, and strings; take upstream's surrounding changes. Repoint any new browser-opened `opencode.ai` docs/help link to the fork docs base; leave the NOT-repointed list. | diff --git a/bun.lock b/bun.lock index 424ebab7d9..ddb1a5b84b 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -95,7 +95,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.17.18-3", + "version": "1.18.2", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -143,7 +143,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -164,7 +164,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -200,7 +200,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -227,7 +227,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -249,7 +249,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -273,7 +273,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -293,7 +293,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.17.18-3", + "version": "1.18.2", "bin": { "opencode": "./bin/opencode", }, @@ -301,7 +301,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", @@ -310,7 +310,7 @@ "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", - "@ai-sdk/openai": "3.0.53", + "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", @@ -345,7 +345,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", @@ -387,7 +387,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -441,7 +441,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -455,7 +455,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "effect": "catalog:", }, @@ -467,7 +467,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -499,7 +499,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -515,7 +515,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -546,7 +546,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -565,7 +565,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.17.18-3", + "version": "1.18.2", "bin": { "codev": "./bin/opencode", }, @@ -576,7 +576,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", @@ -585,7 +585,7 @@ "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", - "@ai-sdk/openai": "3.0.53", + "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", @@ -638,7 +638,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", @@ -696,7 +696,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -772,7 +772,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "cross-spawn": "catalog:", }, @@ -787,7 +787,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -802,7 +802,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -846,7 +846,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -859,9 +859,10 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "@ibm/plex": "6.4.1", + "@kobalte/core": "catalog:", "@opencode-ai/stats-core": "workspace:*", "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -892,7 +893,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -911,7 +912,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -952,7 +953,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "@codev/gateway": "workspace:*", "@opencode-ai/core": "workspace:*", @@ -980,7 +981,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1031,7 +1032,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.17.18-3", + "version": "1.18.2", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -1074,6 +1075,7 @@ ], "patchedDependencies": { "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", @@ -1081,10 +1083,8 @@ "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", - "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", }, "overrides": { @@ -1120,7 +1120,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", @@ -1140,7 +1140,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", @@ -1182,7 +1182,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=="], @@ -1192,7 +1192,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=="], @@ -2790,9 +2790,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=="], @@ -3860,7 +3860,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=="], @@ -4322,7 +4322,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=="], @@ -5664,7 +5664,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=="], @@ -5676,9 +5680,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=="], @@ -6030,7 +6034,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=="], @@ -6056,6 +6060,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=="], @@ -6174,6 +6180,10 @@ "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="], + "ai-gateway-provider/@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="], + + "ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="], + "ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], "ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="], @@ -6400,7 +6410,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=="], @@ -6908,6 +6918,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=="], @@ -6978,6 +6992,14 @@ "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="], + + "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], + + "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + + "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], @@ -7130,6 +7152,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=="], @@ -7378,6 +7404,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=="], @@ -7394,6 +7422,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=="], @@ -7464,6 +7496,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/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/hashes.json b/nix/hashes.json index 50208fdbbe..9d85faba7f 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-JTtn+wXTXg+yklvIMDLcGFaYhTU6ZrCgKT9JTNEQ3gA=", - "aarch64-linux": "sha256-gXU6zyhvAZrZirkL/PlHdkHtEof/7PVSPCaE34Jnd4U=", - "aarch64-darwin": "sha256-Q0oTG3uzOlD/X2kJingLle529lKFoTpyCW2rHXOZ6iE=", - "x86_64-darwin": "sha256-LINvKHxPibTlJeNzfACQx0x+Yj5oROT6Du3I5AtqqXk=" + "x86_64-linux": "sha256-F1luclnqCPQk9yxfmeSYGaM/nScf28yBu9K3Fv+Xd24=", + "aarch64-linux": "sha256-XW0XZnsCRkU3MFJH9TjMRYZHffzVy3cQyiNCkec2gl4=", + "aarch64-darwin": "sha256-bf8kvORs3Fs2UYLp3PekF+AJR7NKOcHb+fIQA79RtMk=", + "x86_64-darwin": "sha256-sBdQPkzd7JXNW6Lbi9JHiAsfHwdLwTKWY+uPeXAv2Nw=" } } diff --git a/package.json b/package.json index 30dfc4313d..0318f5c68b 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "@opentui/core": "0.4.3", "@opentui/keymap": "0.4.3", "@opentui/solid": "0.4.3", - "@tanstack/solid-virtual": "3.13.28", + "@tanstack/solid-virtual": "3.13.32", "@shikijs/stream": "4.2.0", "ulid": "3.0.1", "@kobalte/core": "0.13.11", @@ -69,7 +69,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", @@ -151,10 +151,9 @@ "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", - "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", - "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch" + "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch" } } diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index e63445e87d..5095d95db0 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -136,6 +136,9 @@ export async function setupTimeline( }, }), ) + if (settings.newLayoutDesigns === false) { + localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" })) + } }, input.settings ?? {}) if (input.locale) { await page.addInitScript((locale) => { diff --git a/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts b/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts new file mode 100644 index 0000000000..136232c211 --- /dev/null +++ b/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts @@ -0,0 +1,150 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/FileBrowserSidebar" +const projectID = "proj_file_browser_sidebar" +const sessionID = "ses_file_browser_sidebar" +const title = "File browser sidebar" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`) +// Marks the file-browser sidebar DOM node so a remount (fresh node) is detectable. +const PROBE = "original" + +test.use({ viewport: { width: 1440, height: 900 } }) + +// The file-browser sidebar must stay mounted across preview/pinned file-tab +// switches. Remounting resets scroll and filter state. +test("keeps the file-browser sidebar mounted when switching file tabs", async ({ page }) => { + await setup(page) + + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const panel = page.locator("#review-panel") + await panel.getByRole("button", { name: "Open file" }).click() + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + + const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]') + await expect(sidebar).toBeVisible() + await expect(panel.getByRole("button", { name: "file-00.ts" })).toBeVisible() + + await panel.getByRole("button", { name: "file-00.ts" }).click() + await expect(panel.getByRole("tab", { name: "file-00.ts" })).toHaveAttribute("data-selected", "") + await expect(panel.getByText("contents:file-00.ts", { exact: true })).toBeVisible() + + const viewport = panel.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport') + await viewport.hover() + await page.mouse.wheel(0, 100_000) + await expect + .poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) + const scrolled = await viewport.evaluate((element) => element.scrollTop) + expect(scrolled).toBeGreaterThan(0) + await writeProbe(page) + + await panel.getByRole("button", { name: "file-79.ts" }).click() + await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "") + await expect(panel.getByText("contents:file-79.ts", { exact: true })).toBeVisible() + expect(await readProbe(page)).toBe(PROBE) + await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled) + + await panel.getByRole("button", { name: "file-78.ts" }).dblclick() + await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "") + await panel.getByRole("button", { name: "file-79.ts" }).click() + await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "") + await panel.getByRole("tab", { name: "file-78.ts" }).click() + await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "") + expect(await readProbe(page)).toBe(PROBE) + await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled) +}) + +type Probed = HTMLElement & { __e2eProbe?: string } + +async function writeProbe(page: Page) { + await page.locator('#review-panel [data-component="session-review-v2-sidebar-root"]').evaluate((el, probe) => { + ;(el as Probed).__e2eProbe = probe + }, PROBE) +} + +async function readProbe(page: Page) { + return page + .locator('#review-panel [data-component="session-review-v2-sidebar-root"]') + .evaluate((el) => (el as Probed).__e2eProbe) +} + +async function setup(page: Page) { + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "file-browser-sidebar", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [ + { + id: sessionID, + slug: sessionID, + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + vcsDiff: [], + fileList: (path) => { + if (path) return [] + return files.map((name) => ({ + name, + path: name, + absolute: `${directory}/${name}`, + type: "file" as const, + ignored: false, + })) + }, + fileContent: (path) => ({ type: "text", content: `contents:${path}` }), + pageMessages: () => ({ items: [] }), + }) + + await page.addInitScript( + ({ directory, server, sessionID }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.global.dat:layout", + JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), + ) + localStorage.setItem( + "opencode.global.dat:review-panel-v2", + JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "session", server, sessionId: sessionID }]), + ) + }, + { directory, server, sessionID }, + ) +} diff --git a/packages/app/e2e/regression/legacy-new-session.spec.ts b/packages/app/e2e/regression/legacy-new-session.spec.ts index 30233a5aae..45cd64adcc 100644 --- a/packages/app/e2e/regression/legacy-new-session.spec.ts +++ b/packages/app/e2e/regression/legacy-new-session.spec.ts @@ -24,6 +24,7 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => { await page.addInitScript( ({ directory, draftID, server }) => { localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } })) + localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" })) localStorage.setItem( "opencode.window.browser.dat:tabs", JSON.stringify([{ type: "draft", draftID, server, directory }]), diff --git a/packages/app/e2e/regression/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<string, unknown> }>(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 index f69591f608..25ebd3a37a 100644 --- a/packages/app/e2e/regression/review-open-file.spec.ts +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -74,6 +74,10 @@ test("opens and searches project files inline", async ({ page }) => { "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 }]), @@ -86,13 +90,18 @@ test("opens and searches project files inline", async ({ page }) => { 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() @@ -101,10 +110,13 @@ test("opens and searches project files inline", async ({ page }) => { await panel.getByRole("button", { name: "README.md" }).click() await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "") + await expect(sidebarToggle).toBeEnabled() await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible() + await expect(sidebar).toHaveCount(0) 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() @@ -113,12 +125,17 @@ test("opens and searches project files inline", async ({ page }) => { await expect(filter).toHaveAttribute("aria-activedescendant", resultID!) await filter.press("Enter") await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") + await expect(sidebarToggle).toBeEnabled() await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible() expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 }) await panel.getByRole("button", { name: "Open file" }).click() await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1) await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + await expect(sidebarToggle).toBeDisabled() + await panel.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", "") diff --git a/packages/app/e2e/regression/review-tab-switch.spec.ts b/packages/app/e2e/regression/review-tab-switch.spec.ts index c2ea406c5a..8634166e51 100644 --- a/packages/app/e2e/regression/review-tab-switch.spec.ts +++ b/packages/app/e2e/regression/review-tab-switch.spec.ts @@ -28,8 +28,8 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac await expectSessionTitle(page, titleA) await page.getByRole("button", { name: "Toggle review" }).click() - const reviewTab = page.getByRole("tab", { name: /Review/ }) - const reviewTabPanel = page.getByRole("tabpanel", { name: /Review/ }) + const reviewTab = page.locator("#session-side-panel-review-tab") + const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel") await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel") await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel") const review = page.locator('#review-panel [data-component="session-review-v2"]') diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts index e3ba607b3d..154bab48c4 100644 --- a/packages/app/e2e/regression/review-terminal-stacked.spec.ts +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -111,7 +111,7 @@ test("keeps the review tree and terminal sized when both panels are open", async await expectTree(page, 8, "git-0.ts") await selectMode(page, "Git changes", "Branch changes") - await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible() + await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740") await page.keyboard.press("Control+Backquote") await expect(page.locator("#terminal-panel")).toBeVisible() await expectTree(page, 2_773, "action.yml") 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<void>((resolve) => requestAnimationFrame(() => resolve()))) + for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft") + const cursor = draft.length - 4 + await expect + .poll(() => + editor.evaluate((element) => { + const selection = window.getSelection() + if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1 + const range = selection.getRangeAt(0).cloneRange() + range.selectNodeContents(element) + range.setEnd(selection.anchorNode!, selection.anchorOffset) + return range.toString().length + }), + ) + .toBe(cursor) + await transport.send({ + directory, + payload: { + type: "question.asked", + properties: { + id: "question-caret", + sessionID, + questions: [ + { + header: "Continue", + question: "Continue?", + options: [{ label: "Yes", description: "Continue the session" }], + }, + ], + tool: { messageID: "message-caret", callID: "call-caret" }, + }, + }, + }) + const question = page.locator('[data-component="dock-prompt"][data-kind="question"]') + await expect(question).toBeVisible() + await expect(editor).toHaveCount(0) + + await transport.send({ + directory, + payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } }, + }) + await expect(question).toHaveCount(0) + await expect(editor).toBeVisible() + await page.keyboard.press("x") + + await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`) +}) + async function mockServer( page: Page, requests: { diff --git a/packages/app/e2e/regression/session-timeline-history-root.spec.ts b/packages/app/e2e/regression/session-timeline-history-root.spec.ts index 15375cafed..e5ef7998ea 100644 --- a/packages/app/e2e/regression/session-timeline-history-root.spec.ts +++ b/packages/app/e2e/regression/session-timeline-history-root.spec.ts @@ -17,12 +17,14 @@ import { mockOpenCodeServer } from "../utils/mock-server" import { installSseTransport } from "../utils/sse-transport" import { expectSessionTitle } from "../utils/waits" -const assistants = Array.from({ length: 14 }, (_, index) => +const initialPageSize = 20 +const historyPageSize = 200 +const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) => assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, parentID: userID, created: 1700000001000 + index * 1_000, - completed: index < 13, + completed: index < initialPageSize, }), ) const messages = [userMessage(), ...assistants] @@ -46,7 +48,7 @@ const scenarios = [ test.use({ viewport: { width: 646, height: 1385 } }) for (const scenario of scenarios) { - test(`keeps the latest user turn visible through ${scenario.name}`, async ({ page }) => { + test(`keeps visible timeline content visible through ${scenario.name}`, async ({ page }) => { const requests: { before?: string; phase: "start" | "end" }[] = [] const pages: { before?: string; limit: number }[] = [] const roots: { sessionID: string; messageID: string }[] = [] @@ -101,36 +103,51 @@ for (const scenario of scenarios) { } }, }) - await page.addInitScript( - ({ userPartID, lastPartID }) => { - const state = { armed: false, hidden: false, samples: 0, stop: false } - ;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state - const sample = () => { - if (state.armed) { - const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]") - const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport") - const view = viewport?.getBoundingClientRect() - const visible = (partID: string) => { - const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${partID}"]`) - const rect = part?.getBoundingClientRect() - return ( - !!rect && - !!view && - rect.width > 0 && - rect.height > 0 && - rect.bottom > view.top && - rect.top < view.bottom - ) - } - if (!virtual || !visible(userPartID) || !visible(lastPartID)) state.hidden = true - state.samples++ + await page.addInitScript(() => { + const visibleParts = () => { + const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]") + const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport") + const view = viewport?.getBoundingClientRect() + if (!viewport || !view) return [] + return [...viewport.querySelectorAll<HTMLElement>("[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<HTMLElement>("[data-timeline-virtual-content]") + const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport") + const view = viewport?.getBoundingClientRect() + const visible = (partID: string) => { + const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${CSS.escape(partID)}"]`) + const rect = part?.getBoundingClientRect() + return ( + !!rect && !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom + ) } - if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0)) + if (!virtual || state.visibleParts.length === 0 || state.visibleParts.some((partID) => !visible(partID))) + state.hidden = true + state.samples++ } - requestAnimationFrame(() => setTimeout(sample, 0)) - }, - { userPartID, lastPartID }, - ) + if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0)) + } + requestAnimationFrame(() => setTimeout(sample, 0)) + }) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await transport.waitForConnection() @@ -143,23 +160,28 @@ for (const scenario of scenarios) { "messages:start:latest", "messages:end:latest", `message:${userID}`, - `messages:start:${messages.at(-2)!.info.id}`, + `messages:start:${messages.at(-initialPageSize)!.info.id}`, ]) + await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize) await page.evaluate(() => { ;( window as Window & { - __historyRootProbe?: { armed: boolean } + __historyRootProbe?: { arm(): void } } - ).__historyRootProbe!.armed = true + ).__historyRootProbe!.arm() }) await waitForProbeSamples(page, 0) - expect(await historyRootHidden(page)).toBe(false) + expect(await visibleContentHidden(page)).toBe(false) const beforeHistory = await probeSamples(page) history.resolve() - await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(14) + await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length) + await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2) await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() await waitForProbeSamples(page, beforeHistory) - expect(pages[0]).toEqual({ before: undefined, limit: 2 }) + expect(pages).toEqual([ + { before: undefined, limit: initialPageSize }, + { before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize }, + ]) expect(roots).toEqual([{ sessionID, messageID: userID }]) const message = messageUpdated(scenario.info) @@ -213,7 +235,7 @@ async function waitForProbeSamples(page: Page, after: number) { ) } -function historyRootHidden(page: Page) { +function visibleContentHidden(page: Page) { return page.evaluate( () => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden, ) diff --git a/packages/app/e2e/regression/session-timeline-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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>("[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<void>() + const release = Promise.withResolvers<void>() + 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<void>() + const release = Promise.withResolvers<void>() + await page.route(/ghostty-web/, async (route) => { + ghostty.resolve() + await release.promise + await route.continue() + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, "Terminal composer focus") + + const composer = page.locator('[data-component="prompt-input"]') + const terminal = page.locator('[data-component="terminal"]') + await page.keyboard.press("Control+Backquote") + await expect(terminal).toBeVisible() + await ghostty.promise + await composer.click() + await expect(composer).toBeFocused() + + release.resolve() + await expect(terminal.locator("textarea")).toHaveCount(1) + await page.waitForTimeout(50) + await expect(composer).toBeFocused() +}) + +test("focuses a terminal created from the new-terminal button", async ({ page }) => { + const created = { count: 0 } + await page.route("**/pty", (route) => { + created.count += 1 + const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(next), + }) + }) + await page.route(`**/pty/${newPtyID}`, (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), + ) + await page.route(`**/pty/${newPtyID}/connect-token*`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify({ ticket: "e2e-ticket" }), + }), + ) + await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, "Terminal composer focus") + + const composer = page.locator('[data-component="prompt-input"]') + const terminal = page.locator('[data-component="terminal"]') + await page.keyboard.press("Control+Backquote") + await expect(terminal.locator("textarea")).toHaveCount(1) + await composer.click() + await expect(composer).toBeFocused() + + await page.getByRole("button", { name: "New terminal" }).click() + await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true") + await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true) +}) + +function seedCachedTerminal(page: Page) { + return page.addInitScript( + ({ terminalKey, ptyID }) => { + localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } })) + localStorage.setItem( + terminalKey, + JSON.stringify({ + active: ptyID, + all: [{ id: ptyID, title: "Terminal 1", titleNumber: 1 }], + }), + ) + }, + { terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID }, + ) +} diff --git a/packages/app/e2e/reproduction/timeline-suspense/index.html b/packages/app/e2e/reproduction/timeline-suspense/index.html new file mode 100644 index 0000000000..995008a9f7 --- /dev/null +++ b/packages/app/e2e/reproduction/timeline-suspense/index.html @@ -0,0 +1,34 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>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/tsconfig.json b/packages/app/e2e/tsconfig.json index 53aacbda02..4a6046e4fa 100644 --- a/packages/app/e2e/tsconfig.json +++ b/packages/app/e2e/tsconfig.json @@ -10,6 +10,9 @@ "./performance/timeline-stability/fixture.test.ts", "./performance/timeline-stability/fixture.ts", "./performance/unit/visual-stability.test.ts", + "./reproduction/timeline-suspense/**/*.ts", + "./reproduction/timeline-suspense/**/*.tsx", + "../src/pages/session/timeline/observe-element-offset.ts", "./regression/new-session-panel-corner.spec.ts", "./regression/session-timeline-context-resize.spec.ts", "./utils/**/*.ts" diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index e1946b8fba..2bfba5871a 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -56,6 +56,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const path = url.pathname if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry) if (path === "/global/health") return json(route, { healthy: true }) + if (path === "/api/session") + return json(route, { + data: config.sessions.map((session) => v2Session(session, config.directory)), + cursor: {}, + }) if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false }) if (path === "/permission") return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) @@ -132,6 +137,30 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }) } +function v2Session(session: { id: string } & Record, fallbackDirectory: string) { + const time = session.time && typeof session.time === "object" ? session.time : {} + return { + id: session.id, + parentID: session.parentID, + projectID: session.projectID ?? "project", + cost: session.cost ?? 0, + tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { + created: "created" in time && typeof time.created === "number" ? time.created : 0, + updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0, + ...(session.time && typeof session.time === "object" && "archived" in session.time + ? { archived: session.time.archived } + : {}), + }, + title: session.title ?? session.id, + location: { + directory: typeof session.directory === "string" ? session.directory : fallbackDirectory, + ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}), + }, + ...(typeof session.path === "string" ? { subpath: session.path } : {}), + } +} + function json(route: Route, body: unknown, headers?: Record, status = 200) { return route.fulfill({ status, diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index 186962998d..55420485f3 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -162,7 +162,7 @@ export async function installSseTransport( const request = new Request(input, init) const url = new URL(request.url) if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event")) - return originalFetch(input, init) + return originalFetch(request) const id = ++nextConnectionID const record = { diff --git a/packages/app/package.json b/packages/app/package.json index 5eb9b5efbd..5475299c41 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.17.18-3", + "version": "1.18.2", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 86263d172d..5592e2f4ea 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -153,8 +153,7 @@ function LegacyTargetSessionRedirect() { } // Wraps the non-draft routes. They are gated on (and keyed to) the globally selected -// server via ServerKey, then provide the server-scoped shell (Permission/Layout/ -// Notification/Models + the visual Layout) for that server. +// server via ServerKey, then provide the server-scoped shell for that server. function SelectedServerProviders(props: ParentProps) { return ( @@ -207,7 +206,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) { - + @@ -215,7 +214,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) { - + @@ -309,24 +308,21 @@ function DesktopCommands() { // Server-scoped providers shared by the legacy shell and the top-level new shell. type ServerScopedShellProps = ParentProps<{ directory?: () => string | undefined - sessionID?: () => string | undefined serverScoped?: JSX.Element }> function ServerScopedProviders(props: ServerScopedShellProps) { return ( - - - {props.serverScoped} - {props.children} - - + + {props.serverScoped} + {props.children} + ) } function LegacyServerScopedShell(props: ServerScopedShellProps) { return ( - + {props.children} ) @@ -342,14 +338,6 @@ function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) { ) } -function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) { - return ( - - {props.children} - - ) -} - // The draft page only renders the prompt composer, so it drops TerminalProvider. // FileProvider and CommentsProvider stay because PromptInput uses file search and comment context. function DraftProviders(props: ParentProps) { @@ -559,13 +547,15 @@ export function AppInterface(props: { component={props.router ?? Router} root={(routerProps) => ( - - - - {routerProps.children} - - - + + + + + {routerProps.children} + + + + )} > 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/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 index 288f7112e9..f2a63fe86d 100644 --- a/packages/app/src/components/file-tree-v2-model.test.ts +++ b/packages/app/src/components/file-tree-v2-model.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test } from "bun:test" -import { buildFileTreeV2Model, flattenFileTreeV2 } from "./file-tree-v2-model" +import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model" +import type { FileNode } from "@opencode-ai/sdk/v2" -describe("file tree v2 model", () => { - test("builds sorted depth-first rows", () => { +describe("buildFileTreeV2Model", () => { + test("builds a sorted tree and flattens expanded directories", () => { const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"]) expect(model.total).toBe(8) @@ -18,7 +19,7 @@ describe("file tree v2 model", () => { ]) }) - test("omits descendants of collapsed directories", () => { + test("skips children of collapsed directories", () => { const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"]) expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([ @@ -28,19 +29,46 @@ describe("file tree v2 model", () => { ]) }) - test("normalizes separators and duplicate paths", () => { + test("normalizes duplicate and messy paths", () => { const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"]) const rows = flattenFileTreeV2(model, () => true) - expect(model.total).toBe(4) expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"]) expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts") }) - test("supports paths deeper than the legacy recursion limit", () => { - const file = `${Array.from({ length: 130 }, (_, index) => `dir-${index}`).join("/")}/file.ts` + test("handles deeply nested paths", () => { + const file = Array.from({ length: 130 }, (_, index) => `d${index}`).join("/") + "/leaf.ts" const model = buildFileTreeV2Model([file]) expect(flattenFileTreeV2(model, () => true)).toHaveLength(131) }) }) + +describe("flattenLiveFileTreeV2", () => { + test("flattens live children using original paths for nested lookups", () => { + const nodes: Record = { + "": [ + { name: "src", path: "src", absolute: "/repo/src", type: "directory", ignored: false }, + { name: "README.md", path: "README.md", absolute: "/repo/README.md", type: "file", ignored: false }, + ], + src: [ + { name: "a.ts", path: "src/a.ts", absolute: "/repo/src/a.ts", type: "file", ignored: false }, + { name: "lib", path: "src/lib", absolute: "/repo/src/lib", type: "directory", ignored: false }, + ], + "src/lib": [{ name: "b.ts", path: "src/lib/b.ts", absolute: "/repo/src/lib/b.ts", type: "file", ignored: false }], + } + + expect( + flattenLiveFileTreeV2( + (path) => nodes[path] ?? [], + (path) => path === "src", + ).map((row) => [row.node.path, row.node.originalPath, row.level]), + ).toEqual([ + ["src", "src", 0], + ["src/a.ts", "src/a.ts", 1], + ["src/lib", "src/lib", 1], + ["README.md", "README.md", 0], + ]) + }) +}) diff --git a/packages/app/src/components/file-tree-v2-model.ts b/packages/app/src/components/file-tree-v2-model.ts index 2127b6800f..27783a9bb1 100644 --- a/packages/app/src/components/file-tree-v2-model.ts +++ b/packages/app/src/components/file-tree-v2-model.ts @@ -75,3 +75,33 @@ export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: strin return rows } + +export function flattenLiveFileTreeV2( + children: (path: string) => readonly FileNode[], + expanded: (path: string) => boolean, +) { + const rows: FileTreeV2Row[] = [] + const stack = children("") + .toReversed() + .map((node) => ({ node: toLiveNode(node), level: 0 })) + + while (stack.length > 0) { + const row = stack.pop()! + rows.push(row) + if (row.node.type !== "directory" || !expanded(row.node.path)) continue + const nested = children(row.node.originalPath) + for (let index = nested.length - 1; index >= 0; index--) { + stack.push({ node: toLiveNode(nested[index]!), level: row.level + 1 }) + } + } + + return rows +} + +function toLiveNode(node: FileNode): FileTreeV2Node { + return { + ...node, + path: normalizeFileTreeV2Path(node.path), + originalPath: node.path, + } +} diff --git a/packages/app/src/components/file-tree-v2.tsx b/packages/app/src/components/file-tree-v2.tsx index c712a80443..26cf49d411 100644 --- a/packages/app/src/components/file-tree-v2.tsx +++ b/packages/app/src/components/file-tree-v2.tsx @@ -16,7 +16,13 @@ import type { FileNode } from "@opencode-ai/sdk/v2" import { Icon } from "@opencode-ai/ui/v2/icon" import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree" import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" -import { buildFileTreeV2Model, flattenFileTreeV2, normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" +import { + buildFileTreeV2Model, + flattenFileTreeV2, + flattenLiveFileTreeV2, + normalizeFileTreeV2Path, + type FileTreeV2Node, +} from "@/components/file-tree-v2-model" import { virtualScrollElement } from "@/components/virtual-scroll-element" export type { Kind } from "@/components/file-tree" @@ -36,7 +42,7 @@ function guideLineLeft(level: number) { export const kindLabel = (kind: Kind) => { if (kind === "add") return "A" if (kind === "del") return "D" - return "" + return "M" } export const kindChange = (kind: Kind) => { @@ -68,7 +74,7 @@ const FileTreeNodeV2 = ( "class", "classList", ]) - const kind = () => local.kinds?.get(local.node.path) + const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path)) return ( - {(_, index) => ( -
- )} + {(_, index) =>
} ) } @@ -126,12 +127,18 @@ export default function FileTreeV2(props: { kinds?: ReadonlyMap draggable?: boolean onFileClick?: (file: FileNode) => void + onFileDoubleClick?: (file: FileNode) => void }) { const file = useFile() + const live = () => props.allowed === undefined const draggable = () => props.draggable ?? true const active = () => normalizeFileTreeV2Path(props.active ?? "") - const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? [])) - const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true)) + const model = createMemo(() => (live() ? undefined : buildFileTreeV2Model(props.allowed ?? []))) + const expanded = (path: string) => file.tree.state(path)?.expanded ?? !live() + const rows = createMemo(() => { + if (live()) return flattenLiveFileTreeV2((path) => file.tree.children(path), expanded) + return flattenFileTreeV2(model()!, expanded) + }) const [root, setRoot] = createSignal() const [focused, setFocused] = createSignal() const virtualizer = createVirtualizer({ @@ -155,16 +162,49 @@ export default function FileTreeV2(props: { return [...indexes, index].sort((a, b) => a - b) }, }) + + createEffect(() => { + if (!live()) return + void file.tree.list("") + }) + + // Only scroll when the active path changes (or first appears in the tree). + // Do not re-scroll when expand/collapse reshuffles `rows()`. + let scrolledActive: string | undefined createEffect(() => { const path = active() - if (!path) return + if (!path) { + scrolledActive = undefined + return + } const index = rows().findIndex((row) => row.node.path === path) if (index < 0) return + if (scrolledActive === path) return + scrolledActive = path queueMicrotask(() => { - if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return - virtualizer.scrollToIndex(index, { align: "auto" }) + const next = rows().findIndex((row) => row.node.path === path) + if (next < 0) return + if (virtualizer.range && next >= virtualizer.range.startIndex && next <= virtualizer.range.endIndex) return + virtualizer.scrollToIndex(next, { align: "auto" }) }) }) + + const selectFile = (node: FileTreeV2Node, action?: (file: FileNode) => void) => { + action?.({ + ...node, + path: node.originalPath, + absolute: node.originalPath, + }) + } + + const toggleDirectory = (path: string, originalPath: string) => { + if (expanded(path)) { + file.tree.collapse(originalPath) + return + } + file.tree.expand(originalPath, live() ? undefined : { list: false }) + } + const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const))) const virtualItemByKey = createMemo( () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)), @@ -175,7 +215,7 @@ export default function FileTreeV2(props: {
@@ -209,13 +249,8 @@ export default function FileTreeV2(props: { class="relative" onFocus={() => setFocused(row().node.path)} onBlur={() => setFocused(undefined)} - onClick={() => - props.onFileClick?.({ - ...row().node, - path: row().node.originalPath, - absolute: row().node.originalPath, - }) - } + onClick={() => selectFile(row().node, props.onFileClick)} + onDblClick={() => selectFile(row().node, props.onFileDoubleClick)} > 0}> @@ -239,17 +274,13 @@ export default function FileTreeV2(props: { class="relative" onFocus={() => setFocused(row().node.path)} onBlur={() => setFocused(undefined)} - aria-expanded={file.tree.state(row().node.path)?.expanded ?? true} - onClick={() => - file.tree.state(row().node.path)?.expanded === false - ? file.tree.expand(row().node.path, { list: false }) - : file.tree.collapse(row().node.path) - } + aria-expanded={expanded(row().node.path)} + onClick={() => toggleDirectory(row().node.path, row().node.originalPath)} >
diff --git a/packages/app/src/components/help-button.tsx b/packages/app/src/components/help-button.tsx index dfc978d432..1747602a54 100644 --- a/packages/app/src/components/help-button.tsx +++ b/packages/app/src/components/help-button.tsx @@ -24,8 +24,6 @@ const triggerClass = const showPopover = () => true export function HelpButton() { - if (import.meta.env.VITE_CODEV_CHANNEL !== "dev") return null - const platform = usePlatform() return ( diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 45310b7fa7..ebc0148042 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -19,6 +19,7 @@ import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/f import { ContentPart, DEFAULT_PROMPT, + isCommentItem, isPromptEqual, Prompt, usePrompt, @@ -36,6 +37,8 @@ import { Icon } from "@opencode-ai/ui/icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" @@ -631,7 +634,9 @@ export const PromptInput: Component = (props) => { const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 const handleBlur = () => { - savedCursor = currentCursor() + const cursor = currentCursor() + savedCursor = cursor + if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor) closePopover() setComposing(false) } @@ -1319,6 +1324,7 @@ export const PromptInput: Component = (props) => { onQueue: props.onQueue, onAbort: props.onAbort, onSubmit: props.onSubmit, + model: props.controls.model.selection, }) const handleKeyDown = (event: KeyboardEvent) => { @@ -1609,7 +1615,7 @@ export const PromptInput: Component = (props) => { )} /> !isCommentItem(item))} active={(item) => { const active = comments.active() return !!item.commentID && item.commentID === active?.id && item.path === active?.file @@ -1619,6 +1625,7 @@ export const PromptInput: Component = (props) => { if (item.commentID) comments.remove(item.path, item.commentID) prompt.context.remove(item.key) }} + newLayoutDesigns={props.controls.newLayoutDesigns} t={(key) => language.t(key as Parameters[0])} /> = (props) => { } onRemove={removeAttachment} removeLabel={language.t("prompt.attachment.remove")} + newLayoutDesigns={props.controls.newLayoutDesigns} + comments={contextItems().filter(isCommentItem)} + commentActive={(item) => { + const active = comments.active() + return !!item.commentID && item.commentID === active?.id && item.path === active?.file + }} + onOpenComment={openComment} + onRemoveComment={(item) => { + if (item.commentID) comments.remove(item.path, item.commentID) + prompt.context.remove(item.key) + }} />
= (props) => { > } + variant="ghost-muted" + size="large" style={buttons()} disabled={store.mode !== "normal"} tabIndex={store.mode === "normal" ? undefined : -1} aria-label={language.t("prompt.menu.addImagesAndFiles")} /> - + {language.t("prompt.menu.imagesAndFiles")} @@ -1838,6 +1853,7 @@ export const PromptInput: Component = (props) => { if (item.commentID) comments.remove(item.path, item.commentID) prompt.context.remove(item.key) }} + newLayoutDesigns={props.controls.newLayoutDesigns} t={(key) => language.t(key as Parameters[0])} /> = (props) => { } onRemove={removeAttachment} removeLabel={language.t("prompt.attachment.remove")} + newLayoutDesigns={props.controls.newLayoutDesigns} />
-
- -
+
} > - setSearch(event.currentTarget.value)} + /> + +
+ 0} + fallback={ +
+ No models found + Try another search. +
+ } + > + + {(model) => ( + + )} + +
+
+
+ +
+ ) +} + +function CompareModelDetail(props: { model: ModelCatalogEntry }) { + return ( + + ) +} + +function CompareModelFact(props: { label: string; value: string; href?: string }) { + return ( +
+
{props.label}
+
+ + {(href) => {props.value}} + +
+
+ ) +} + +function ComparisonDetailMatrix(props: { + sections: ComparisonDetailSection[] + highlightBest: boolean + modelCount: number +}) { + return ( +
+
+ + {(section) => ( +
+ +
+ {section.title} + {(badge) => {badge()}} +
+ + {(_, index) => ( +
+ )} +
+
+
+ ) +} + +function ComparisonDetailSpacer(props: { modelCount: number }) { + return ( + <> +