chore: merge dev into v2 (#36770)

Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot] 2026-07-14 09:58:18 -05:00 committed by GitHub
parent 26e27c7a7f
commit 634386fe0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
116 changed files with 3797 additions and 1049 deletions

View file

@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
const TEAM = {
tui: ["kommander", "simonklee"],
desktop_web: ["Hona", "Brendonovich"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
inference: ["fwang", "MrMushrooooom", "starptech"],
windows: ["Hona"],
} as const

130
bun.lock
View file

@ -32,7 +32,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@ -157,7 +157,7 @@
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@ -171,7 +171,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@ -207,7 +207,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@ -234,7 +234,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@ -256,7 +256,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@ -280,7 +280,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@ -300,7 +300,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.17.18",
"version": "1.17.20",
"bin": {
"opencode": "./bin/opencode",
},
@ -308,7 +308,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",
@ -317,7 +317,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",
@ -354,7 +354,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",
@ -396,7 +396,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@ -456,7 +456,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@ -470,7 +470,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"effect": "catalog:",
},
@ -482,7 +482,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@ -514,7 +514,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@ -530,7 +530,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-beta.83",
},
@ -562,7 +562,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
@ -581,7 +581,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.17.18",
"version": "1.17.20",
"bin": {
"opencode": "./bin/opencode",
},
@ -592,7 +592,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",
@ -601,7 +601,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",
@ -656,7 +656,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",
@ -714,7 +714,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/client": "workspace:*",
@ -801,7 +801,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"cross-spawn": "catalog:",
},
@ -816,7 +816,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
@ -833,7 +833,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@ -894,7 +894,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@ -907,9 +907,10 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@opencode-ai/stats-core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
@ -940,7 +941,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@ -959,7 +960,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@ -1000,7 +1001,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
@ -1030,7 +1031,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@ -1081,7 +1082,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@ -1152,6 +1153,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",
@ -1159,10 +1161,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": {
@ -1198,7 +1198,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",
@ -1218,7 +1218,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",
@ -1260,7 +1260,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=="],
@ -1270,7 +1270,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=="],
@ -3122,7 +3122,7 @@
"@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/start-client-core": ["@tanstack/start-client-core@1.170.13", "", { "dependencies": { "@tanstack/router-core": "1.171.14", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-storage-context": "1.167.16", "seroval": "^1.5.4" } }, "sha512-o37M3msIK5ec87kPrIYJWXb1XPnjIe5/jrkGLXiXpFuVL99z7mhoBCzftKtVPtzqI8EElnRE/VGFYT9BHNnWcw=="],
@ -3136,7 +3136,7 @@
"@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
"@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=="],
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="],
@ -4344,7 +4344,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=="],
@ -4854,7 +4854,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=="],
@ -6340,7 +6340,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=="],
@ -6352,9 +6356,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=="],
@ -6844,7 +6848,7 @@
"@opencode-ai/console-mail/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="],
"@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=="],
@ -6884,6 +6888,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=="],
@ -7230,6 +7236,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=="],
@ -7534,7 +7544,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=="],
@ -8226,6 +8236,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=="],
@ -8470,6 +8484,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=="],
@ -8782,6 +8804,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=="],
@ -9116,6 +9142,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=="],
"@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
@ -9302,6 +9330,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=="],
@ -9402,6 +9434,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=="],

View file

@ -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-N4zM1zNufSg8DrDWOHWJYgVpn6vDghX/CJ0pym9ItxI=",
"aarch64-linux": "sha256-Votrb6IbVt6OS5pcAlBd3L2btkZHa62Eu3mAFzKSlGM=",
"aarch64-darwin": "sha256-Ofmy6plO4CFt/DoVdyt3Sr2rk6VJhas4zXq3DnvP/6A=",
"x86_64-darwin": "sha256-LOeqfqlPbhp1c0Gq56fvKSzve7dvcCwlooTmDMFMznw="
}
}

View file

@ -49,7 +49,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",
@ -76,7 +76,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",
@ -161,10 +161,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"
}
}

View file

@ -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 },
)
}

View file

@ -91,15 +91,17 @@ test("opens and searches project files inline", async ({ page }) => {
const panel = page.locator("#review-panel")
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
const contextButton = page.getByRole("button", { name: "View context usage" })
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeDisabled()
await expect(sidebar).toBeVisible()
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await expect(sidebar).toHaveCount(0)
await expect(sidebar).toBeHidden()
await panel.getByRole("button", { name: "Open file" }).click()
const filter = panel.getByRole("combobox", { name: "Filter files" })
await expect(filter).toBeFocused()
@ -108,6 +110,7 @@ test("opens and searches project files inline", async ({ page }) => {
await panel.getByRole("button", { name: "README.md" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(sidebar).toHaveCount(0)
@ -122,12 +125,17 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
await filter.press("Enter")
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeDisabled()
await panel.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", "")

View file

@ -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"]')

View file

@ -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")

View file

@ -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: {

View file

@ -150,7 +150,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))

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.17.18",
"version": "1.17.20",
"description": "",
"type": "module",
"exports": {

View file

@ -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 (
<Dialog fit>
<form onSubmit={model.submit} class="contents">
<DialogHeader>
<DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody class="flex max-h-[min(560px,calc(100vh-160px))] w-full flex-col gap-6 overflow-y-auto px-4 pt-4 pb-1">
<Field>
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
<TextInputV2
autofocus
appearance="large"
class="!w-full"
value={model.store.name}
placeholder={model.folderName()}
onInput={(event) => model.setStore("name", event.currentTarget.value)}
/>
</Field>
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.icon")}
</div>
<div class="flex items-center gap-3">
<button
type="button"
aria-label={language.t("dialog.project.edit.icon.alt")}
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
classList={{
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
}}
onMouseEnter={() => model.setStore("iconHover", true)}
onMouseLeave={() => model.setStore("iconHover", false)}
onDrop={model.drop}
onDragOver={model.dragOver}
onDragLeave={model.dragLeave}
onClick={model.iconClick}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
src={getProjectAvatarSource(props.project.id, {
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
})}
variant={getProjectAvatarVariant(model.store.color)}
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
/>
<span
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
classList={{
"opacity-100": model.store.iconHover,
"opacity-0": !model.store.iconHover,
}}
>
<Icon name={model.store.iconOverride ? "close" : "outline-share"} />
</span>
</button>
<input
ref={(element) => {
model.setIconInput(element)
}}
type="file"
accept="image/*"
class="hidden"
onChange={model.inputChange}
/>
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
<span>{language.t("dialog.project.edit.icon.hint")}</span>
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
</div>
</div>
</div>
<Show when={!model.store.iconOverride}>
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.color")}
</div>
<div class="-ml-1 flex gap-1.5">
<For each={PROJECT_AVATAR_VARIANTS}>
{(color) => (
<button
type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
classList={{
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
getProjectAvatarVariant(model.store.color) === color,
}}
onClick={() => {
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
model.setStore(
"color",
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
)
}}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
variant={getProjectAvatarVariant(color)}
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
/>
</button>
)}
</For>
</div>
</div>
</Show>
<Field>
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
<TextareaV2
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
rows={3}
value={model.store.startup}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
spellcheck={false}
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
/>
</Field>
</DialogBody>
<DialogFooter>
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 type="submit" variant="contrast" disabled={model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</ButtonV2>
</DialogFooter>
</form>
</Dialog>
)
}

View file

@ -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 (
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6 pt-0">
<form onSubmit={model.submit} class="flex flex-col gap-6 p-6 pt-0">
<div class="flex flex-col gap-4">
<TextField
autofocus
type="text"
label={language.t("dialog.project.edit.name")}
placeholder={folderName()}
value={store.name}
onChange={(v) => setStore("name", v)}
placeholder={model.folderName()}
value={model.store.name}
onChange={(v) => model.setStore("name", v)}
/>
<div class="flex flex-col gap-2">
@ -125,38 +34,32 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<div class="flex gap-3 items-start">
<div
class="relative"
onMouseEnter={() => setStore("iconHover", true)}
onMouseLeave={() => setStore("iconHover", false)}
onMouseEnter={() => model.setStore("iconHover", true)}
onMouseLeave={() => model.setStore("iconHover", false)}
>
<div
class="relative size-16 rounded-md transition-colors cursor-pointer"
classList={{
"border-text-interactive-base bg-surface-info-base/20": store.dragOver,
"border-border-base hover:border-border-strong": !store.dragOver,
"overflow-hidden": !!store.iconOverride,
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => {
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}
>
<Show
when={getProjectAvatarSource(props.project.id, {
color: store.color,
color: model.store.color,
url: props.project.icon?.url,
override: store.iconOverride,
override: model.store.iconOverride,
})}
fallback={
<div class="size-full flex items-center justify-center">
<Avatar
fallback={store.name || defaultName()}
{...getAvatarColors(store.color)}
fallback={model.store.name || model.defaultName()}
{...getAvatarColors(model.store.color)}
class="size-full text-[32px]"
/>
</div>
@ -174,8 +77,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<div
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
classList={{
"opacity-100": store.iconHover && !store.iconOverride,
"opacity-0": !(store.iconHover && !store.iconOverride),
"opacity-100": model.store.iconHover && !model.store.iconOverride,
"opacity-0": !(model.store.iconHover && !model.store.iconOverride),
}}
>
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
@ -183,8 +86,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<div
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
classList={{
"opacity-100": store.iconHover && !!store.iconOverride,
"opacity-0": !(store.iconHover && !!store.iconOverride),
"opacity-100": model.store.iconHover && !!model.store.iconOverride,
"opacity-0": !(model.store.iconHover && !!model.store.iconOverride),
}}
>
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
@ -193,12 +96,12 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<input
id="icon-upload"
ref={(el) => {
iconInput = el
model.setIconInput(el)
}}
type="file"
accept="image/*"
class="hidden"
onChange={handleInputChange}
onChange={model.inputChange}
/>
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
<span>{language.t("dialog.project.edit.icon.hint")}</span>
@ -207,7 +110,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
</div>
</div>
<Show when={!store.iconOverride}>
<Show when={!model.store.iconOverride}>
<div class="flex flex-col gap-2">
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
<div class="flex gap-1.5">
@ -216,21 +119,21 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<button
type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={store.color === color}
aria-pressed={model.store.color === color}
classList={{
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
store.color === color,
model.store.color === color,
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
store.color !== color,
model.store.color !== color,
}}
onClick={() => {
if (store.color === color && !props.project.icon?.url) return
setStore("color", store.color === color ? undefined : color)
if (model.store.color === color && !props.project.icon?.url) return
model.setStore("color", model.store.color === color ? undefined : color)
}}
>
<Avatar
fallback={store.name || defaultName()}
fallback={model.store.name || model.defaultName()}
{...getAvatarColors(color)}
class="size-full rounded"
/>
@ -246,19 +149,19 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
label={language.t("dialog.project.edit.worktree.startup")}
description={language.t("dialog.project.edit.worktree.startup.description")}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
value={store.startup}
onChange={(v) => setStore("startup", v)}
value={model.store.startup}
onChange={(v) => model.setStore("startup", v)}
spellcheck={false}
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
/>
</div>
<div class="flex justify-end gap-2">
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
<Button type="button" variant="ghost" size="large" onClick={model.close}>
{language.t("common.cancel")}
</Button>
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
<Button type="submit" variant="primary" size="large" disabled={model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</Button>
</div>
</form>

View file

@ -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
},
}
}

View file

@ -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<string, FileNode[]> = {
"": [
{ 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],
])
})
})

View file

@ -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,
}
}

View file

@ -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 (
<Dynamic
@ -110,12 +116,7 @@ const FileTreeNodeV2 = (
function GuideLines(props: { level: number }) {
return (
<For each={Array.from({ length: props.level })}>
{(_, index) => (
<div
class="absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 group-hover/file-tree-v2:opacity-50"
style={`left: ${guideLineLeft(index())}px`}
/>
)}
{(_, index) => <div data-slot="file-tree-v2-guide" style={`left: ${guideLineLeft(index())}px`} />}
</For>
)
}
@ -126,12 +127,18 @@ export default function FileTreeV2(props: {
kinds?: ReadonlyMap<string, Kind>
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<HTMLDivElement>()
const [focused, setFocused] = createSignal<string>()
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
@ -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: {
<div
ref={setRoot}
data-component="file-tree-v2"
data-total-rows={model().total}
data-total-rows={live() ? rows().length : model()!.total}
class="group/file-tree-v2"
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
>
@ -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)}
>
<GuideLines level={row().level} />
<Show when={row().level > 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)}
>
<GuideLines level={row().level} />
<div
data-slot="file-tree-v2-chevron"
data-expanded={file.tree.state(row().node.path)?.expanded === false ? undefined : ""}
data-expanded={expanded(row().node.path) ? "" : undefined}
class="size-4 flex items-center justify-center"
>
<Icon name="chevron-down" />

View file

@ -19,6 +19,7 @@ import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/f
import {
ContentPart,
DEFAULT_PROMPT,
isCommentItem,
isPromptEqual,
Prompt,
usePrompt,
@ -632,7 +633,9 @@ export const PromptInput: Component<PromptInputProps> = (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)
}
@ -1626,7 +1629,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)}
/>
<PromptContextItems
items={contextItems()}
items={contextItems().filter((item) => !isCommentItem(item))}
active={(item) => {
const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
@ -1636,6 +1639,7 @@ export const PromptInput: Component<PromptInputProps> = (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<typeof language.t>[0])}
/>
<PromptImageAttachments
@ -1645,6 +1649,17 @@ export const PromptInput: Component<PromptInputProps> = (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)
}}
/>
<div
class="relative min-h-[52px]"
@ -1852,6 +1867,7 @@ export const PromptInput: Component<PromptInputProps> = (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<typeof language.t>[0])}
/>
<PromptImageAttachments
@ -1861,6 +1877,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
newLayoutDesigns={props.controls.newLayoutDesigns}
/>
<div
class="relative"
@ -2341,7 +2358,7 @@ function ModelControlContent(props: { state: ComposerModelControlState; v2?: boo
/>
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<span class="truncate leading-4">{props.state.modelName}</span>
<span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}>
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span>

View file

@ -1,6 +1,8 @@
import { Component, For, Show } from "solid-js"
import { Dynamic } from "solid-js/web"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path"
import type { ContextItem } from "@/context/prompt"
@ -12,6 +14,7 @@ type ContextItemsProps = {
active: (item: PromptContextItem) => boolean
openComment: (item: PromptContextItem) => void
remove: (item: PromptContextItem) => void
newLayoutDesigns: boolean
t: (key: string) => string
}
@ -27,10 +30,17 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
const selected = props.active(item)
return (
<TooltipV2
<Dynamic
component={props.newLayoutDesigns ? TooltipV2 : Tooltip}
value={
<span class="flex max-w-[300px]">
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
<span
classList={{
"truncate-start [unicode-bidi:plaintext] min-w-0": true,
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-invert-base": !props.newLayoutDesigns,
}}
>
{directory}
</span>
<span class="shrink-0">{filename}</span>
@ -78,7 +88,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
</Show>
</div>
</TooltipV2>
</Dynamic>
)
}}
</For>

View file

@ -0,0 +1,43 @@
@keyframes prompt-attachments-fade-left {
from {
visibility: hidden;
}
to {
visibility: visible;
}
}
@keyframes prompt-attachments-fade-right {
from {
visibility: visible;
}
to {
visibility: hidden;
}
}
[data-slot="prompt-attachments"] {
timeline-scope: --prompt-attachments-scroll;
[data-slot^="prompt-attachments-fade-"] {
visibility: hidden;
}
}
@supports (animation-timeline: --prompt-attachments-scroll) and (timeline-scope: --prompt-attachments-scroll) {
[data-slot="prompt-attachments-scroll"] {
scroll-timeline: --prompt-attachments-scroll x;
}
[data-slot="prompt-attachments-fade-left"] {
animation: prompt-attachments-fade-left linear both;
animation-timeline: --prompt-attachments-scroll;
animation-range: 0 0.1px;
}
[data-slot="prompt-attachments-fade-right"] {
animation: prompt-attachments-fade-right linear both;
animation-timeline: --prompt-attachments-scroll;
animation-range: calc(100% - 1.1px) calc(100% - 1px);
}
}

View file

@ -1,60 +1,167 @@
import { Component, For, Show } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import type { ImageAttachmentPart } from "@/context/prompt"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { AttachmentCardV2 } from "@opencode-ai/session-ui/v2/attachment-card-v2"
import { CommentCardV2 } from "@opencode-ai/session-ui/v2/comment-card-v2"
import { typeLabel } from "@opencode-ai/session-ui/message-file"
import type { ContextItem, ImageAttachmentPart } from "@/context/prompt"
import "./image-attachments.css"
type PromptCommentItem = ContextItem & { key: string }
type PromptImageAttachmentsProps = {
attachments: ImageAttachmentPart[]
onOpen: (attachment: ImageAttachmentPart) => void
onRemove: (id: string) => void
removeLabel: string
newLayoutDesigns: boolean
comments?: PromptCommentItem[]
commentActive?: (item: PromptCommentItem) => boolean
onOpenComment?: (item: PromptCommentItem) => void
onRemoveComment?: (item: PromptCommentItem) => void
}
const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
const imageClass =
"size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
const imageClassV2 = "w-[58px] h-[46px] rounded-[6px] object-cover"
// inset box-shadows do not paint over <img> content, so the hairline is a separate overlay
const imageHairlineClassV2 =
"absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none"
const removeClass =
"absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
const removeClassV2 =
"absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
return (
<Show when={props.attachments.length > 0}>
<div class="flex flex-wrap gap-2 px-3 pt-3">
<For each={props.attachments}>
{(attachment) => (
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
<div class="relative group">
<Show when={props.attachments.length > 0 || (props.newLayoutDesigns && (props.comments?.length ?? 0) > 0)}>
<div data-slot="prompt-attachments" classList={{ relative: props.newLayoutDesigns }}>
<div
data-slot="prompt-attachments-scroll"
classList={{
"flex gap-2": true,
"flex-nowrap overflow-x-auto no-scrollbar px-2 pt-2 pb-1": props.newLayoutDesigns,
"flex-wrap px-3 pt-3": !props.newLayoutDesigns,
}}
>
<Show when={props.newLayoutDesigns}>
<For each={props.comments ?? []}>
{(item) => (
<div class="relative group shrink-0">
<TooltipV2
value={item.comment}
placement="top"
openDelay={800}
contentClass="max-w-[300px] break-words"
>
<CommentCardV2
comment={item.comment ?? ""}
path={item.path}
selection={item.selection}
active={props.commentActive?.(item)}
onClick={() => props.onOpenComment?.(item)}
/>
</TooltipV2>
<button
type="button"
onClick={() => props.onRemoveComment?.(item)}
class={removeClassV2}
aria-label={props.removeLabel}
>
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
</button>
</div>
)}
</For>
</Show>
<For each={props.attachments}>
{(attachment) => {
const image = attachment.mime.startsWith("image/")
const media = () => (
<Show
when={attachment.mime.startsWith("image/")}
when={image}
fallback={
<div class={fallbackClass}>
<Icon name="folder" class="size-6 text-text-weak" />
</div>
<Show
when={props.newLayoutDesigns}
fallback={
<div class={fallbackClass}>
<Icon name="folder" class="size-6 text-text-weak" />
</div>
}
>
<AttachmentCardV2 title={attachment.filename}>
{typeLabel(attachment.filename, attachment.mime)}
</AttachmentCardV2>
</Show>
}
>
<img
src={attachment.dataUrl}
alt={attachment.filename}
class={imageClass}
class={props.newLayoutDesigns ? imageClassV2 : imageClass}
onClick={() => props.onOpen(attachment)}
/>
</Show>
<button
type="button"
onClick={() => props.onRemove(attachment.id)}
class={removeClass}
aria-label={props.removeLabel}
>
<Icon name="close" class="size-3 text-text-weak" />
</button>
)
const name = () => (
<div class={nameClass}>
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
</div>
</div>
</Tooltip>
)}
</For>
)
const remove = () => (
<button
type="button"
onClick={() => props.onRemove(attachment.id)}
class={props.newLayoutDesigns ? removeClassV2 : removeClass}
aria-label={props.removeLabel}
>
<Show when={props.newLayoutDesigns} fallback={<Icon name="close" class="size-3 text-text-weak" />}>
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
</Show>
</button>
)
// v2 keeps the remove button outside the tooltip trigger so hovering it dismisses the tooltip
return (
<Show
when={props.newLayoutDesigns}
fallback={
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
<div class="relative group">
{media()}
{name()}
{remove()}
</div>
</Tooltip>
}
>
<div class="relative group shrink-0">
<TooltipV2 value={attachment.filename} placement="top" contentClass="break-all">
{media()}
<Show when={image}>
<div class={imageHairlineClassV2} />
</Show>
</TooltipV2>
{remove()}
</div>
</Show>
)
}}
</For>
</div>
<Show when={props.newLayoutDesigns}>
<div
data-slot="prompt-attachments-fade-left"
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)]"
/>
<div
data-slot="prompt-attachments-fade-right"
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)]"
/>
</Show>
</div>
</Show>
)

View file

@ -1,5 +1,6 @@
import { For, Show } from "solid-js"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { getFilename } from "@opencode-ai/core/util/path"
@ -96,10 +97,17 @@ export function PromptWorkspaceSelector(props: {
{(branch) => (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{branch()}</span>
</div>
<TooltipV2
placement="top"
value={branch()}
class="min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{branch()}</span>
</div>
</TooltipV2>
</>
)}
</Show>

View file

@ -1,6 +1,7 @@
export { SessionHeader } from "./session-header"
export { SessionContextTab } from "./session-context-tab"
export { SortableTab, FileVisual } from "./session-sortable-tab"
export { SortableTabV2 } from "./session-sortable-tab-v2"
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view"
export { NewSessionDesignView } from "./session-new-design-view"

View file

@ -0,0 +1,98 @@
import { For, Show } from "solid-js"
import { AppIcon } from "@opencode-ai/ui/app-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { SplitButtonV2, SplitButtonV2Action, SplitButtonV2MenuTrigger } from "@opencode-ai/ui/v2/split-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useLanguage } from "@/context/language"
import { type OpenApp, useOpenInApp } from "@/components/session/open-in-app"
export function OpenInAppV2(props: { directory: () => string }) {
const language = useLanguage()
const state = useOpenInApp(props)
return (
<Show when={props.directory() && state.canOpen()}>
<SplitButtonV2 class="session-review-v2-open-in-app" onPointerDown={(event) => event.stopPropagation()}>
<TooltipV2
placement="bottom"
value={language.t("session.header.open.ariaLabel", { app: state.current().label })}
class="flex items-center"
>
<SplitButtonV2Action
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation()
if (state.opening()) return
state.openDir(state.current().id)
}}
disabled={state.opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })}
>
<Show when={state.opening()} fallback={<AppIcon id={state.current().icon} class="size-[18px]" />}>
<Spinner class="size-3.5" />
</Show>
</SplitButtonV2Action>
</TooltipV2>
<MenuV2
gutter={4}
modal={false}
placement="bottom-end"
open={state.menu.open}
onOpenChange={(open) => state.setMenu("open", open)}
>
<MenuV2.Trigger
as={SplitButtonV2MenuTrigger}
disabled={state.opening()}
aria-label={language.t("session.header.open.menu")}
onPointerDown={(event) => event.stopPropagation()}
>
<IconV2 name="chevron-down" size="small" />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class="open-in-app-v2-menu">
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("session.header.openIn")}</MenuV2.GroupLabel>
<MenuV2.RadioGroup
value={state.current().id}
onChange={(value) => {
state.selectApp(value as OpenApp)
}}
>
<For each={state.options()}>
{(option) => (
<MenuV2.RadioItem
value={option.id}
disabled={state.opening()}
onSelect={() => {
state.selectApp(option.id)
state.setMenu("open", false)
state.openDir(option.id)
}}
>
<AppIcon id={option.icon} />
{option.label}
</MenuV2.RadioItem>
)}
</For>
</MenuV2.RadioGroup>
</MenuV2.Group>
<MenuV2.Separator />
<MenuV2.Item
onSelect={() => {
state.setMenu("open", false)
state.copyPath()
}}
>
<Icon name="copy" size="small" class="text-icon-weak" />
{language.t("session.header.open.copyPath")}
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</SplitButtonV2>
</Show>
)
}

View file

@ -0,0 +1,229 @@
import { createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { Persist, persisted } from "@/utils/persist"
import { showToast } from "@/utils/toast"
export const OPEN_APPS = [
"vscode",
"cursor",
"zed",
"textmate",
"antigravity",
"finder",
"terminal",
"iterm2",
"ghostty",
"warp",
"xcode",
"android-studio",
"powershell",
"sublime-text",
] as const
export type OpenApp = (typeof OPEN_APPS)[number]
export type OpenAppOS = "macos" | "windows" | "linux" | "unknown"
export const MAC_OPEN_APPS = [
{
id: "vscode",
label: "session.header.open.app.vscode",
icon: "vscode",
openWith: "Visual Studio Code",
},
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" },
{ id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" },
{
id: "antigravity",
label: "session.header.open.app.antigravity",
icon: "antigravity",
openWith: "Antigravity",
},
{ id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" },
{ id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" },
{ id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" },
{ id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" },
{ id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" },
{
id: "android-studio",
label: "session.header.open.app.androidStudio",
icon: "android-studio",
openWith: "Android Studio",
},
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
export const WINDOWS_OPEN_APPS = [
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
{
id: "powershell",
label: "session.header.open.app.powershell",
icon: "powershell",
openWith: "powershell",
},
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
export const LINUX_OPEN_APPS = [
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
export function detectOpenAppOS(platform: ReturnType<typeof usePlatform>): OpenAppOS {
if (platform.platform === "desktop" && platform.os) return platform.os
if (typeof navigator !== "object") return "unknown"
const value = navigator.platform || navigator.userAgent
if (/Mac/i.test(value)) return "macos"
if (/Win/i.test(value)) return "windows"
if (/Linux/i.test(value)) return "linux"
return "unknown"
}
export function openAppFileManager(os: OpenAppOS) {
if (os === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
if (os === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
return { label: "session.header.open.fileManager", icon: "finder" as const }
}
export function openAppsForOS(os: OpenAppOS) {
if (os === "macos") return MAC_OPEN_APPS
if (os === "windows") return WINDOWS_OPEN_APPS
return LINUX_OPEN_APPS
}
const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
export function useOpenInApp(input: { directory: () => string }) {
const platform = usePlatform()
const server = useServer()
const language = useLanguage()
const os = createMemo(() => detectOpenAppOS(platform))
const apps = createMemo(() => openAppsForOS(os()))
const fileManager = createMemo(() => openAppFileManager(os()))
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
})
createEffect(() => {
if (platform.platform !== "desktop") return
if (!platform.checkAppExists) return
const list = apps()
setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
void Promise.all(
list.map((app) =>
Promise.resolve(platform.checkAppExists?.(app.openWith))
.then((value) => Boolean(value))
.catch(() => false)
.then((ok) => [app.id, ok] as const),
),
).then((entries) => {
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
})
})
const options = createMemo(() => {
return [
{ id: "finder", label: language.t(fileManager().label), icon: fileManager().icon },
...apps()
.filter((app) => exists[app.id])
.map((app) => ({ ...app, label: language.t(app.label) })),
] as const
})
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp | "finder" }))
const [menu, setMenu] = createStore({ open: false })
const [openRequest, setOpenRequest] = createStore({
app: undefined as OpenApp | undefined,
})
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(
() =>
options().find((o) => o.id === prefs.app) ??
options()[0] ??
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
)
const opening = createMemo(() => openRequest.app !== undefined)
const selectApp = (app: OpenApp | "finder") => {
if (!options().some((item) => item.id === app)) return
setPrefs("app", app)
}
const openDir = (app: OpenApp | "finder") => {
if (opening() || !canOpen() || !platform.openPath) return
const directory = input.directory()
if (!directory) return
const item = options().find((o) => o.id === app)
const openWith = item && "openWith" in item ? item.openWith : undefined
setOpenRequest("app", app)
platform
.openPath(directory, openWith)
.catch((err: unknown) => showRequestError(language, err))
.finally(() => {
setOpenRequest("app", undefined)
})
}
const copyPath = () => {
const directory = input.directory()
if (!directory) return
navigator.clipboard
.writeText(directory)
.then(() => {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("session.share.copy.copied"),
description: directory,
})
})
.catch((err: unknown) => showRequestError(language, err))
}
return {
canOpen,
opening,
current,
options,
menu,
setMenu,
openDir,
selectApp,
copyPath,
}
}

View file

@ -0,0 +1,66 @@
import { createMemo, Show } from "solid-js"
import type { JSX } from "solid-js"
import { useSortable } from "@dnd-kit/solid/sortable"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
import { FileVisual } from "./session-sortable-tab"
export function SortableTabV2(props: {
tab: string
index: () => number
temporary?: boolean
onTabClose: (tab: string) => void
onTabDoubleClick?: (tab: string) => void
}): JSX.Element {
const file = useFile()
const language = useLanguage()
const command = useCommand()
const sortable = useSortable({
get id() {
return props.tab
},
get index() {
return props.index()
},
})
const path = createMemo(() => file.pathFromTab(props.tab))
const content = createMemo(() => {
const value = path()
if (!value) return
return <FileVisual path={value} temporary={props.temporary} />
})
return (
<div ref={sortable.ref} class="h-full flex items-center">
<div class="relative">
<Tabs.Trigger
value={props.tab}
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => props.onTabClose(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
>
<Show when={content()}>{(value) => value()}</Show>
</Tabs.Trigger>
</div>
</div>
)
}

View file

@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test"
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
describe("serverStatusDotClass", () => {
test("uses the success token while the server and services are healthy", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
})
test("uses the warning token for non-blocking issues while the server is online", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
})
test("uses the critical token only after the server connection drops", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false })).toBe("bg-icon-critical-base")
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: true })).toBe("bg-icon-critical-base")
})
test("stays neutral before status is ready", () => {
expect(serverStatusDotClass({ ready: false, serverHealth: true, issue: false })).toBe("bg-border-weak-base")
expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false })).toBe("bg-border-weak-base")
})
})
describe("hasNonBlockingServiceIssue", () => {
test("detects MCP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false)
})
test("detects LSP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["error"] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
})
})

View file

@ -0,0 +1,19 @@
import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client"
export function hasNonBlockingServiceIssue(input: {
mcp: Array<McpStatus["status"]>
lsp: Array<LspStatus["status"]>
}) {
return (
input.mcp.some((status) => status !== "connected" && status !== "disabled") ||
input.lsp.some((status) => status === "error")
)
}
export function serverStatusDotClass(input: { ready: boolean; serverHealth: boolean | undefined; issue: boolean }) {
if (input.serverHealth === false) return "bg-icon-critical-base"
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
if (input.issue) return "bg-icon-warning-base"
if (input.serverHealth === true) return "bg-icon-success-base"
return "bg-border-weak-base"
}

View file

@ -9,6 +9,7 @@ import { ServerConnection, useServer } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useSync } from "@/context/sync"
import { useGlobal } from "@/context/global"
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
@ -19,16 +20,14 @@ export function StatusPopover() {
const global = useGlobal()
const sync = useSync()
const [shown, setShown] = createSignal(false)
const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync().data.mcp_ready)
const mcpIssue = createMemo(() => {
const mcp = Object.values(sync().data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
const warn = mcp.some((item) => item.status === "needs_auth")
if (failed) return "critical" as const
if (warn) return "warning" as const
})
const serverHealthy = () => global.servers.health[server.key]?.healthy === true
const healthy = createMemo(() => global.servers.health[server.key]?.healthy === true && !mcpIssue())
const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const issue = createMemo(() =>
hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
lsp: (sync().data.lsp ?? []).map((item) => item.status),
}),
)
return (
<Popover
@ -47,13 +46,11 @@ export function StatusPopover() {
<Icon name={shown() ? "status-active" : "status"} size="small" />
</div>
<div
classList={{
"absolute -top-px -right-px size-1.5 rounded-full": true,
"bg-icon-success-base": ready() && healthy(),
"bg-icon-warning-base": ready() && serverHealthy() && mcpIssue() === "warning",
"bg-icon-critical-base": serverHealthy() || (ready() && serverHealthy() && mcpIssue() === "critical"),
"bg-border-weak-base": serverHealthy() || !ready(),
}}
class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
ready: ready(),
serverHealth: serverHealth(),
issue: issue(),
})}`}
/>
</div>
}
@ -87,21 +84,18 @@ function DirectoryStatusPopover() {
const sync = useSync()
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready)
const mcpIssue = createMemo(() => {
const mcp = Object.values(sync().data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
const warn = mcp.some((item) => item.status === "needs_auth")
if (failed) return "critical" as const
if (warn) return "warning" as const
})
const healthy = createMemo(() => serverHealth() === true && !mcpIssue())
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const issue = createMemo(() =>
hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
lsp: (sync().data.lsp ?? []).map((item) => item.status),
}),
)
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: ready(),
healthy: healthy(),
serverHealth: serverHealth(),
issue: mcpIssue(),
issue: issue(),
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
@ -123,8 +117,8 @@ function ServerStatusPopover() {
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: serverHealth() !== undefined,
healthy: serverHealth() === true,
serverHealth: serverHealth(),
issue: false,
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
@ -140,9 +134,8 @@ function ServerStatusPopover() {
type StatusPopoverState = {
shown: boolean
ready: boolean
healthy: boolean
serverHealth: boolean | undefined
issue?: "critical" | "warning"
issue: boolean
label: string
onOpenChange: (value: boolean) => void
body: () => JSX.Element
@ -161,16 +154,6 @@ function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
}
function StatusPopoverView(props: { state: StatusPopoverState }) {
const statusDotClass = () => ({
"absolute rounded-full": true,
"bg-icon-success-base": props.state.ready && props.state.healthy,
"bg-icon-warning-base": props.state.ready && props.state.serverHealth === true && props.state.issue === "warning",
"bg-icon-critical-base":
props.state.serverHealth === false ||
(props.state.ready && props.state.serverHealth === true && props.state.issue === "critical"),
"bg-border-weak-base": props.state.serverHealth === undefined || !props.state.ready,
})
const popoverProps = {
class:
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
@ -195,8 +178,7 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
<div class="relative size-4">
<IconV2 name={props.state.shown ? "status-active" : "status"} />
<div
classList={statusDotClass()}
class="-top-1 -right-1 size-2 border border-[var(--v2-background-bg-deep)]"
class={`absolute -top-1 -right-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
/>
</div>
}

View file

@ -47,12 +47,20 @@ export function getAvatarColors(key?: string) {
}
export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
if (key === "orange") return "orange"
if (key === "pink") return "pink"
if (key === "cyan") return "cyan"
if (key === "purple") return "purple"
if (key === "mint") return "cyan"
if (key === "lime") return "green"
if (
key === "orange" ||
key === "yellow" ||
key === "cyan" ||
key === "green" ||
key === "red" ||
key === "pink" ||
key === "blue" ||
key === "purple" ||
key === "gray"
)
return key
return "gray"
}

View file

@ -37,6 +37,9 @@ type PlatformBase = {
/** Open a local path in a local app (desktop only) */
openPath?(path: string, app?: string): Promise<void>
/** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
revealPath?(path: string): Promise<boolean>
/** Restart the app */
restart(): Promise<void>

View file

@ -145,7 +145,7 @@ function contextItemKey(item: ContextItem) {
return `${key}:c=${digest.slice(0, 8)}`
}
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
return item.type === "file" && !!item.comment?.trim()
}

View file

@ -24,6 +24,7 @@ export {
createPromptSession,
createPromptState,
DEFAULT_PROMPT,
isCommentItem,
isPromptEqual,
} from "./prompt-state"
export type {

View file

@ -638,7 +638,7 @@ export const dict = {
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
"session.error.notFound.closeTab": "Close Tab",
"session.error.serverConnection": "Can't connect to this server",
"session.review.filesChanged": "{{count}} Files Changed",
"session.review.filesChanged": "Files Changed {{count}}",
"session.review.change.one": "Change",
"session.review.change.other": "Changes",
"session.review.loadingChanges": "Loading changes...",

View file

@ -2,11 +2,30 @@ import { describe, expect, test } from "bun:test"
import { shouldOpenSessionInBackground } from "./home-session-open"
describe("shouldOpenSessionInBackground", () => {
test("opens middle clicks in the background", () => {
expect(
shouldOpenSessionInBackground({ button: 1, mac: true, meta: false, ctrl: false, shift: false, alt: false }),
).toBe(true)
expect(
shouldOpenSessionInBackground({ button: 2, mac: true, meta: false, ctrl: false, shift: false, alt: false }),
).toBe(false)
})
test("requires only the platform primary modifier", () => {
expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: false, alt: false })).toBe(true)
expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: false })).toBe(true)
expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: true, alt: false })).toBe(false)
expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: true })).toBe(false)
expect(shouldOpenSessionInBackground({ mac: false, meta: true, ctrl: false, shift: false, alt: false })).toBe(false)
expect(
shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: false, alt: false }),
).toBe(true)
expect(
shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: false }),
).toBe(true)
expect(
shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: true, alt: false }),
).toBe(false)
expect(
shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: true }),
).toBe(false)
expect(
shouldOpenSessionInBackground({ button: 0, mac: false, meta: true, ctrl: false, shift: false, alt: false }),
).toBe(false)
})
})

View file

@ -1,10 +1,13 @@
export function shouldOpenSessionInBackground(input: {
button: number
mac: boolean
meta: boolean
ctrl: boolean
shift: boolean
alt: boolean
}) {
if (input.button === 1) return true
if (input.button !== 0) return false
if (input.shift || input.alt) return false
if (input.mac) return input.meta && !input.ctrl
return input.ctrl && !input.meta

View file

@ -244,6 +244,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
// background without navigating, matching browser conventions.
function isBackgroundOpen(event: MouseEvent) {
return shouldOpenSessionInBackground({
button: event.button,
mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
meta: event.metaKey,
ctrl: event.ctrlKey,

View file

@ -1,4 +1,5 @@
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FilePart, Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
import {
@ -57,6 +58,8 @@ import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs"
import { TerminalProvider, useTerminal } from "@/context/terminal"
import { PromptInput } from "@/components/prompt-input"
import { setCursorPosition } from "@/components/prompt-input/editor-dom"
import { promptLength } from "@/components/prompt-input/history"
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
import {
createPromptInputController,
@ -79,6 +82,7 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { sessionPanelLayout } from "@/pages/session/session-panel-layout"
import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2"
import { SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2"
import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds"
@ -1015,7 +1019,10 @@ export default function Page() {
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
if (composer.blocked() || isChildSession()) return
inputRef?.focus()
const input = inputRef
if (!input) return
input.focus()
setCursorPosition(input, prompt.cursor() ?? promptLength(prompt.current()))
}
}
@ -1867,7 +1874,30 @@ export default function Page() {
.map((item) => ({ id: item.id, text: line(item.id) }))
})
const actions = { revert }
// attachment bytes are embedded as a data URL, so downloading always works;
// revealing requires the on-disk path captured by the client that attached the file
const openAttachment = (file: FilePart) => {
const download = () => {
const anchor = document.createElement("a")
anchor.href = file.url
anchor.download = getFilename(file.filename) || "attachment"
anchor.click()
}
const path = file.filename ?? ""
const absolute = path.startsWith("/") || path.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(path)
if (platform.revealPath && absolute) {
void platform.revealPath(path).then(
(revealed) => {
if (!revealed) download()
},
() => download(),
)
return
}
download()
}
const actions = { revert, openAttachment }
createEffect(() => {
const sessionID = params.id
@ -2232,6 +2262,13 @@ export default function Page() {
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
reviewCount={reviewCount}
reviewPanel={reviewPanelV2}
reviewSidebarToggle={(disabled) => (
<SessionReviewV2SidebarToggle
opened={reviewV2State.sidebarOpened()}
disabled={disabled}
onToggle={reviewV2State.toggleSidebar}
/>
)}
fileBrowserState={reviewV2State}
activeDiff={activeReviewFile()}
focusReviewDiff={focusReviewDiff}

View file

@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js"
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
@ -6,9 +6,12 @@ import type { FileSearchHandle } from "@opencode-ai/session-ui/file"
import { useFileComponent } from "@opencode-ai/ui/context/file"
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { createLineCommentController } from "@opencode-ai/session-ui/line-comment-annotations"
import { createLineCommentControllerV2 } from "@opencode-ai/session-ui/v2/line-comment-annotations-v2"
import { sampledChecksum } from "@opencode-ai/core/util/encode"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { showToast } from "@/utils/toast"
@ -16,6 +19,7 @@ import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange
import { useComments } from "@/context/comments"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
import { useSettings } from "@/context/settings"
import { getSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
@ -53,6 +57,30 @@ function FileCommentMenu(props: {
)
}
function FileCommentMenuV2(props: {
moreLabel: string
editLabel: string
deleteLabel: string
onEdit: VoidFunction
onDelete: VoidFunction
}) {
return (
<div onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
<MenuV2 gutter={4}>
<MenuV2.Trigger as="button" type="button" data-slot="line-comment-v2-overflow" aria-label={props.moreLabel}>
<LineCommentV2OverflowIcon />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={props.onEdit}>{props.editLabel}</MenuV2.Item>
<MenuV2.Item onSelect={props.onDelete}>{props.deleteLabel}</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</div>
)
}
type ScrollPos = { x: number; y: number }
function createScrollSync(input: { tab: () => string; view: ReturnType<typeof useSessionLayout>["view"] }) {
@ -180,6 +208,15 @@ export function FileTabContent(props: { tab: string }) {
}
export function SessionFileView(props: { tab: string }) {
const settings = useSettings()
return (
<Show when={settings.general.newLayoutDesigns()} fallback={<SessionFileViewV1 tab={props.tab} />}>
<SessionFileViewV2 tab={props.tab} />
</Show>
)
}
function SessionFileViewV1(props: { tab: string }) {
const file = useFile()
const comments = useComments()
const language = useLanguage()
@ -463,3 +500,294 @@ export function SessionFileView(props: { tab: string }) {
return content()
}
function SessionFileViewV2(props: { tab: string }) {
const file = useFile()
const comments = useComments()
const language = useLanguage()
const prompt = usePrompt()
const fileComponent = useFileComponent()
const { sessionKey, tabs, view } = useSessionLayout()
const activeFileTab = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
}).activeFileTab
let find: FileSearchHandle | null = null
const search = {
register: (handle: FileSearchHandle | null) => {
find = handle
},
}
const path = createMemo(() => file.pathFromTab(props.tab))
const state = createMemo(() => {
const p = path()
if (!p) return
return file.get(p)
})
const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => sampledChecksum(contents()))
const selectedLines = createMemo<SelectedLineRange | null>(() => {
const p = path()
if (!p) return null
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
})
const scrollSync = createScrollSync({
tab: () => props.tab,
view,
})
const selectionPreview = (source: string, selection: FileSelection) => {
return previewSelectedLines(source, {
start: selection.startLine,
end: selection.endLine,
})
}
const buildPreview = (filePath: string, selection: FileSelection) => {
const source = filePath === path() ? contents() : file.get(filePath)?.content?.content
if (!source) return undefined
return selectionPreview(source, selection)
}
const addCommentToContext = (input: {
file: string
selection: SelectedLineRange
comment: string
preview?: string
origin?: "review" | "file"
}) => {
const selection = selectionFromLines(input.selection)
const preview = input.preview ?? buildPreview(input.file, selection)
const saved = comments.add({
file: input.file,
selection: input.selection,
comment: input.comment,
})
prompt.context.add({
type: "file",
path: input.file,
selection,
comment: input.comment,
commentID: saved.id,
commentOrigin: input.origin,
preview,
})
}
const updateCommentInContext = (input: {
id: string
file: string
selection: SelectedLineRange
comment: string
}) => {
comments.update(input.file, input.id, input.comment)
const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined
prompt.context.updateComment(input.file, input.id, {
comment: input.comment,
...(preview ? { preview } : {}),
})
}
const removeCommentFromContext = (input: { id: string; file: string }) => {
comments.remove(input.file, input.id)
prompt.context.removeComment(input.file, input.id)
}
const fileComments = createMemo(() => {
const p = path()
if (!p) return []
return comments.list(p)
})
const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
const [note, setNote] = createStore({
openedComment: null as string | null,
commenting: null as SelectedLineRange | null,
selected: null as SelectedLineRange | null,
})
const syncSelected = (range: SelectedLineRange | null) => {
const p = path()
if (!p) return
file.setSelectedLines(p, range ? cloneSelectedLineRange(range) : null)
}
const activeSelection = () => note.selected ?? selectedLines()
const commentsUi = createLineCommentControllerV2({
comments: fileComments,
label: language.t("ui.lineComment.submit"),
draftKey: () => path() ?? props.tab,
mention: {
items: file.searchFilesAndDirectories,
},
getSide: (range) => range.endSide ?? range.side ?? "additions",
state: {
opened: () => note.openedComment,
setOpened: (id) => setNote("openedComment", id),
selected: () => note.selected,
setSelected: (range) => setNote("selected", range),
commenting: () => note.commenting,
setCommenting: (range) => setNote("commenting", range),
syncSelected,
hoverSelected: syncSelected,
},
onSubmit: ({ comment, selection }) => {
const p = path()
if (!p) return
addCommentToContext({ file: p, selection, comment, origin: "file" })
},
onUpdate: ({ id, comment, selection }) => {
const p = path()
if (!p) return
updateCommentInContext({ id, file: p, selection, comment })
},
onDelete: (comment) => {
const p = path()
if (!p) return
removeCommentFromContext({ id: comment.id, file: p })
},
editSubmitLabel: language.t("common.save"),
renderCommentActions: (_, controls) => (
<FileCommentMenuV2
moreLabel={language.t("common.moreOptions")}
editLabel={language.t("common.edit")}
deleteLabel={language.t("common.delete")}
onEdit={controls.edit}
onDelete={controls.remove}
/>
),
})
createEffect(() => {
if (typeof window === "undefined") return
const onKeyDown = (event: KeyboardEvent) => {
if (activeFileTab() !== props.tab) return
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return
if (event.key.toLowerCase() !== "f") return
event.preventDefault()
event.stopPropagation()
find?.focus()
}
makeEventListener(window, "keydown", onKeyDown, { capture: true })
})
createEffect(
on(
path,
() => {
commentsUi.note.reset()
},
{ defer: true },
),
)
createEffect(() => {
const focus = comments.focus()
const p = path()
if (!focus || !p) return
if (focus.file !== p) return
if (activeFileTab() !== props.tab) return
const target = fileComments().find((comment) => comment.id === focus.id)
if (!target) return
commentsUi.note.openComment(target.id, target.selection, { cancelDraft: true })
requestAnimationFrame(() => comments.clearFocus())
})
let prev = {
loaded: false,
ready: false,
active: false,
}
createEffect(() => {
const loaded = !!state()?.loaded
const ready = file.ready()
const active = activeFileTab() === props.tab
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
prev = { loaded, ready, active }
if (!restore) return
scrollSync.queueRestore()
})
const renderFile = (source: string) => (
<div class="relative overflow-hidden pb-40">
<Dynamic
component={fileComponent}
mode="text"
file={{
name: path() ?? "",
contents: source,
cacheKey: cacheKey(),
}}
enableLineSelection
enableGutterUtility
selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
scrollSync.queueRestore()
}}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
renderGutterUtility={commentsUi.renderGutterUtility}
onLineSelected={(range: SelectedLineRange | null) => {
commentsUi.onLineSelected(range)
}}
onLineSelectionEnd={(range: SelectedLineRange | null) => {
if (!range) {
commentsUi.note.select(null)
commentsUi.note.cancelDraft()
return
}
commentsUi.onLineSelectionEnd(range)
}}
onLineNumberSelectionEnd={(range: SelectedLineRange | null) => {
commentsUi.onLineNumberSelectionEnd(range)
}}
search={search}
class="select-text"
media={{
mode: "auto",
path: path(),
current: state()?.content,
onLoad: scrollSync.queueRestore,
onError: (args: { kind: "image" | "audio" | "svg" }) => {
if (args.kind !== "svg") return
showToast({
variant: "error",
title: language.t("toast.file.loadFailed.title"),
})
},
}}
/>
</div>
)
const content = () => (
<div class="mt-3 relative h-full min-h-0">
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
<Switch>
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
</Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>
</ScrollView>
</div>
)
return content()
}

View file

@ -1,28 +1,46 @@
import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { DragDropProvider as DndKitProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable } from "@dnd-kit/solid/sortable"
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import {
DragDropProvider,
DragDropSensors,
DragOverlay,
SortableProvider,
closestCenter,
type DragEvent,
} from "@thisbeyond/solid-dnd"
import { Tabs } from "@opencode-ai/ui/tabs"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import FileTree from "@/components/file-tree"
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
import { SessionContextUsage } from "@/components/session-context-usage"
const reviewTabID = "session-side-panel-review-tab"
const reviewTabPanelID = "session-side-panel-review-tabpanel"
import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel"
import { SessionContextTab, SortableTab, SortableTabV2, FileVisual } from "@/components/session"
import { OpenInAppV2 } from "@/components/session/open-in-app-v2"
import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
@ -47,6 +65,7 @@ export function SessionSidePanel(props: {
reviewHasFocusableContent: () => boolean
reviewCount: () => number
reviewPanel: () => JSX.Element
reviewSidebarToggle?: (disabled: boolean) => JSX.Element
fileBrowserState?: SessionFileBrowserState
activeDiff?: string
focusReviewDiff: (path: string) => void
@ -60,7 +79,9 @@ export function SessionSidePanel(props: {
const language = useLanguage()
const command = useCommand()
const dialog = useDialog()
const sdk = useSDK()
const { sessionKey, tabs, view, params } = useSessionLayout()
const projectDirectory = createMemo(() => sdk().directory)
const isDesktop = createMediaQuery("(min-width: 768px)")
const shown = settings.visibility.fileTree
@ -92,11 +113,9 @@ export function SessionSidePanel(props: {
return "mix" as const
}
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) {
const file = normalize(diff.file)
const file = normalizeFileTreeV2Path(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
out.set(file, kind)
@ -153,6 +172,7 @@ export function SessionSidePanel(props: {
fileBrowser: () => !!props.fileBrowserState,
})
const contextOpen = tabState.contextOpen
const openFileOpen = tabState.openFileOpen
const panelTabs = tabState.panelTabs
const openedTabs = tabState.openedTabs
const activeTab = tabState.activeTab
@ -170,10 +190,8 @@ export function SessionSidePanel(props: {
layout.fileTree.setTab("all")
}
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
})
let fileFilter: HTMLInputElement | undefined
let tabList: HTMLDivElement | undefined
const temporaryTab = tabs().preview
const previewTab = (value: string) => {
const next = normalizeTab(value)
@ -196,10 +214,26 @@ export function SessionSidePanel(props: {
}
const browserTab = createMemo(() => {
if (!props.fileBrowserState) return undefined
if (activeTab() === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
const active = activeTab()
if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
if (active && file.pathFromTab(active)) return active
return activeFileTab()
})
const browserKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix")))
// Keep the file-browser shell mounted while any file tab exists. Kobalte briefly
// selects Review while the tab For replaces a preview trigger, which would
// otherwise dispose the sidebar and reset scroll.
const fileBrowserMounted = createMemo(() => {
if (!props.fileBrowserState) return false
return openedTabs().length > 0 || openFileOpen() || !!browserTab()
})
const fileBrowserVisible = createMemo(() => {
const active = activeTab()
return active !== "review" && active !== "context" && active !== "empty"
})
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
})
const handleDragStart = (event: unknown) => {
const id = getDraggableId(event)
@ -285,72 +319,283 @@ export function SessionSidePanel(props: {
"bg-background-base": !settings.general.newLayoutDesigns(),
}}
>
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={activeTab()} onChange={activateTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<Show when={reviewTab() && props.canReview()}>
<Tabs.Trigger
value="review"
id={reviewTabID}
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
<Show
when={props.fileBrowserState}
fallback={
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={activeTab()} onChange={activateTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={props.hasReview()}>
<div>{props.reviewCount()}</div>
</Show>
</div>
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<Show when={reviewTab() && props.canReview()}>
<Tabs.Trigger
value="review"
id={reviewTabID}
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
>
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={props.hasReview()}>
<div>{props.reviewCount()}</div>
</Show>
</div>
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={openedTabs()}>
<For each={panelTabs()}>
{(tab) => (
<Show
when={tab === SESSION_OPEN_FILE_TAB}
fallback={
<SortableTab
tab={tab}
temporary={temporaryTab() === tab}
onTabClose={tabs().close}
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
/>
}
>
<Tabs.Trigger
value={SESSION_OPEN_FILE_TAB}
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
>
<div class="flex items-center gap-1.5 italic">
<Icon name="open-file" size="small" />
<span>{language.t("command.file.open")}</span>
</div>
</Tabs.Trigger>
</Show>
)}
</For>
</SortableProvider>
<div
class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}}
>
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
>
<IconButton
icon="close-small"
icon="plus-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
iconSize="large"
class="!rounded-md"
onClick={() => {
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
})
}}
aria-label={language.t("command.file.open")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Tabs.List>
</div>
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
<div
id={reviewTabPanelID}
role="tabpanel"
aria-labelledby={reviewTabID}
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
data-slot="tabs-content"
class="flex flex-col h-full overflow-hidden contain-strict"
>
{props.reviewPanel()}
</div>
</Show>
<SortableProvider ids={openedTabs()}>
<Show when={activeTab() === "empty"}>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
</div>
</div>
</Tabs.Content>
</Show>
<Show when={activeTab() === "context"}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab />
</div>
</Tabs.Content>
</Show>
<Show when={activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />}
</Show>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable} keyed>
{(tab) => {
const path = file.pathFromTab(tab)
return (
<div data-component="tabs-drag-preview">
<Show when={path}>
{(p) => <FileVisual active path={p()} temporary={temporaryTab() === tab} />}
</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
}
>
<DndKitProvider
sensors={[
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) =>
event.target instanceof Element &&
(!!event.target.closest('[data-slot="tabs-trigger-close-button"]') ||
!!event.target.closest(".session-review-v2-open-in-app-slot")),
}),
]}
modifiers={[
RestrictToHorizontalAxis,
RestrictToElement.configure({ element: () => tabList ?? null }),
]}
plugins={(defaults) => [
...defaults.filter((plugin) => plugin !== Accessibility),
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
Feedback.configure({ dropAnimation: null }),
]}
onDragEnd={(event) => {
const source = event.operation.source
if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return
tabs().move(source.id.toString(), source.index)
}}
>
<Tabs value={activeTab()} onChange={activateTab}>
<div class="session-review-v2-tabs-bar sticky top-0 shrink-0 flex items-center">
<Tabs.List
ref={(el: HTMLDivElement) => {
tabList = el
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<Show when={props.reviewSidebarToggle}>
{(toggle) => (
<div class="h-full shrink-0 flex items-center justify-center">
{toggle()(activeTab() === SESSION_OPEN_FILE_TAB)}
</div>
)}
</Show>
<Show when={reviewTab() && props.canReview()}>
<Tabs.Trigger
value="review"
id={reviewTabID}
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
>
{props.hasReview()
? language.t("session.review.filesChanged", { count: props.reviewCount() })
: language.t("session.tab.review")}
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<For each={panelTabs()}>
{(tab) => (
<Show
when={tab === SESSION_OPEN_FILE_TAB}
fallback={
<SortableTab
<SortableTabV2
tab={tab}
index={() => tabs().all().indexOf(tab)}
temporary={temporaryTab() === tab}
onTabClose={tabs().close}
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
@ -386,106 +631,104 @@ export function SessionSidePanel(props: {
</Show>
)}
</For>
</SortableProvider>
<div
class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}}
>
<TooltipKeybind
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
<div
class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}}
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
class="!rounded-md"
onClick={() => {
if (props.fileBrowserState) {
openFileBrowser()
return
}
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
})
}}
aria-label={language.t("command.file.open")}
/>
</TooltipKeybind>
<TooltipV2
value={
<>
{language.t("command.file.open")}
<Show when={openFileKeybind().length > 0}>
<KeybindV2 keys={openFileKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
class="flex items-center"
>
<IconButtonV2
icon={<Icon name="plus-small" />}
variant="ghost-muted"
size="large"
onClick={() => openFileBrowser()}
aria-label={language.t("command.file.open")}
/>
</TooltipV2>
</div>
</Tabs.List>
<div
class="session-review-v2-open-in-app-slot shrink-0 flex items-center pr-3"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<OpenInAppV2 directory={projectDirectory} />
</div>
</Tabs.List>
</div>
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
<div
id={reviewTabPanelID}
role="tabpanel"
aria-labelledby={reviewTabID}
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
data-slot="tabs-content"
class="flex flex-col h-full overflow-hidden contain-strict"
>
{props.reviewPanel()}
</div>
</Show>
<Show when={activeTab() === "empty"}>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
<div
id={reviewTabPanelID}
role="tabpanel"
aria-labelledby={reviewTabID}
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
data-slot="tabs-content"
class="flex flex-col h-full overflow-hidden contain-strict"
>
{props.reviewPanel()}
</div>
</Show>
<Show when={activeTab() === "empty"}>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
</div>
</div>
</div>
</Tabs.Content>
</Show>
</Tabs.Content>
</Show>
<Show when={activeTab() === "context"}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab />
</div>
</Tabs.Content>
</Show>
<Show when={browserTab()}>
<SessionFileBrowserTab
tab={browserTab()!}
placeholder={browserTab() === SESSION_OPEN_FILE_TAB}
active={file.pathFromTab(browserTab()!)}
kinds={browserKinds()}
state={props.fileBrowserState!}
onSelect={(path) => previewTab(file.tab(path))}
onSelectPermanent={(path) => openTab(file.tab(path))}
filterRef={(element) => (fileFilter = element)}
/>
</Show>
<Show when={!props.fileBrowserState && activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />}
</Show>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable} keyed>
{(tab) => {
const path = file.pathFromTab(tab)
return (
<div data-component="tabs-drag-preview">
<Show when={path}>
{(p) => <FileVisual active path={p()} temporary={temporaryTab() === tab} />}
</Show>
<Show when={activeTab() === "context"}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab />
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
</Tabs.Content>
</Show>
<Show when={fileBrowserMounted()}>
<div
id={fileBrowserTabPanelID}
role="tabpanel"
data-slot="tabs-content"
class="h-full min-h-0 overflow-hidden"
classList={{ hidden: !fileBrowserVisible() }}
inert={!fileBrowserVisible() || undefined}
>
<SessionFileBrowserTab
tab={browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB}
placeholder={
(browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB) === SESSION_OPEN_FILE_TAB
}
active={file.pathFromTab(browserTab() ?? activeFileTab() ?? "")}
kinds={kinds()}
state={props.fileBrowserState!}
onSelect={(path) => previewTab(file.tab(path))}
onSelectPermanent={(path) => openTab(file.tab(path))}
filterRef={(element) => (fileFilter = element)}
/>
</div>
</Show>
</Tabs>
</DndKitProvider>
</Show>
</div>
</div>
</Show>
@ -513,10 +756,19 @@ export function SessionSidePanel(props: {
>
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{props.reviewCount()}{" "}
{language.t(
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
)}
<Show
when={settings.general.newLayoutDesigns()}
fallback={
<>
{props.reviewCount()}{" "}
{language.t(
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
)}
</>
}
>
{language.t("session.review.filesChanged", { count: props.reviewCount() })}
</Show>
</Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.files.all")}

View file

@ -327,6 +327,7 @@ export function MessageTimeline(props: {
parts: getMsgParts,
status: sessionStatus,
showReasoningSummaries: settings.general.showReasoningSummaries,
inlineComments: settings.general.newLayoutDesigns,
})
const activeMessageID = projection.activeMessageID
const assistantMessagesByParent = projection.assistantMessagesByParent
@ -1135,6 +1136,10 @@ export function MessageTimeline(props: {
const m = messageByID().get(userMessageRow().userMessageID)
if (m?.role === "user") return m
})
const messageComments = createMemo(() => {
if (!settings.general.newLayoutDesigns()) return []
return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? [])
})
return (
<TimelineRowFrame row={userMessageRow}>
<Show when={message()}>
@ -1146,6 +1151,7 @@ export function MessageTimeline(props: {
parts={getMsgParts(userMessageRow().userMessageID)}
actions={props.actions}
useV2Actions={settings.general.newLayoutDesigns()}
comments={messageComments()}
/>
</div>
</div>

View file

@ -14,6 +14,7 @@ export function createTimelineProjection(input: {
parts: (messageID: string) => Part[]
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
inlineComments: Accessor<boolean>
}) {
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
const assistantMessagesByParent = createMemo(() => {
@ -59,6 +60,7 @@ export function createTimelineProjection(input: {
input.showReasoningSummaries(),
input.status().type,
activeMessageID() === userMessage.id,
input.inlineComments(),
),
),
),

View file

@ -38,6 +38,8 @@ export namespace Timeline {
showReasoning: boolean,
status: SessionStatus["type"],
isActive: boolean,
// v2 renders comments inside the user message attachments row instead of a strip row
inlineComments: boolean,
) {
const rows: TimelineRow.TimelineRow[] = []
@ -74,7 +76,7 @@ export namespace Timeline {
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id }))
if (comments.length > 0)
if (comments.length > 0 && !inlineComments)
rows.push(
new TimelineRow.CommentStrip({
userMessageID: userMessage.id,
@ -84,7 +86,7 @@ export namespace Timeline {
rows.push(
new TimelineRow.UserMessage({
userMessageID: userMessage.id,
anchor: comments.length === 0,
anchor: inlineComments || comments.length === 0,
}),
)

View file

@ -5,7 +5,6 @@ import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
SessionReviewV2,
SessionReviewV2Sidebar,
SessionReviewV2SidebarToggle,
} from "@opencode-ai/session-ui/v2/session-review-v2"
import { SessionReviewFilePreviewV2 } from "@opencode-ai/session-ui/v2/session-review-file-preview-v2"
import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-v2"
@ -65,6 +64,8 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
)
const searching = createMemo(() => props.state.filter().trim().length > 0)
const kinds = createMemo(() => reviewDiffKinds(diffs()))
// Changes-only trees omit "M" — every row is already a change; A/D stay visible.
const treeKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix")))
const activeDiff = createMemo(() => {
// A focused comment takes over the preview until the preview applies it and
// clears the focus; the owner then persists the file as the active selection.
@ -112,9 +113,6 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
stats={<DiffChanges changes={diffs()} />}
empty={props.empty}
sidebarOpen={props.state.sidebarOpened()}
sidebarToggle={
<SessionReviewV2SidebarToggle opened={props.state.sidebarOpened()} onToggle={props.state.toggleSidebar} />
}
sidebar={
// Always mounted: the sidebar header hosts the changes-mode dropdown,
// which must stay reachable when the current mode has zero diffs.
@ -126,7 +124,7 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
diffs={diffs}
filteredFiles={filteredFiles}
searching={searching}
kinds={kinds}
kinds={treeKinds}
activeDiff={activeDiff}
/>
}

View file

@ -1,14 +1,9 @@
import { createMemo, createSignal, createUniqueId, Show } from "solid-js"
import { createQuery } from "@tanstack/solid-query"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import {
SessionFilePanelV2,
SessionFilePanelV2Empty,
SessionFilePanelV2Title,
} from "@opencode-ai/session-ui/v2/session-file-panel-v2"
import { SessionReviewV2Sidebar, SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2"
import FileTree, { type Kind } from "@/components/file-tree"
import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode-ai/session-ui/v2/session-file-panel-v2"
import { SessionReviewV2Sidebar } from "@opencode-ai/session-ui/v2/session-review-v2"
import FileTreeV2, { type Kind } from "@/components/file-tree-v2"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
@ -93,102 +88,92 @@ export function SessionFileBrowserTab(props: {
})
}
// Keep the sidebar outside Kobalte Tabs.Content: a morphing content value
// unmounts the whole panel on every file-tab switch and resets sidebar scroll.
return (
<Tabs.Content value={props.tab} class="h-full min-h-0 overflow-hidden">
<SessionFilePanelV2
toolbar
toolbarStart={
<>
<SessionReviewV2SidebarToggle opened={sidebarOpened()} onToggle={props.state.toggleSidebar} />
<Show when={!sidebarOpened()}>
<SessionFilePanelV2Title>{title()}</SessionFilePanelV2Title>
</Show>
</>
}
sidebar={
<SessionReviewV2Sidebar
open={sidebarOpened()}
title={<span class="truncate">{title()}</span>}
filter={filter()}
onFilterChange={setFilter}
onFilterKeyDown={onFilterKeyDown}
filterAutofocus={props.placeholder}
filterRef={props.filterRef}
filterControls={resultsID}
filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined}
filterExpanded={query().length > 0 && files().length > 0}
width={props.state.sidebarWidth()}
onWidthChange={props.state.resizeSidebar}
<SessionFilePanelV2
toolbar={false}
sidebar={
<SessionReviewV2Sidebar
open={sidebarOpened()}
title={<span class="truncate">{title()}</span>}
filter={filter()}
onFilterChange={setFilter}
onFilterKeyDown={onFilterKeyDown}
filterAutofocus={props.placeholder}
filterRef={props.filterRef}
filterControls={resultsID}
filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined}
filterExpanded={query().length > 0 && files().length > 0}
width={props.state.sidebarWidth()}
onWidthChange={props.state.resizeSidebar}
>
<Show
when={query()}
fallback={
<FileTreeV2
active={props.active}
kinds={props.kinds}
onFileClick={(node) => props.onSelect(node.path)}
onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
/>
}
>
<Show
when={query()}
when={!loading()}
fallback={
<FileTree
path=""
class="pt-1"
active={props.active}
kinds={props.kinds}
onFileClick={(node) => props.onSelect(node.path)}
onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
/>
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<Show
when={!loading()}
when={files().length > 0}
fallback={
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
{language.t("palette.empty")}
</div>
}
>
<Show
when={files().length > 0}
fallback={
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
{language.t("palette.empty")}
</div>
}
>
<SessionFileListV2
id={resultsID}
role="listbox"
optionID={optionID}
files={files()}
kinds={props.kinds}
active={props.active}
highlighted={highlighted()}
onFileClick={(path) => {
setExplicitHighlight(path)
props.onSelect(path)
}}
onFileDoubleClick={props.onSelectPermanent}
/>
</Show>
<SessionFileListV2
id={resultsID}
role="listbox"
optionID={optionID}
files={files()}
kinds={props.kinds}
active={props.active}
highlighted={highlighted()}
onFileClick={(path) => {
setExplicitHighlight(path)
props.onSelect(path)
}}
onFileDoubleClick={props.onSelectPermanent}
/>
</Show>
</Show>
</SessionReviewV2Sidebar>
</Show>
</SessionReviewV2Sidebar>
}
>
<Show
when={!props.placeholder}
fallback={
<SessionFilePanelV2Empty>
<div class="flex flex-col items-center gap-3 text-center text-text-weak">
<Icon name="file-tree" size="large" />
<div class="text-14-medium text-text-strong">{language.t("command.file.open")}</div>
<div class="text-13-regular">{language.t("session.files.selectToOpen")}</div>
</div>
</SessionFilePanelV2Empty>
}
>
<Show
when={!props.placeholder}
fallback={
<SessionFilePanelV2Empty>
<div class="flex flex-col items-center gap-3 text-center text-text-weak">
<Icon name="file-tree" size="large" />
<div class="text-14-medium text-text-strong">{language.t("command.file.open")}</div>
<div class="text-13-regular">{language.t("session.files.selectToOpen")}</div>
</div>
</SessionFilePanelV2Empty>
}
>
<div class="min-h-0 flex-1">
<Show when={props.tab} keyed>
{(tab) => <SessionFileView tab={tab} />}
</Show>
</div>
</Show>
</SessionFilePanelV2>
</Tabs.Content>
<div class="min-h-0 flex-1">
<Show when={props.tab} keyed>
{(tab) => <SessionFileView tab={tab} />}
</Show>
</div>
</Show>
</SessionFilePanelV2>
)
}

View file

@ -1,8 +1,32 @@
import { expect, test } from "bun:test"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
import { createVirtualizer, defaultRangeExtractor, Virtualizer } from "@tanstack/solid-virtual"
import { createRoot, createSignal } from "solid-js"
import { filterVirtualIndexes } from "@/pages/session/timeline/virtual-items"
test("end anchoring survives consecutive resizes when the first scroll write is clamped", () => {
const writes: { offset: number; adjustments?: number }[] = []
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>({
count: 5,
estimateSize: () => 50,
initialOffset: 50,
initialRect: { width: 400, height: 200 },
anchorTo: "end",
scrollEndThreshold: 1,
getScrollElement: () => null,
scrollToFn: (offset, options) => writes.push({ offset, adjustments: options.adjustments }),
observeElementRect: () => {},
observeElementOffset: () => {},
})
virtualizer.getTotalSize()
virtualizer.resizeItem(4, 120)
expect(writes).toEqual([{ offset: 50, adjustments: 70 }])
writes.length = 0
virtualizer.resizeItem(4, 200)
expect(writes).toEqual([{ offset: 120, adjustments: 80 }])
})
test("reactive count updates preserve measured row sizes", () => {
createRoot((dispose) => {
const [count, setCount] = createSignal(2)
@ -42,23 +66,26 @@ test("initial rect projects rows before a scroll element connects", () => {
})
})
test("logical scroll offset includes pending measurement adjustments", () => {
createRoot((dispose) => {
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
count: 2,
getScrollElement: () => null,
estimateSize: () => 60,
initialOffset: 100,
initialRect: { width: 800, height: 60 },
})
virtualizer.getTotalSize()
virtualizer.resizeItem(0, 100)
expect(virtualizer.scrollOffset).toBe(100)
expect(virtualizer.getLogicalScrollOffset()).toBe(140)
dispose()
test("clamps oversized offsets with scroll margin and padding changes", () => {
const options = (paddingEnd: number) => ({
count: 20,
estimateSize: () => 60,
initialOffset: Number.MAX_SAFE_INTEGER,
initialRect: { width: 800, height: 600 },
scrollMargin: 64,
paddingEnd,
overscan: 1,
getScrollElement: () => null,
scrollToFn: () => {},
observeElementRect: () => {},
observeElementOffset: () => {},
})
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>(options(64))
expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 18, 19])
virtualizer.setOptions(options(600))
expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([18, 19])
})
test("stale pinned indexes do not produce missing virtual items after count shrinks", () => {

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/codemode",
"version": "1.17.18",
"version": "1.17.20",
"description": "Effect-native confined code execution over schema-described tools",
"private": true,
"type": "module",

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.17.18",
"version": "1.17.20",
"type": "module",
"license": "MIT",
"scripts": {

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.17.18",
"version": "1.17.20",
"private": true,
"type": "module",
"license": "MIT",

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.17.18",
"version": "1.17.20",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.17.18",
"version": "1.17.20",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.17.18",
"version": "1.17.20",
"type": "module",
"license": "MIT",
"scripts": {

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.18",
"version": "1.17.20",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
@ -64,7 +64,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",
@ -73,7 +73,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",
@ -110,7 +110,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",

View file

@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.17.18",
"version": "1.17.20",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View file

@ -184,6 +184,16 @@ export function registerIpcHandlers(deps: Deps) {
})
})
ipcMain.handle("reveal-path", async (_event: IpcMainInvokeEvent, path: string) => {
const exists = await stat(path).then(
() => true,
() => false,
)
if (!exists) return false
shell.showItemInFolder(path)
return true
})
ipcMain.handle("read-clipboard-image", () => {
const image = clipboard.readImage()
if (image.isEmpty()) return null

View file

@ -95,6 +95,7 @@ const api: ElectronAPI = {
saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts),
openLink: (url) => ipcRenderer.send("open-link", url),
openPath: (path, app) => ipcRenderer.invoke("open-path", path, app),
revealPath: (path) => ipcRenderer.invoke("reveal-path", path),
readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"),
showNotification: (title, body) => ipcRenderer.send("show-notification", title, body),
getWindowFocused: () => ipcRenderer.invoke("get-window-focused"),

View file

@ -86,6 +86,7 @@ export type ElectronAPI = {
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
openLink: (url: string) => void
openPath: (path: string, app?: string) => Promise<void>
revealPath: (path: string) => Promise<boolean>
readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null>
showNotification: (title: string, body?: string) => void
getWindowFocused: () => Promise<boolean>

View file

@ -218,6 +218,9 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
}
return window.api.openPath(path, app)
},
async revealPath(path: string) {
return window.api.revealPath(path)
},
back() {
window.history.back()

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.18",
"version": "1.17.20",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.18",
"version": "1.17.20",
"name": "@opencode-ai/effect-sqlite-node",
"type": "module",
"license": "MIT",

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.17.18",
"version": "1.17.20",
"private": true,
"type": "module",
"license": "MIT",

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.17.18",
"version": "1.17.20",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.18",
"version": "1.17.20",
"name": "@opencode-ai/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.18",
"version": "1.17.20",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.18",
"version": "1.17.20",
"name": "opencode",
"type": "module",
"license": "MIT",
@ -58,7 +58,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",
@ -67,7 +67,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",
@ -122,7 +122,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",

View file

@ -348,6 +348,18 @@ const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient.HttpCl
return yield* fetchOrgs(account.url, accessToken)
})
const remove = Effect.fn("Account.remove")(function* (accountID: AccountID) {
const active = yield* repo.active()
yield* repo.remove(accountID)
if (Option.isNone(active) || active.value.id !== accountID) return
const next = (yield* orgsByAccount()).flatMap((group) =>
group.orgs.map((org) => ({ accountID: group.account.id, orgID: org.id })),
)[0]
if (!next) return
yield* repo.use(next.accountID, Option.some(next.orgID))
})
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
@ -445,7 +457,7 @@ const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient.HttpCl
activeOrg,
list: repo.list,
orgsByAccount,
remove: repo.remove,
remove,
use: repo.use,
orgs,
config,

View file

@ -283,8 +283,10 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
return Object.fromEntries(
Object.entries(provider.models)
.filter(([, model]) => {
if (model.options.reasoningMode === "pro") return false
if (ALLOWED_MODELS.has(model.api.id)) return true
if (DISALLOWED_MODELS.has(model.api.id)) return false
if (model.api.id === "gpt-5.6") return false
const match = model.api.id.match(/^gpt-(\d+\.\d+)/)
return match ? parseFloat(match[1]) > 5.4 : false
})
@ -416,6 +418,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
const requestInit = {
...init,
body: init?.body,
headers,
}
if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit)

View file

@ -1266,6 +1266,14 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
// @ts-expect-error dead V1 expects raw model IDs inside normalized ModelsDev data.
const id = `${model.id}-${mode}`
const base = fromModelsDevModel(provider, model)
const providerOptions = (
opts as {
readonly provider?: {
readonly body?: Record<string, unknown>
readonly headers?: Record<string, string>
}
}
).provider
models[id] = {
...base,
id: ModelV2.ID.make(id),
@ -1273,18 +1281,8 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
name: `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`,
// @ts-expect-error dead V1 expects raw mode costs inside normalized ModelsDev data.
cost: opts.cost ? mergeDeep(base.cost, cost(opts.cost)) : base.cost,
// @ts-expect-error dead V1 expects raw mode provider bodies inside normalized ModelsDev data.
options: opts.provider?.body
? Object.fromEntries(
// @ts-expect-error dead V1 expects raw mode provider bodies inside normalized ModelsDev data.
Object.entries(opts.provider.body).map(([k, v]) => [
k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()),
v,
]),
)
: base.options,
// @ts-expect-error dead V1 expects raw mode headers inside normalized ModelsDev data.
headers: opts.provider?.headers ?? base.headers,
options: modeOptions(base, providerOptions?.body),
headers: providerOptions?.headers ?? base.headers,
}
}
}
@ -1298,6 +1296,17 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
}
}
function modeOptions(model: Model, body: Record<string, unknown> | undefined) {
if (!body) return model.options
const options = Object.fromEntries(
Object.entries(body).map(([key, value]) => [key.replace(/_([a-z])/g, (_, char) => char.toUpperCase()), value]),
)
const reasoning = body.reasoning
if (model.api.npm !== "@ai-sdk/openai" || !isRecord(reasoning) || typeof reasoning.mode !== "string") return options
const { reasoning: _, ...rest } = options
return { ...rest, reasoningMode: reasoning.mode }
}
function modelSuggestions(provider: Info | undefined, modelID: ModelV2.ID, enableExperimentalModels: boolean) {
const available = provider
? Object.keys(provider.models).filter((id) => {

View file

@ -761,7 +761,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
}
case "@ai-sdk/gateway":
if (model.id.includes("anthropic")) {
if (model.api.id.includes("anthropic")) {
if (adaptiveEfforts) {
return Object.fromEntries(
adaptiveEfforts.map((effort) => [
@ -794,8 +794,8 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
},
}
}
if (model.id.includes("google")) {
if (id.includes("2.5")) {
if (model.api.id.includes("google")) {
if (model.api.id.includes("2.5")) {
return {
high: {
thinkingConfig: {
@ -806,7 +806,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
max: {
thinkingConfig: {
includeThoughts: true,
thinkingBudget: googleThinkingBudgetMax(id),
thinkingBudget: googleThinkingBudgetMax(model.api.id.toLowerCase()),
},
},
}
@ -1104,7 +1104,8 @@ export function options(input: {
input.model.providerID === "openai" ||
input.model.api.npm === "@ai-sdk/openai" ||
input.model.api.npm === "@ai-sdk/github-copilot" ||
input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle"
input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle" ||
input.model.api.npm === "@ai-sdk/xai"
) {
result["store"] = false
}
@ -1259,7 +1260,8 @@ export function smallOptions(model: Provider.Model) {
if (
model.providerID === "openai" ||
model.api.npm === "@ai-sdk/openai" ||
model.api.npm === "@ai-sdk/github-copilot"
model.api.npm === "@ai-sdk/github-copilot" ||
model.api.npm === "@ai-sdk/xai"
) {
const base = { store: false }
return mergeDeep(base, small)

View file

@ -173,6 +173,53 @@ it.live("orgsByAccount groups orgs per account", () =>
}),
)
it.live("remove switches to another org when the active account is removed", () =>
Effect.gen(function* () {
const first = AccountID.make("user-1")
const second = AccountID.make("user-2")
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id: first,
email: "one@example.com",
url: "https://one.example.com",
accessToken: AccessToken.make("at_1"),
refreshToken: RefreshToken.make("rt_1"),
expiry: Date.now() + outsideEagerRefreshWindow,
orgID: Option.some(OrgID.make("org-1")),
}),
)
yield* AccountRepo.Service.use((r) =>
r.persistAccount({
id: second,
email: "two@example.com",
url: "https://two.example.com",
accessToken: AccessToken.make("at_2"),
refreshToken: RefreshToken.make("rt_2"),
expiry: Date.now() + outsideEagerRefreshWindow,
orgID: Option.some(OrgID.make("org-2")),
}),
)
const client = HttpClient.make((req) =>
Effect.succeed(
req.url === "https://one.example.com/api/orgs" ? json(req, [org("org-1", "One")]) : json(req, [], 404),
),
)
yield* Account.use.remove(second).pipe(Effect.provide(live(client)))
const active = yield* AccountRepo.use.active()
expect(Option.getOrThrow(active)).toEqual(
expect.objectContaining({
id: first,
active_org_id: OrgID.make("org-1"),
}),
)
}),
)
it.live("token refresh persists the new token", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")

View file

@ -149,16 +149,32 @@ describe("plugin.codex", () => {
await enabled.dispose?.()
})
test("uses Codex context limits for OAuth GPT models", async () => {
test("filters unsupported modes and uses Codex context limits for OAuth GPT models", async () => {
const hooks = await CodexAuthPlugin({} as never)
const limit = { context: 1_050_000, input: 922_000, output: 128_000 }
const provider = {
models: Object.fromEntries(
["gpt-5.4", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"].map((id) => [
id,
{ id, api: { id }, limit, cost: {} },
]),
),
models: {
...Object.fromEntries(
["gpt-5.4", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.7-pro"].map((id) => [
id,
{ id, api: { id }, limit, cost: {}, options: {} },
]),
),
"gpt-5.4-pro": {
id: "gpt-5.4-pro",
api: { id: "gpt-5.4" },
limit,
cost: {},
options: { reasoningMode: "pro" },
},
"gpt-5.6-sol-high": {
id: "gpt-5.6-sol-high",
api: { id: "gpt-5.6-sol" },
limit,
cost: {},
options: { reasoningEffort: "high" },
},
},
}
const models = await hooks.provider!.models!(provider as never, { auth: { type: "oauth" } } as never)
@ -168,6 +184,9 @@ describe("plugin.codex", () => {
expect(models["gpt-5.6-sol"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
expect(models["gpt-5.6-terra"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
expect(models["gpt-5.6-luna"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
expect(models["gpt-5.4-pro"]).toBeUndefined()
expect(models["gpt-5.7-pro"]).toBeDefined()
expect(models["gpt-5.6-sol-high"]).toBeDefined()
expect(await hooks.provider!.models!(provider as never, { auth: { type: "api" } } as never)).toBe(
provider.models as never,
)

View file

@ -1339,16 +1339,17 @@ it.instance(
},
)
test("mode cost preserves over-200k pricing from base model", () => {
test("mode options and cost are derived from the base model", () => {
const provider = {
id: "openai",
name: "OpenAI",
env: [],
npm: "@ai-sdk/openai",
api: "https://api.openai.com/v1",
models: {
"gpt-5.4": {
id: "gpt-5.4",
name: "GPT-5.4",
"gpt-5.6-sol": {
id: "gpt-5.6-sol",
name: "GPT-5.6 Sol",
family: "gpt",
release_date: "2026-03-05",
attachment: true,
@ -1384,18 +1385,29 @@ test("mode cost preserves over-200k pricing from base model", () => {
},
},
},
pro: {
provider: {
body: {
reasoning: { mode: "pro" },
service_tier: "priority",
},
},
},
},
},
},
},
} as unknown as ModelsDevProvider
const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"]
const model = Provider.fromModelsDevProvider(provider).models["gpt-5.6-sol-fast"]
expect(model.cost.input).toEqual(5)
expect(model.cost.output).toEqual(30)
expect(model.cost.cache.read).toEqual(0.5)
expect(model.cost.cache.write).toEqual(0)
expect(model.options["serviceTier"]).toEqual("priority")
const pro = Provider.fromModelsDevProvider(provider).models["gpt-5.6-sol-pro"]
expect(pro.api.id).toEqual("gpt-5.6-sol")
expect(pro.options).toEqual({ reasoningMode: "pro", serviceTier: "priority" })
expect(model.cost.experimentalOver200K).toEqual({
input: 5,
output: 22.5,

View file

@ -155,6 +155,43 @@ describe("ProviderTransform.options - setCacheKey", () => {
expect(result.store).toBe(false)
})
test("should set store=false for xAI provider by default", () => {
const xaiModel = {
...mockModel,
providerID: "xai",
api: {
id: "grok-4",
url: "https://api.x.ai",
npm: "@ai-sdk/xai",
},
}
const result = ProviderTransform.options({
model: xaiModel,
sessionID,
providerOptions: {},
})
expect(result.store).toBe(false)
expect(result.promptCacheKey).toBe(sessionID)
})
test("should set store=false for xAI SDK regardless of provider ID", () => {
const xaiModel = {
...mockModel,
providerID: "custom-xai",
api: {
id: "grok-4",
url: "https://api.x.ai",
npm: "@ai-sdk/xai",
},
}
const result = ProviderTransform.options({
model: xaiModel,
sessionID,
providerOptions: {},
})
expect(result.store).toBe(false)
})
test("should set store=false for azure provider by default", () => {
const azureModel = {
...mockModel,
@ -3372,6 +3409,42 @@ describe("ProviderTransform.variants", () => {
})
describe("@ai-sdk/gateway", () => {
test("configured anthropic aliases route by the API ID", () => {
const model = createMockModel({
id: "my-claude",
providerID: "gateway",
api: {
id: "anthropic/claude-sonnet-4-6",
url: "https://gateway.ai",
npm: "@ai-sdk/gateway",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"])
expect(result.high).toEqual({
thinking: {
type: "adaptive",
},
effort: "high",
})
})
test("configured google aliases route by the API ID", () => {
const model = createMockModel({
id: "my-gemini",
providerID: "gateway",
api: {
id: "google/gemini-2.5-pro",
url: "https://gateway.ai",
npm: "@ai-sdk/gateway",
},
})
expect(ProviderTransform.variants(model)).toEqual({
high: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } },
max: { thinkingConfig: { includeThoughts: true, thinkingBudget: 32_768 } },
})
})
test("anthropic sonnet 4.6 models return adaptive thinking options", () => {
const model = createMockModel({
id: "anthropic/claude-sonnet-4-6",

View file

@ -1095,7 +1095,7 @@ describe("session.llm.stream", () => {
const agent = {
name: "test",
mode: "primary",
options: {},
options: { reasoningMode: "pro" },
permission: [{ permission: "*", pattern: "*", action: "allow" }],
temperature: 0.2,
} satisfies Agent.Info
@ -1126,6 +1126,7 @@ describe("session.llm.stream", () => {
expect(body.model).toBe(resolved.api.id)
expect(body.stream).toBe(true)
expect((body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
expect((body.reasoning as { mode?: string } | undefined)?.mode).toBe("pro")
const maxTokens = body.max_output_tokens as number | undefined
expect(maxTokens).toBe(undefined) // match codex cli behavior

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.17.18",
"version": "1.17.20",
"type": "module",
"license": "MIT",
"scripts": {

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.17.18",
"version": "1.17.20",
"type": "module",
"license": "MIT",
"scripts": {

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server",
"version": "1.17.18",
"version": "1.17.20",
"private": true,
"type": "module",
"license": "MIT",

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/session-ui",
"version": "1.17.18",
"version": "1.17.20",
"private": true,
"type": "module",
"license": "MIT",

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { FilePart } from "@opencode-ai/sdk/v2"
import { attached, inline, kind } from "./message-file"
import { attached, inline, kind, typeLabel } from "./message-file"
function file(part: Partial<FilePart> = {}): FilePart {
return {
@ -52,4 +52,14 @@ describe("message-file", () => {
expect(kind(file({ mime: "image/png" }))).toBe("image")
expect(kind(file({ mime: "application/pdf" }))).toBe("file")
})
test("labels attachment types from the basename extension", () => {
expect(typeLabel("list.md", "text/plain")).toBe("Markdown")
expect(typeLabel("/repo/src/main.ts", "text/plain")).toBe("TypeScript")
expect(typeLabel("/tmp/report.pdf", "application/pdf")).toBe("PDF")
expect(typeLabel("notes.xyz", "text/plain")).toBe("XYZ")
expect(typeLabel("/home/user/my.project/Makefile", "text/plain")).toBe("File")
expect(typeLabel(".gitignore", "text/plain")).toBe("File")
expect(typeLabel("/repo/.env", "text/plain")).toBe("File")
})
})

View file

@ -1,3 +1,5 @@
import { bundledLanguagesInfo } from "shiki"
import { getFilename } from "@opencode-ai/core/util/path"
import type { FilePart } from "@opencode-ai/sdk/v2"
export function attached(part: FilePart) {
@ -12,3 +14,22 @@ export function inline(part: FilePart) {
export function kind(part: FilePart) {
return part.mime.startsWith("image/") ? "image" : "file"
}
// language metadata only; grammars stay behind shiki's lazy imports
const LANGUAGE_NAMES = new Map<string, string>(
bundledLanguagesInfo.flatMap((info) =>
[info.id, ...(info.aliases ?? [])].map((alias) => [alias, info.name] as [string, string]),
),
)
// attachments carry text/plain for all text files, so the label comes from the extension;
// filename may be an absolute path, so extract the basename before looking for one
export function typeLabel(filename: string, mime: string) {
if (mime === "application/pdf") return "PDF"
const base = getFilename(filename)
// idx 0 is a dotfile like .gitignore, not an extension
const idx = base.lastIndexOf(".")
const suffix = idx <= 0 ? "" : base.slice(idx + 1).toLowerCase()
if (!suffix) return "File"
return LANGUAGE_NAMES.get(suffix) ?? suffix.toUpperCase()
}

View file

@ -57,8 +57,20 @@
}
&[data-type="image"] {
width: 48px;
height: 48px;
position: relative;
width: 58px;
height: 46px;
border: none;
/* inset box-shadows do not paint over <img> content, so the hairline is an overlay */
&::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
pointer-events: none;
}
}
&[data-type="file"] {
@ -1301,6 +1313,16 @@ body:not([data-new-layout]) {
&:hover {
border-color: var(--border-strong-base);
}
&[data-type="image"] {
width: 48px;
height: 48px;
border: 1px solid var(--border-weak-base);
&::after {
content: none;
}
}
}
[data-slot="user-message-attachment-name"] {

View file

@ -45,6 +45,8 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Markdown } from "./markdown"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { AttachmentCardV2 } from "../v2/components/attachment-card-v2"
import { CommentCardV2 } from "../v2/components/comment-card-v2"
import { checksum } from "@opencode-ai/core/util/encode"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
@ -58,7 +60,7 @@ import { ToolStatusTitle } from "./tool-status-title"
import { patchFiles } from "./apply-patch-file"
import { animate } from "motion"
import { useLocation } from "@solidjs/router"
import { attached, inline, kind } from "./message-file"
import { attached, inline, kind, typeLabel } from "./message-file"
import { readPartText } from "./message-part-text"
import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
@ -165,6 +167,7 @@ export interface MessageProps {
showAssistantCopyPartID?: string | null
showReasoningSummaries?: boolean
useV2Actions?: boolean
comments?: UserMessageComment[]
}
export type SessionAction = (input: { sessionID: string; messageID: string }) => Promise<void> | void
@ -172,6 +175,16 @@ export type SessionAction = (input: { sessionID: string; messageID: string }) =>
export type UserActions = {
fork?: SessionAction
revert?: SessionAction
openAttachment?: (file: FilePart) => void
}
export type UserMessageComment = {
path: string
comment: string
selection?: {
startLine: number
endLine: number
}
}
export interface MessagePartProps {
@ -937,6 +950,7 @@ export function Message(props: MessageProps) {
parts={props.parts}
actions={props.actions}
useV2Actions={props.useV2Actions}
comments={props.comments}
/>
)}
</Match>
@ -1162,6 +1176,7 @@ export function UserMessageDisplay(props: {
parts: PartType[]
actions?: UserActions
useV2Actions?: boolean
comments?: UserMessageComment[]
}) {
const data = useData()
const dialog = useDialog()
@ -1183,6 +1198,8 @@ export function UserMessageDisplay(props: {
const attachments = createMemo(() => files().filter(attached))
const messageComments = createMemo(() => (newLayout() ? (props.comments ?? []) : []))
const inlineFiles = createMemo(() => files().filter(inline))
const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? [])
@ -1239,35 +1256,59 @@ export function UserMessageDisplay(props: {
return (
<div data-component="user-message" data-timeline-part-id={textPart()?.id}>
<Show when={attachments().length > 0}>
<Show when={attachments().length > 0 || messageComments().length > 0}>
<div data-slot="user-message-attachments">
<For each={messageComments()}>
{(comment) => (
<CommentCardV2
comment={comment.comment}
path={comment.path}
selection={comment.selection}
title={comment.comment}
/>
)}
</For>
<For each={attachments()}>
{(file) => {
const type = kind(file)
const name = file.filename ?? i18n.t("ui.message.attachment.alt")
return (
<div
data-slot="user-message-attachment"
data-type={type}
data-clickable={type === "image" ? "true" : undefined}
title={type === "file" ? name : undefined}
onClick={() => {
if (type === "image") openImagePreview(file.url, name)
}}
<Show
when={newLayout() && type === "file"}
fallback={
<div
data-slot="user-message-attachment"
data-type={type}
data-clickable={type === "image" ? "true" : undefined}
title={type === "file" ? name : undefined}
onClick={() => {
if (type === "image") openImagePreview(file.url, name)
}}
>
<Show
when={type === "image"}
fallback={
<div data-slot="user-message-attachment-file">
<FileIcon node={{ path: name, type: "file" }} />
<span data-slot="user-message-attachment-name">{name}</span>
</div>
}
>
<img data-slot="user-message-attachment-image" src={file.url} alt={name} />
</Show>
</div>
}
>
<Show
when={type === "image"}
fallback={
<div data-slot="user-message-attachment-file">
<FileIcon node={{ path: name, type: "file" }} />
<span data-slot="user-message-attachment-name">{name}</span>
</div>
}
<AttachmentCardV2
title={getFilename(name)}
hover={name}
clickable={!!props.actions?.openAttachment}
onClick={() => props.actions?.openAttachment?.(file)}
>
<img data-slot="user-message-attachment-image" src={file.url} alt={name} />
</Show>
</div>
{typeLabel(name, file.mime)}
</AttachmentCardV2>
</Show>
)
}}
</For>

View file

@ -0,0 +1,58 @@
[data-component="attachment-card-v2"] {
display: flex;
flex-direction: column;
gap: 6px;
box-sizing: border-box;
width: 160px;
min-width: 160px;
max-width: 160px;
padding: 8px;
border-radius: 6px;
background: var(--v2-overlay-simple-overlay-hover);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
cursor: default;
&[data-active] {
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-strong);
}
&[data-clickable] {
cursor: pointer;
}
[data-slot="attachment-card-v2-title"],
[data-slot="attachment-card-v2-subtitle"] {
max-width: 100%;
font-family: var(--v2-font-family-sans, "Inter", sans-serif);
font-style: normal;
font-size: 11px;
line-height: 12px;
letter-spacing: 0.05px;
font-variation-settings: "slnt" 0;
}
[data-slot="attachment-card-v2-title"] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 530;
color: var(--v2-text-text-base);
}
[data-slot="attachment-card-v2-subtitle"] {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
overflow: hidden;
white-space: nowrap;
font-weight: 440;
color: var(--v2-text-text-muted);
[data-component="file-icon"] {
width: 12px;
height: 12px;
flex: none;
}
}
}

View file

@ -0,0 +1,26 @@
import type { JSX } from "solid-js"
import "./attachment-card-v2.css"
/** Shared 160px two-line card used by v2 file and comment attachments in the composer and timeline. */
export function AttachmentCardV2(props: {
title: string
active?: boolean
clickable?: boolean
/** native title attribute */
hover?: string
onClick?: () => void
children: JSX.Element
}) {
return (
<div
data-component="attachment-card-v2"
data-active={props.active ? "true" : undefined}
data-clickable={props.clickable ? "true" : undefined}
title={props.hover}
onClick={() => props.onClick?.()}
>
<span data-slot="attachment-card-v2-title">{props.title}</span>
<span data-slot="attachment-card-v2-subtitle">{props.children}</span>
</div>
)
}

View file

@ -0,0 +1,27 @@
import { Show } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { getFilenameTruncated } from "@opencode-ai/core/util/path"
import { AttachmentCardV2 } from "./attachment-card-v2"
export function CommentCardV2(props: {
comment: string
path: string
selection?: { startLine: number; endLine: number }
active?: boolean
title?: string
onClick?: () => void
}) {
return (
<AttachmentCardV2 title={props.comment} active={props.active} hover={props.title} onClick={props.onClick}>
<FileIcon node={{ path: props.path, type: "file" }} />
<span>
{getFilenameTruncated(props.path, 14)}
<Show when={props.selection}>
{(sel) =>
sel().startLine === sel().endLine ? `:${sel().startLine}` : `:${sel().startLine}-${sel().endLine}`
}
</Show>
</span>
</AttachmentCardV2>
)
}

View file

@ -11,6 +11,7 @@ import {
import { useI18n } from "@opencode-ai/ui/context/i18n"
import { cloneSelectedLineRange, formatSelectedLineLabel } from "../../pierre/selection-bridge"
import { LineCommentEditorV2, LineCommentV2 } from "@opencode-ai/ui/v2/line-comment-v2"
import type { LineCommentEditorV2Mention } from "@opencode-ai/ui/v2/line-comment-v2"
type LineCommentControllerV2Props<T extends LineCommentShape> = {
comments: Accessor<T[]>
@ -23,6 +24,7 @@ type LineCommentControllerV2Props<T extends LineCommentShape> = {
onDelete?: (comment: T) => void
renderCommentActions?: (comment: T, controls: { edit: VoidFunction; remove: VoidFunction }) => JSX.Element
editSubmitLabel?: string
mention?: LineCommentEditorV2Mention
}
type CommentProps = {
@ -43,6 +45,7 @@ type DraftProps = {
onSubmit: (value: string) => void
cancelLabel?: string
submitLabel?: string
mention?: LineCommentEditorV2Mention
}
function lineCommentElementV2(view: Accessor<CommentProps>) {
@ -70,6 +73,7 @@ function lineCommentElementV2(view: Accessor<CommentProps>) {
onSubmit={view().editor!.onSubmit}
cancelLabel={view().editor!.cancelLabel}
submitLabel={view().editor!.submitLabel}
mention={view().editor!.mention}
/>
</div>
</Show>
@ -87,6 +91,7 @@ function lineCommentDraftElementV2(view: Accessor<DraftProps>) {
onSubmit={view().onSubmit}
cancelLabel={view().cancelLabel}
submitLabel={view().submitLabel}
mention={view().mention}
/>
</div>
)
@ -142,6 +147,7 @@ export function createLineCommentControllerV2<T extends LineCommentShape>(props:
},
cancelLabel: i18n.t("ui.lineComment.cancel"),
submitLabel: props.editSubmitLabel,
mention: props.mention,
}
: undefined
},
@ -165,6 +171,7 @@ export function createLineCommentControllerV2<T extends LineCommentShape>(props:
},
cancelLabel: i18n.t("ui.lineComment.cancel"),
submitLabel: i18n.t("ui.lineComment.submit"),
mention: props.mention,
}),
})
@ -202,6 +209,7 @@ export function createLineCommentControllerV2<T extends LineCommentShape>(props:
}
return {
note,
annotations,
renderAnnotation,
renderGutterUtility,

View file

@ -76,6 +76,11 @@
color: var(--text-strong, var(--v2-text-text-base));
}
[data-component="session-review-v2-sidebar-root"]
[data-slot="session-review-v2-sidebar-title"]:not(:has([data-component="select-v2-root"])) {
margin-left: 8px;
}
[data-component="session-review-v2-sidebar-root"]
[data-slot="session-review-v2-sidebar-title"]
[data-component="select-v2-root"] {
@ -97,7 +102,7 @@
width: 100%;
max-width: 100%;
min-width: 0;
margin-top: 2px;
margin-top: 3px;
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] {

View file

@ -26,7 +26,6 @@ export type SessionReviewV2Props = {
empty?: JSX.Element
sidebarOpen?: boolean
sidebar?: JSX.Element
sidebarToggle?: JSX.Element
activeFile?: string
files: string[]
onSelectFile: (file: string) => void
@ -199,7 +198,6 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
const toolbarStart = () => (
<>
{props.sidebarToggle}
<Show when={showCollapsedMeta()}>
<div data-slot="session-review-v2-toolbar-collapsed-meta">
<Show when={title()}>
@ -319,7 +317,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
)
}
export function SessionReviewV2SidebarToggle(props: { opened: boolean; onToggle: () => void }) {
export function SessionReviewV2SidebarToggle(props: { opened: boolean; disabled?: boolean; onToggle: () => void }) {
const i18n = useI18n()
return (
@ -330,6 +328,7 @@ export function SessionReviewV2SidebarToggle(props: { opened: boolean; onToggle:
class="session-review-v2-sidebar-toggle"
aria-label={i18n.t("ui.sessionReviewV2.toggleSidebar")}
aria-expanded={props.opened}
disabled={props.disabled}
onClick={props.onToggle}
icon={<Icon name="filetree" />}
/>

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.17.18",
"version": "1.17.20",
"type": "module",
"license": "MIT",
"scripts": {

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-app",
"version": "1.17.18",
"version": "1.17.20",
"private": true,
"type": "module",
"license": "MIT",
@ -13,6 +13,7 @@
},
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@opencode-ai/stats-core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",

View file

@ -39,6 +39,7 @@ import {
uniqueComparisonPairs,
type ComparisonModelRef,
} from "../compare-cards"
import { BreadcrumbSelect } from "../breadcrumb-select"
import {
applyThemePreference,
Footer,
@ -294,61 +295,46 @@ function ModelHero(props: {
<Show
when={labs().length > 0}
fallback={
<span data-slot="model-hero-crumb" data-current="true">
<span data-slot="model-hero-crumb" data-menu="true">
<span>{props.labName}</span>
<ChevronDownIcon />
</span>
}
>
<details data-component="model-hero-menu">
<summary data-slot="model-hero-crumb" data-current="true">
<span>{props.labName}</span>
<ChevronDownIcon />
</summary>
<div data-slot="model-hero-options">
<For each={labs()}>
{(lab) => (
<a
data-slot="model-hero-option"
data-current={lab.id === providerSlug(labId()) ? "true" : undefined}
href={language.route(`${import.meta.env.BASE_URL}${lab.id}`)}
>
{lab.name}
</a>
)}
</For>
</div>
</details>
<BreadcrumbSelect
ariaLabel="Choose a lab"
label={props.labName}
options={labs().map((lab) => ({
href: language.route(`${import.meta.env.BASE_URL}${lab.id}`),
label: lab.name,
value: lab.id,
}))}
value={providerSlug(labId())}
variant="model"
/>
</Show>
<span data-slot="model-hero-separator">/</span>
<Show
when={labModels().length > 0}
fallback={
<span data-slot="model-hero-crumb" data-current="true" aria-current="page">
<span data-slot="model-hero-crumb" data-menu="true" data-current="true" aria-current="page">
<span>{modelName()}</span>
<ChevronDownIcon />
</span>
}
>
<details data-component="model-hero-menu">
<summary data-slot="model-hero-crumb" data-current="true" aria-current="page">
<span>{modelName()}</span>
<ChevronDownIcon />
</summary>
<div data-slot="model-hero-options">
<For each={labModels()}>
{(model) => (
<a
data-slot="model-hero-option"
data-current={model.id === props.catalog?.id ? "true" : undefined}
href={language.route(`${import.meta.env.BASE_URL}${model.id}`)}
>
{model.name}
</a>
)}
</For>
</div>
</details>
<BreadcrumbSelect
ariaLabel="Choose a model"
current
label={modelName()}
options={labModels().map((model) => ({
href: language.route(`${import.meta.env.BASE_URL}${model.id}`),
label: model.name,
value: model.id,
}))}
value={props.catalog?.id ?? ""}
variant="model"
/>
</Show>
</nav>
<div data-slot="model-hero-title-row">
@ -432,7 +418,7 @@ function ModelHeroSparkline(props: { data: StatsModelData }) {
function ChevronDownIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" fill="none">
<path d="M4.75 6.25L8 9.5L11.25 6.25" stroke="currentColor" stroke-width="1.5" />
<path d="M5 6.5L8 9.5L11 6.5" stroke="currentColor" />
</svg>
)
}

View file

@ -29,6 +29,7 @@ import { SectionHeading } from "../section-heading"
import { runStatsEffect } from "../../stats-runtime"
import { setStatsPageCacheHeaders } from "../stats-cache"
import { ComparisonCardsSection, modelRefFromCatalog, uniqueComparisonPairs } from "../compare-cards"
import { BreadcrumbSelect } from "../breadcrumb-select"
import {
applyThemePreference,
Footer,
@ -205,6 +206,7 @@ function LabHero(props: { lab: ModelCatalogLab; labs: ModelCatalogLab[] }) {
function LabHeroBreadcrumb(props: { label: string; labs?: ModelCatalogLab[] }) {
const language = useLanguage()
const labs = () => props.labs ?? []
const current = () => labs().find((lab) => lab.name === props.label)
return (
<nav data-component="lab-hero-breadcrumb" aria-label="Data breadcrumb">
<a data-slot="lab-hero-crumb" href={language.route(import.meta.env.BASE_URL)}>
@ -217,32 +219,23 @@ function LabHeroBreadcrumb(props: { label: string; labs?: ModelCatalogLab[] }) {
<span data-slot="lab-hero-crumb" data-current="true" aria-current="page">
<span>{props.label}</span>
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
<path d="M4.75 6.25L8 9.5L11.25 6.25" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M5 6.5L8 9.5L11 6.5" fill="none" stroke="currentColor" />
</svg>
</span>
}
>
<details data-component="lab-hero-menu">
<summary data-slot="lab-hero-crumb" data-current="true" aria-current="page">
<span>{props.label}</span>
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
<path d="M4.75 6.25L8 9.5L11.25 6.25" fill="none" stroke="currentColor" stroke-width="1.5" />
</svg>
</summary>
<div data-slot="lab-hero-options">
<For each={labs()}>
{(lab) => (
<a
data-slot="lab-hero-option"
data-current={lab.name === props.label ? "true" : undefined}
href={language.route(`${import.meta.env.BASE_URL}${lab.id}`)}
>
{lab.name}
</a>
)}
</For>
</div>
</details>
<BreadcrumbSelect
ariaLabel="Choose a lab"
current
label={props.label}
options={labs().map((lab) => ({
href: language.route(`${import.meta.env.BASE_URL}${lab.id}`),
label: lab.name,
value: lab.id,
}))}
value={current()?.id ?? ""}
variant="lab"
/>
</Show>
</nav>
)

View file

@ -0,0 +1,71 @@
import { Select } from "@kobalte/core/select"
import { createMemo } from "solid-js"
export type BreadcrumbSelectOption = {
href: string
label: string
value: string
}
export function BreadcrumbSelect(props: {
ariaLabel: string
current?: boolean
label: string
options: BreadcrumbSelectOption[]
value: string
variant: "lab" | "model"
}) {
const selected = createMemo(() => props.options.find((option) => option.value === props.value) ?? null)
return (
<Select<BreadcrumbSelectOption>
data-component="stats-breadcrumb-select"
data-variant={props.variant}
options={props.options}
optionValue="value"
optionTextValue="label"
value={selected()}
placeholder={props.label}
disallowEmptySelection
closeOnSelection
shouldFocusWrap
placement="bottom"
gutter={8}
sameWidth={false}
itemComponent={(itemProps) => (
<Select.Item
item={itemProps.item}
data-slot="stats-breadcrumb-select-option"
data-current={itemProps.item.rawValue.value === props.value ? "true" : undefined}
>
<Select.ItemLabel>{itemProps.item.rawValue.label}</Select.ItemLabel>
</Select.Item>
)}
onChange={(option) => {
if (!option || option.value === props.value) return
window.location.assign(option.href)
}}
>
<Select.Trigger
data-slot="stats-breadcrumb-select-trigger"
data-current={props.current ? "true" : undefined}
data-variant={props.variant}
aria-label={props.ariaLabel}
aria-current={props.current ? "page" : undefined}
>
<Select.Value<BreadcrumbSelectOption> data-slot="stats-breadcrumb-select-value">
{(state) => <span>{state.selectedOption()?.label ?? props.label}</span>}
</Select.Value>
<Select.Icon data-slot="stats-breadcrumb-select-icon">
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
<path d="M5 6.5L8 9.5L11 6.5" fill="none" stroke="currentColor" />
</svg>
</Select.Icon>
</Select.Trigger>
<Select.HiddenSelect />
<Select.Content data-component="stats-breadcrumb-select-content" data-variant={props.variant}>
<Select.Listbox data-slot="stats-breadcrumb-select-listbox" />
</Select.Content>
</Select>
)
}

View file

@ -134,7 +134,7 @@ export default function ModelCompareIndex() {
Data
</a>
<span data-slot="compare-home-separator">/</span>
<span data-slot="compare-home-crumb" data-current="true">
<span data-slot="compare-home-crumb" data-current="true" aria-current="page">
Compare
</span>
</nav>

View file

@ -2888,7 +2888,7 @@
padding: 0 8px;
overflow: hidden;
background: var(--stats-layer-2);
color: var(--stats-faint);
color: var(--stats-hero-muted);
font-size: 13px;
font-weight: 500;
line-height: 1.1;
@ -2897,21 +2897,7 @@
}
[data-page="stats"] a[data-slot="lab-hero-crumb"] {
color: var(--stats-faint);
}
[data-page="stats"] [data-component="lab-hero-menu"] {
position: relative;
display: inline-block;
}
[data-page="stats"] [data-component="lab-hero-menu"] summary {
cursor: pointer;
list-style: none;
}
[data-page="stats"] [data-component="lab-hero-menu"] summary::-webkit-details-marker {
display: none;
color: var(--stats-hero-muted);
}
[data-page="stats"] [data-slot="lab-hero-crumb"][data-current="true"] {
@ -2920,9 +2906,7 @@
}
[data-page="stats"] a[data-slot="lab-hero-crumb"]:hover,
[data-page="stats"] a[data-slot="lab-hero-crumb"]:focus-visible,
[data-page="stats"] summary[data-slot="lab-hero-crumb"]:hover,
[data-page="stats"] summary[data-slot="lab-hero-crumb"]:focus-visible {
[data-page="stats"] a[data-slot="lab-hero-crumb"]:focus-visible {
color: var(--stats-text);
outline: none;
text-decoration: none;
@ -2941,57 +2925,6 @@
color: currentColor;
}
[data-page="stats"] [data-component="lab-hero-menu"][open] [data-slot="lab-hero-crumb"] svg {
transform: rotate(180deg);
}
[data-page="stats"] [data-slot="lab-hero-options"] {
position: absolute;
top: calc(100% + 8px);
left: 50%;
z-index: 5;
display: grid;
width: max-content;
min-width: 180px;
max-width: min(280px, calc(100vw - 48px));
max-height: min(360px, calc(100vh - 160px));
padding: 6px;
overflow: auto;
border: 1px solid var(--stats-line);
background: var(--stats-bg);
box-shadow: 0 12px 32px #0000001a;
transform: translateX(-50%);
}
[data-page="stats"] [data-slot="lab-hero-option"] {
display: flex;
align-items: center;
min-width: 0;
height: 30px;
padding: 0 8px;
overflow: hidden;
color: var(--stats-muted);
font-size: 13px;
font-weight: 500;
line-height: 1.2;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-page="stats"] [data-slot="lab-hero-option"]:hover,
[data-page="stats"] [data-slot="lab-hero-option"]:focus-visible {
background: var(--stats-layer-2);
color: var(--stats-text);
outline: none;
text-decoration: none;
}
[data-page="stats"] [data-slot="lab-hero-option"][data-current="true"] {
background: var(--stats-layer-2);
color: var(--stats-text);
}
[data-page="stats"] [data-slot="lab-hero-separator"] {
display: inline-flex;
align-items: center;
@ -3113,29 +3046,147 @@
white-space: nowrap;
}
[data-page="stats"] [data-slot="model-hero-crumb"][data-current="true"] {
[data-page="stats"] a[data-slot="model-hero-crumb"] {
color: var(--stats-hero-muted);
}
[data-page="stats"] [data-slot="model-hero-crumb"][data-menu="true"] {
padding-right: 4px;
}
[data-page="stats"] [data-component="model-hero-menu"] {
position: relative;
display: inline-block;
[data-page="stats"] [data-slot="model-hero-crumb"][data-current="true"] {
color: var(--stats-text);
}
[data-page="stats"] [data-component="stats-breadcrumb-select"] {
display: inline-flex;
min-width: 0;
}
[data-page="stats"] [data-component="model-hero-menu"] summary {
[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"] {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 4px;
flex: 0 1 auto;
max-width: min(100%, 280px);
height: 24px;
box-sizing: border-box;
margin: 0;
padding: 0 4px 0 8px;
overflow: hidden;
border: 0;
border-radius: 0;
appearance: none;
background: var(--stats-layer-2);
color: var(--stats-hero-muted);
font: inherit;
font-size: 13px;
font-weight: 500;
line-height: 1.1;
text-align: center;
white-space: nowrap;
cursor: pointer;
list-style: none;
}
[data-page="stats"] [data-component="model-hero-menu"] summary::-webkit-details-marker {
display: none;
[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"][data-current="true"],
[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"]:hover,
[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"][data-expanded] {
color: var(--stats-text);
}
[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"]:focus-visible {
color: var(--stats-text);
outline: 1px solid var(--stats-text);
outline-offset: 2px;
}
[data-page="stats"] [data-slot="stats-breadcrumb-select-value"] {
min-width: 0;
overflow: hidden;
}
[data-page="stats"] [data-slot="stats-breadcrumb-select-value"] span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-page="stats"] [data-slot="stats-breadcrumb-select-icon"] {
display: inline-flex;
flex: 0 0 auto;
color: var(--stats-faint);
}
[data-page="stats"] [data-slot="stats-breadcrumb-select-icon"] svg {
width: 16px;
height: 16px;
color: currentColor;
}
[data-page="stats"]
[data-slot="stats-breadcrumb-select-trigger"][data-expanded]
[data-slot="stats-breadcrumb-select-icon"] {
transform: rotate(180deg);
}
[data-page="stats"] [data-popper-positioner]:has([data-component="stats-breadcrumb-select-content"]) {
z-index: 20;
}
[data-page="stats"] [data-component="stats-breadcrumb-select-content"] {
z-index: 20;
width: max-content;
min-width: 180px;
max-width: min(360px, calc(100vw - 48px));
overflow: hidden;
border: 1px solid var(--stats-line);
background: var(--stats-bg);
box-shadow: 0 12px 32px #0000001a;
}
[data-page="stats"] [data-component="stats-breadcrumb-select-content"][data-variant="lab"] {
max-width: min(280px, calc(100vw - 48px));
}
[data-page="stats"] [data-slot="stats-breadcrumb-select-listbox"] {
display: grid;
max-height: min(360px, calc(100vh - 160px));
box-sizing: border-box;
margin: 0;
padding: 6px;
overflow: auto;
list-style: none;
outline: none;
}
[data-page="stats"] [data-slot="stats-breadcrumb-select-option"] {
display: flex;
align-items: center;
min-width: 0;
height: 30px;
box-sizing: border-box;
padding: 0 8px;
overflow: hidden;
color: var(--stats-muted);
font-size: 13px;
font-weight: 500;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
outline: none;
}
[data-page="stats"] [data-slot="stats-breadcrumb-select-option"][data-highlighted],
[data-page="stats"] [data-slot="stats-breadcrumb-select-option"][data-selected] {
background: var(--stats-layer-2);
color: var(--stats-text);
}
[data-page="stats"] a[data-slot="model-hero-crumb"]:hover,
[data-page="stats"] a[data-slot="model-hero-crumb"]:focus-visible,
[data-page="stats"] summary[data-slot="model-hero-crumb"]:hover,
[data-page="stats"] summary[data-slot="model-hero-crumb"]:focus-visible {
[data-page="stats"] a[data-slot="model-hero-crumb"]:focus-visible {
color: var(--stats-text);
outline: none;
text-decoration: none;
@ -3151,58 +3202,7 @@
flex: 0 0 auto;
width: 16px;
height: 16px;
color: var(--stats-faint);
}
[data-page="stats"] [data-component="model-hero-menu"][open] [data-slot="model-hero-crumb"] svg {
transform: rotate(180deg);
}
[data-page="stats"] [data-slot="model-hero-options"] {
position: absolute;
top: calc(100% + 8px);
left: 50%;
z-index: 5;
display: grid;
width: max-content;
min-width: 180px;
max-width: min(360px, calc(100vw - 48px));
max-height: min(360px, calc(100vh - 160px));
padding: 6px;
overflow: auto;
border: 1px solid var(--stats-line);
background: var(--stats-bg);
box-shadow: 0 12px 32px #0000001a;
transform: translateX(-50%);
}
[data-page="stats"] [data-slot="model-hero-option"] {
display: flex;
align-items: center;
min-width: 0;
height: 30px;
padding: 0 8px;
overflow: hidden;
color: var(--stats-muted);
font-size: 13px;
font-weight: 500;
line-height: 1.2;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-page="stats"] [data-slot="model-hero-option"]:hover,
[data-page="stats"] [data-slot="model-hero-option"]:focus-visible {
background: var(--stats-layer-2);
color: var(--stats-text);
outline: none;
text-decoration: none;
}
[data-page="stats"] [data-slot="model-hero-option"][data-current="true"] {
background: var(--stats-layer-2);
color: var(--stats-text);
color: currentColor;
}
[data-page="stats"] [data-slot="model-hero-separator"] {
@ -5533,6 +5533,14 @@
white-space: nowrap;
}
[data-page="stats"] a[data-slot="compare-home-crumb"] {
color: var(--stats-hero-muted);
}
[data-page="stats"] [data-component="compare-home-breadcrumb"] [data-slot="compare-home-crumb"][data-current="true"] {
color: var(--stats-text);
}
[data-page="stats"] a[data-slot="compare-home-crumb"]:hover,
[data-page="stats"] a[data-slot="compare-home-crumb"]:focus-visible {
color: var(--stats-text);
@ -7876,6 +7884,10 @@
max-width: min(100%, 240px);
}
[data-page="stats"] [data-component="model-hero-breadcrumb"] [data-slot="stats-breadcrumb-select-trigger"] {
max-width: min(100%, 240px);
}
[data-page="stats"] [data-slot="model-hero-title-row"] {
flex-wrap: wrap;
gap: 16px;
@ -7967,10 +7979,6 @@
min-width: 48px;
}
[data-page="stats"] [data-slot="lab-hero-options"] {
max-width: calc(100vw - 48px);
}
[data-page="stats"] [data-component="lab-related-list"] {
grid-auto-columns: minmax(320px, 392px);
grid-auto-flow: column;

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-core",
"version": "1.17.18",
"version": "1.17.20",
"private": true,
"type": "module",
"license": "MIT",

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-server",
"version": "1.17.18",
"version": "1.17.20",
"private": true,
"type": "module",
"license": "MIT",

View file

@ -60,6 +60,10 @@ export function isPromptEqual(a: Prompt, b: Prompt) {
return a.every((part, i) => JSON.stringify(part) === JSON.stringify(b[i]))
}
export function isCommentItem(item: ContextItem) {
return !!item.comment?.trim()
}
export function createPromptState() {
const [store, setStore] = createStore({
prompt: clonePrompt(DEFAULT_PROMPT),

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/tui",
"version": "1.17.18",
"version": "1.17.20",
"private": true,
"type": "module",
"license": "MIT",

View file

@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.17.18",
"version": "1.17.20",
"type": "module",
"license": "MIT",
"repository": {

Some files were not shown because too many files have changed in this diff Show more