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
commit 634386fe0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
116 changed files with 3764 additions and 1016 deletions

View file

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

130
bun.lock
View file

@ -32,7 +32,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@corvu/drawer": "catalog:", "@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0", "@dnd-kit/abstract": "0.5.0",
@ -157,7 +157,7 @@
}, },
"packages/codemode": { "packages/codemode": {
"name": "@opencode-ai/codemode", "name": "@opencode-ai/codemode",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"acorn": "8.15.0", "acorn": "8.15.0",
"effect": "catalog:", "effect": "catalog:",
@ -171,7 +171,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@ -207,7 +207,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@ -234,7 +234,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.82", "@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@ -256,7 +256,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@ -280,7 +280,7 @@
}, },
"packages/console/support": { "packages/console/support": {
"name": "@opencode-ai/console-support", "name": "@opencode-ai/console-support",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*", "@opencode-ai/console-core": "workspace:*",
@ -300,7 +300,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"version": "1.17.18", "version": "1.17.20",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@ -308,7 +308,7 @@
"@ai-sdk/alibaba": "1.0.17", "@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82", "@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/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27", "@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/deepinfra": "2.0.41",
@ -317,7 +317,7 @@
"@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27", "@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/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26", "@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
@ -354,7 +354,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.10.0", "gitlab-ai-provider": "6.11.1",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@ -396,7 +396,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@zip.js/zip.js": "2.7.62", "@zip.js/zip.js": "2.7.62",
"effect": "catalog:", "effect": "catalog:",
@ -456,7 +456,7 @@
}, },
"packages/effect-drizzle-sqlite": { "packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite", "name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@ -470,7 +470,7 @@
}, },
"packages/effect-sqlite-node": { "packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node", "name": "@opencode-ai/effect-sqlite-node",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"effect": "catalog:", "effect": "catalog:",
}, },
@ -482,7 +482,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@hono/standard-validator": "catalog:", "@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@ -514,7 +514,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@ -530,7 +530,7 @@
}, },
"packages/http-recorder": { "packages/http-recorder": {
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@effect/platform-node-shared": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83",
}, },
@ -562,7 +562,7 @@
}, },
"packages/llm": { "packages/llm": {
"name": "@opencode-ai/llm", "name": "@opencode-ai/llm",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14", "@smithy/eventstream-codec": "4.2.14",
@ -581,7 +581,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.17.18", "version": "1.17.20",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@ -592,7 +592,7 @@
"@ai-sdk/alibaba": "1.0.17", "@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82", "@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/cerebras": "2.0.60",
"@ai-sdk/cohere": "3.0.27", "@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/deepinfra": "2.0.41",
@ -601,7 +601,7 @@
"@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27", "@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/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26", "@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
@ -656,7 +656,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.10.0", "gitlab-ai-provider": "6.11.1",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@ -714,7 +714,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
@ -801,7 +801,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@ -816,7 +816,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@opencode-ai/server", "name": "@opencode-ai/server",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@ -833,7 +833,7 @@
}, },
"packages/session-ui": { "packages/session-ui": {
"name": "@opencode-ai/session-ui", "name": "@opencode-ai/session-ui",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@ -894,7 +894,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@ -907,9 +907,10 @@
}, },
"packages/stats/app": { "packages/stats/app": {
"name": "@opencode-ai/stats-app", "name": "@opencode-ai/stats-app",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@opencode-ai/stats-core": "workspace:*", "@opencode-ai/stats-core": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
@ -940,7 +941,7 @@
}, },
"packages/stats/core": { "packages/stats/core": {
"name": "@opencode-ai/stats-core", "name": "@opencode-ai/stats-core",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@aws-sdk/client-athena": "3.933.0", "@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0", "@planetscale/database": "1.19.0",
@ -959,7 +960,7 @@
}, },
"packages/stats/server": { "packages/stats/server": {
"name": "@opencode-ai/stats-server", "name": "@opencode-ai/stats-server",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@aws-sdk/client-firehose": "3.933.0", "@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
@ -1000,7 +1001,7 @@
}, },
"packages/tui": { "packages/tui": {
"name": "@opencode-ai/tui", "name": "@opencode-ai/tui",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@ -1030,7 +1031,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
@ -1081,7 +1082,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@ -1152,6 +1153,7 @@
], ],
"patchedDependencies": { "patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@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", "@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", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.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", "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", "@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", "@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", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@ai-sdk/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", "pacote@21.5.0": "patches/pacote@21.5.0.patch",
}, },
"overrides": { "overrides": {
@ -1198,7 +1198,7 @@
"@solidjs/router": "0.15.4", "@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"@tanstack/solid-virtual": "3.13.28", "@tanstack/solid-virtual": "3.13.32",
"@tsconfig/bun": "1.0.9", "@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.13", "@types/bun": "1.3.13",
@ -1218,7 +1218,7 @@
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
"luxon": "3.6.1", "luxon": "3.6.1",
"marked": "17.0.1", "marked": "17.0.6",
"marked-shiki": "1.2.1", "marked-shiki": "1.2.1",
"opentui-spinner": "0.0.7", "opentui-spinner": "0.0.7",
"remeda": "2.26.0", "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/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=="], "@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/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=="], "@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-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=="], "@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/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=="], "@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=="], "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=="], "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=="], "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=="], "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/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=="], "@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/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=="], "@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/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=="], "@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/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=="], "@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=="], "@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/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/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="], "ai-gateway-provider/@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/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=="], "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=="], "@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/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=="], "@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/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=="], "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=="], "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/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=="], "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=="], "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=="], "@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/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=="], "@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/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=="], "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=="], "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/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-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=="], "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": { "nodeModules": {
"x86_64-linux": "sha256-JTtn+wXTXg+yklvIMDLcGFaYhTU6ZrCgKT9JTNEQ3gA=", "x86_64-linux": "sha256-N4zM1zNufSg8DrDWOHWJYgVpn6vDghX/CJ0pym9ItxI=",
"aarch64-linux": "sha256-gXU6zyhvAZrZirkL/PlHdkHtEof/7PVSPCaE34Jnd4U=", "aarch64-linux": "sha256-Votrb6IbVt6OS5pcAlBd3L2btkZHa62Eu3mAFzKSlGM=",
"aarch64-darwin": "sha256-Q0oTG3uzOlD/X2kJingLle529lKFoTpyCW2rHXOZ6iE=", "aarch64-darwin": "sha256-Ofmy6plO4CFt/DoVdyt3Sr2rk6VJhas4zXq3DnvP/6A=",
"x86_64-darwin": "sha256-LINvKHxPibTlJeNzfACQx0x+Yj5oROT6Du3I5AtqqXk=" "x86_64-darwin": "sha256-LOeqfqlPbhp1c0Gq56fvKSzve7dvcCwlooTmDMFMznw="
} }
} }

View file

@ -49,7 +49,7 @@
"@opentui/core": "0.4.3", "@opentui/core": "0.4.3",
"@opentui/keymap": "0.4.3", "@opentui/keymap": "0.4.3",
"@opentui/solid": "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", "@shikijs/stream": "4.2.0",
"ulid": "3.0.1", "ulid": "3.0.1",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
@ -76,7 +76,7 @@
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"luxon": "3.6.1", "luxon": "3.6.1",
"marked": "17.0.1", "marked": "17.0.6",
"marked-shiki": "1.2.1", "marked-shiki": "1.2.1",
"remend": "1.3.0", "remend": "1.3.0",
"@playwright/test": "1.59.1", "@playwright/test": "1.59.1",
@ -161,10 +161,9 @@
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.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", "@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", "@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", "@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 panel = page.locator("#review-panel")
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]') 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" }) const contextButton = page.getByRole("button", { name: "View context usage" })
await contextButton.click() await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await panel.getByRole("button", { name: "Open file" }).click() await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeDisabled()
await expect(sidebar).toBeVisible() await expect(sidebar).toBeVisible()
await contextButton.click() await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") 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() await panel.getByRole("button", { name: "Open file" }).click()
const filter = panel.getByRole("combobox", { name: "Filter files" }) const filter = panel.getByRole("combobox", { name: "Filter files" })
await expect(filter).toBeFocused() 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 panel.getByRole("button", { name: "README.md" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "") 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(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(sidebar).toHaveCount(0) 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 expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
await filter.press("Enter") await filter.press("Enter")
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") 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() await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 }) expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
await panel.getByRole("button", { name: "Open file" }).click() await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1) await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") 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 page.keyboard.press("Control+w")
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0) await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") 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 expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click() await page.getByRole("button", { name: "Toggle review" }).click()
const reviewTab = page.getByRole("tab", { name: /Review/ }) const reviewTab = page.locator("#session-side-panel-review-tab")
const reviewTabPanel = page.getByRole("tabpanel", { name: /Review/ }) const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
await expect(reviewTab).toHaveAttribute("aria-controls", "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") await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
const review = page.locator('#review-panel [data-component="session-review-v2"]') 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 expectTree(page, 8, "git-0.ts")
await selectMode(page, "Git changes", "Branch changes") 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 page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible() await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_773, "action.yml") await expectTree(page, 2_773, "action.yml")

View file

@ -1,6 +1,7 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/RequestDocks" const directory = "C:/OpenCode/RequestDocks"
@ -100,6 +101,67 @@ test("shows a pending permission dock", async ({ page }) => {
expect(request.postDataJSON()).toEqual({ response: "once" }) 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( async function mockServer(
page: Page, page: Page,
requests: { requests: {

View file

@ -150,7 +150,10 @@ test.describe("session timeline projection", () => {
parentID: "msg_2000_diff_next_user", parentID: "msg_2000_diff_next_user",
created: 1700000011000, 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]") }) const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = 0)) await scroller.evaluate((element) => (element.scrollTop = 0))

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.17.18", "version": "1.17.20",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "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 { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { createMemo, For, Show } from "solid-js" import { For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { type LocalProject, getAvatarColors } from "@/context/layout" import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/core/util/path"
import { Avatar } from "@opencode-ai/ui/avatar" import { Avatar } from "@opencode-ai/ui/avatar"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getProjectAvatarSource } from "@/pages/layout/helpers" import { getProjectAvatarSource } from "@/pages/layout/helpers"
import { ServerConnection } from "@/context/server" 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 const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) { export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
const dialog = useDialog()
const global = useGlobal()
const language = useLanguage() const language = useLanguage()
const serverCtx = createMemo(() => global.ensureServerCtx(props.server)) const model = createEditProjectModel(props)
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()
}
return ( return (
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto"> <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"> <div class="flex flex-col gap-4">
<TextField <TextField
autofocus autofocus
type="text" type="text"
label={language.t("dialog.project.edit.name")} label={language.t("dialog.project.edit.name")}
placeholder={folderName()} placeholder={model.folderName()}
value={store.name} value={model.store.name}
onChange={(v) => setStore("name", v)} onChange={(v) => model.setStore("name", v)}
/> />
<div class="flex flex-col gap-2"> <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="flex gap-3 items-start">
<div <div
class="relative" class="relative"
onMouseEnter={() => setStore("iconHover", true)} onMouseEnter={() => model.setStore("iconHover", true)}
onMouseLeave={() => setStore("iconHover", false)} onMouseLeave={() => model.setStore("iconHover", false)}
> >
<div <div
class="relative size-16 rounded-md transition-colors cursor-pointer" class="relative size-16 rounded-md transition-colors cursor-pointer"
classList={{ classList={{
"border-text-interactive-base bg-surface-info-base/20": store.dragOver, "border-text-interactive-base bg-surface-info-base/20": model.store.dragOver,
"border-border-base hover:border-border-strong": !store.dragOver, "border-border-base hover:border-border-strong": !model.store.dragOver,
"overflow-hidden": !!store.iconOverride, "overflow-hidden": !!model.store.iconOverride,
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => {
if (store.iconOverride && store.iconHover) {
clearIcon()
} else {
iconInput?.click()
}
}} }}
onDrop={model.drop}
onDragOver={model.dragOver}
onDragLeave={model.dragLeave}
onClick={model.iconClick}
> >
<Show <Show
when={getProjectAvatarSource(props.project.id, { when={getProjectAvatarSource(props.project.id, {
color: store.color, color: model.store.color,
url: props.project.icon?.url, url: props.project.icon?.url,
override: store.iconOverride, override: model.store.iconOverride,
})} })}
fallback={ fallback={
<div class="size-full flex items-center justify-center"> <div class="size-full flex items-center justify-center">
<Avatar <Avatar
fallback={store.name || defaultName()} fallback={model.store.name || model.defaultName()}
{...getAvatarColors(store.color)} {...getAvatarColors(model.store.color)}
class="size-full text-[32px]" class="size-full text-[32px]"
/> />
</div> </div>
@ -174,8 +77,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<div <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" 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={{ classList={{
"opacity-100": store.iconHover && !store.iconOverride, "opacity-100": model.store.iconHover && !model.store.iconOverride,
"opacity-0": !(store.iconHover && !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" /> <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 <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" 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={{ classList={{
"opacity-100": store.iconHover && !!store.iconOverride, "opacity-100": model.store.iconHover && !!model.store.iconOverride,
"opacity-0": !(store.iconHover && !!store.iconOverride), "opacity-0": !(model.store.iconHover && !!model.store.iconOverride),
}} }}
> >
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" /> <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 <input
id="icon-upload" id="icon-upload"
ref={(el) => { ref={(el) => {
iconInput = el model.setIconInput(el)
}} }}
type="file" type="file"
accept="image/*" accept="image/*"
class="hidden" class="hidden"
onChange={handleInputChange} onChange={model.inputChange}
/> />
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center"> <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> <span>{language.t("dialog.project.edit.icon.hint")}</span>
@ -207,7 +110,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
</div> </div>
</div> </div>
<Show when={!store.iconOverride}> <Show when={!model.store.iconOverride}>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label> <label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
<div class="flex gap-1.5"> <div class="flex gap-1.5">
@ -216,21 +119,21 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<button <button
type="button" type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })} aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={store.color === color} aria-pressed={model.store.color === color}
classList={{ classList={{
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true, "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": "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": "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
store.color !== color, model.store.color !== color,
}} }}
onClick={() => { onClick={() => {
if (store.color === color && !props.project.icon?.url) return if (model.store.color === color && !props.project.icon?.url) return
setStore("color", store.color === color ? undefined : color) model.setStore("color", model.store.color === color ? undefined : color)
}} }}
> >
<Avatar <Avatar
fallback={store.name || defaultName()} fallback={model.store.name || model.defaultName()}
{...getAvatarColors(color)} {...getAvatarColors(color)}
class="size-full rounded" class="size-full rounded"
/> />
@ -246,19 +149,19 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
label={language.t("dialog.project.edit.worktree.startup")} label={language.t("dialog.project.edit.worktree.startup")}
description={language.t("dialog.project.edit.worktree.startup.description")} description={language.t("dialog.project.edit.worktree.startup.description")}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")} placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
value={store.startup} value={model.store.startup}
onChange={(v) => setStore("startup", v)} onChange={(v) => model.setStore("startup", v)}
spellcheck={false} spellcheck={false}
class="max-h-14 w-full overflow-y-auto font-mono text-xs" class="max-h-14 w-full overflow-y-auto font-mono text-xs"
/> />
</div> </div>
<div class="flex justify-end gap-2"> <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")} {language.t("common.cancel")}
</Button> </Button>
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}> <Button type="submit" variant="primary" size="large" disabled={model.save.isPending}>
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")} {model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</Button> </Button>
</div> </div>
</form> </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 { 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", () => { describe("buildFileTreeV2Model", () => {
test("builds sorted depth-first rows", () => { 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"]) const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"])
expect(model.total).toBe(8) expect(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"]) const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"])
expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([ 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 model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"])
const rows = flattenFileTreeV2(model, () => true) 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.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") 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", () => { test("handles deeply nested paths", () => {
const file = `${Array.from({ length: 130 }, (_, index) => `dir-${index}`).join("/")}/file.ts` const file = Array.from({ length: 130 }, (_, index) => `d${index}`).join("/") + "/leaf.ts"
const model = buildFileTreeV2Model([file]) const model = buildFileTreeV2Model([file])
expect(flattenFileTreeV2(model, () => true)).toHaveLength(131) 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 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 { Icon } from "@opencode-ai/ui/v2/icon"
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree" import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" 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" import { virtualScrollElement } from "@/components/virtual-scroll-element"
export type { Kind } from "@/components/file-tree" export type { Kind } from "@/components/file-tree"
@ -36,7 +42,7 @@ function guideLineLeft(level: number) {
export const kindLabel = (kind: Kind) => { export const kindLabel = (kind: Kind) => {
if (kind === "add") return "A" if (kind === "add") return "A"
if (kind === "del") return "D" if (kind === "del") return "D"
return "" return "M"
} }
export const kindChange = (kind: Kind) => { export const kindChange = (kind: Kind) => {
@ -68,7 +74,7 @@ const FileTreeNodeV2 = (
"class", "class",
"classList", "classList",
]) ])
const kind = () => local.kinds?.get(local.node.path) const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path))
return ( return (
<Dynamic <Dynamic
@ -110,12 +116,7 @@ const FileTreeNodeV2 = (
function GuideLines(props: { level: number }) { function GuideLines(props: { level: number }) {
return ( return (
<For each={Array.from({ length: props.level })}> <For each={Array.from({ length: props.level })}>
{(_, index) => ( {(_, index) => <div data-slot="file-tree-v2-guide" style={`left: ${guideLineLeft(index())}px`} />}
<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`}
/>
)}
</For> </For>
) )
} }
@ -126,12 +127,18 @@ export default function FileTreeV2(props: {
kinds?: ReadonlyMap<string, Kind> kinds?: ReadonlyMap<string, Kind>
draggable?: boolean draggable?: boolean
onFileClick?: (file: FileNode) => void onFileClick?: (file: FileNode) => void
onFileDoubleClick?: (file: FileNode) => void
}) { }) {
const file = useFile() const file = useFile()
const live = () => props.allowed === undefined
const draggable = () => props.draggable ?? true const draggable = () => props.draggable ?? true
const active = () => normalizeFileTreeV2Path(props.active ?? "") const active = () => normalizeFileTreeV2Path(props.active ?? "")
const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? [])) const model = createMemo(() => (live() ? undefined : buildFileTreeV2Model(props.allowed ?? [])))
const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true)) 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 [root, setRoot] = createSignal<HTMLDivElement>()
const [focused, setFocused] = createSignal<string>() const [focused, setFocused] = createSignal<string>()
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({ const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
@ -155,16 +162,49 @@ export default function FileTreeV2(props: {
return [...indexes, index].sort((a, b) => a - b) 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(() => { createEffect(() => {
const path = active() const path = active()
if (!path) return if (!path) {
scrolledActive = undefined
return
}
const index = rows().findIndex((row) => row.node.path === path) const index = rows().findIndex((row) => row.node.path === path)
if (index < 0) return if (index < 0) return
if (scrolledActive === path) return
scrolledActive = path
queueMicrotask(() => { queueMicrotask(() => {
if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return const next = rows().findIndex((row) => row.node.path === path)
virtualizer.scrollToIndex(index, { align: "auto" }) 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 rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const)))
const virtualItemByKey = createMemo( const virtualItemByKey = createMemo(
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)), () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
@ -175,7 +215,7 @@ export default function FileTreeV2(props: {
<div <div
ref={setRoot} ref={setRoot}
data-component="file-tree-v2" data-component="file-tree-v2"
data-total-rows={model().total} data-total-rows={live() ? rows().length : model()!.total}
class="group/file-tree-v2" class="group/file-tree-v2"
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }} style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
> >
@ -209,13 +249,8 @@ export default function FileTreeV2(props: {
class="relative" class="relative"
onFocus={() => setFocused(row().node.path)} onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)} onBlur={() => setFocused(undefined)}
onClick={() => onClick={() => selectFile(row().node, props.onFileClick)}
props.onFileClick?.({ onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
...row().node,
path: row().node.originalPath,
absolute: row().node.originalPath,
})
}
> >
<GuideLines level={row().level} /> <GuideLines level={row().level} />
<Show when={row().level > 0}> <Show when={row().level > 0}>
@ -239,17 +274,13 @@ export default function FileTreeV2(props: {
class="relative" class="relative"
onFocus={() => setFocused(row().node.path)} onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)} onBlur={() => setFocused(undefined)}
aria-expanded={file.tree.state(row().node.path)?.expanded ?? true} aria-expanded={expanded(row().node.path)}
onClick={() => onClick={() => toggleDirectory(row().node.path, row().node.originalPath)}
file.tree.state(row().node.path)?.expanded === false
? file.tree.expand(row().node.path, { list: false })
: file.tree.collapse(row().node.path)
}
> >
<GuideLines level={row().level} /> <GuideLines level={row().level} />
<div <div
data-slot="file-tree-v2-chevron" 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" class="size-4 flex items-center justify-center"
> >
<Icon name="chevron-down" /> <Icon name="chevron-down" />

View file

@ -19,6 +19,7 @@ import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/f
import { import {
ContentPart, ContentPart,
DEFAULT_PROMPT, DEFAULT_PROMPT,
isCommentItem,
isPromptEqual, isPromptEqual,
Prompt, Prompt,
usePrompt, usePrompt,
@ -632,7 +633,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
const handleBlur = () => { const handleBlur = () => {
savedCursor = currentCursor() const cursor = currentCursor()
savedCursor = cursor
if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor)
closePopover() closePopover()
setComposing(false) setComposing(false)
} }
@ -1626,7 +1629,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)} )}
/> />
<PromptContextItems <PromptContextItems
items={contextItems()} items={contextItems().filter((item) => !isCommentItem(item))}
active={(item) => { active={(item) => {
const active = comments.active() const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file 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) if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key) prompt.context.remove(item.key)
}} }}
newLayoutDesigns={props.controls.newLayoutDesigns}
t={(key) => language.t(key as Parameters<typeof language.t>[0])} t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/> />
<PromptImageAttachments <PromptImageAttachments
@ -1645,6 +1649,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
onRemove={removeAttachment} onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")} 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 <div
class="relative min-h-[52px]" class="relative min-h-[52px]"
@ -1852,6 +1867,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (item.commentID) comments.remove(item.path, item.commentID) if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key) prompt.context.remove(item.key)
}} }}
newLayoutDesigns={props.controls.newLayoutDesigns}
t={(key) => language.t(key as Parameters<typeof language.t>[0])} t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/> />
<PromptImageAttachments <PromptImageAttachments
@ -1861,6 +1877,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
onRemove={removeAttachment} onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")} removeLabel={language.t("prompt.attachment.remove")}
newLayoutDesigns={props.controls.newLayoutDesigns}
/> />
<div <div
class="relative" class="relative"
@ -2341,7 +2358,7 @@ function ModelControlContent(props: { state: ComposerModelControlState; v2?: boo
/> />
)} )}
</Show> </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"}> <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" /> <Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span> </span>

View file

@ -1,6 +1,8 @@
import { Component, For, Show } from "solid-js" import { Component, For, Show } from "solid-js"
import { Dynamic } from "solid-js/web"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { IconButton } from "@opencode-ai/ui/icon-button" 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 { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path" import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path"
import type { ContextItem } from "@/context/prompt" import type { ContextItem } from "@/context/prompt"
@ -12,6 +14,7 @@ type ContextItemsProps = {
active: (item: PromptContextItem) => boolean active: (item: PromptContextItem) => boolean
openComment: (item: PromptContextItem) => void openComment: (item: PromptContextItem) => void
remove: (item: PromptContextItem) => void remove: (item: PromptContextItem) => void
newLayoutDesigns: boolean
t: (key: string) => string t: (key: string) => string
} }
@ -27,10 +30,17 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
const selected = props.active(item) const selected = props.active(item)
return ( return (
<TooltipV2 <Dynamic
component={props.newLayoutDesigns ? TooltipV2 : Tooltip}
value={ value={
<span class="flex max-w-[300px]"> <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} {directory}
</span> </span>
<span class="shrink-0">{filename}</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>} {(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
</Show> </Show>
</div> </div>
</TooltipV2> </Dynamic>
) )
}} }}
</For> </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 { Component, For, Show } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon" 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 { 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 = { type PromptImageAttachmentsProps = {
attachments: ImageAttachmentPart[] attachments: ImageAttachmentPart[]
onOpen: (attachment: ImageAttachmentPart) => void onOpen: (attachment: ImageAttachmentPart) => void
onRemove: (id: string) => void onRemove: (id: string) => void
removeLabel: string 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 fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
const imageClass = const imageClass =
"size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors" "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 = 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" "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" 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) => { export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
return ( return (
<Show when={props.attachments.length > 0}> <Show when={props.attachments.length > 0 || (props.newLayoutDesigns && (props.comments?.length ?? 0) > 0)}>
<div class="flex flex-wrap gap-2 px-3 pt-3"> <div data-slot="prompt-attachments" classList={{ relative: props.newLayoutDesigns }}>
<For each={props.attachments}> <div
{(attachment) => ( data-slot="prompt-attachments-scroll"
<Tooltip value={attachment.filename} placement="top" contentClass="break-all"> classList={{
<div class="relative group"> "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 <Show
when={attachment.mime.startsWith("image/")} when={image}
fallback={ fallback={
<div class={fallbackClass}> <Show
<Icon name="folder" class="size-6 text-text-weak" /> when={props.newLayoutDesigns}
</div> 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 <img
src={attachment.dataUrl} src={attachment.dataUrl}
alt={attachment.filename} alt={attachment.filename}
class={imageClass} class={props.newLayoutDesigns ? imageClassV2 : imageClass}
onClick={() => props.onOpen(attachment)} onClick={() => props.onOpen(attachment)}
/> />
</Show> </Show>
<button )
type="button" const name = () => (
onClick={() => props.onRemove(attachment.id)}
class={removeClass}
aria-label={props.removeLabel}
>
<Icon name="close" class="size-3 text-text-weak" />
</button>
<div class={nameClass}> <div class={nameClass}>
<span class="text-10-regular text-white truncate block">{attachment.filename}</span> <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
</div> </div>
</div> )
</Tooltip> const remove = () => (
)} <button
</For> 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> </div>
</Show> </Show>
) )

View file

@ -1,5 +1,6 @@
import { For, Show } from "solid-js" import { For, Show } from "solid-js"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" 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 } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
@ -96,10 +97,17 @@ export function PromptWorkspaceSelector(props: {
{(branch) => ( {(branch) => (
<> <>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span> <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]"> <TooltipV2
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" /> placement="top"
<span class="min-w-0 truncate">{branch()}</span> value={branch()}
</div> 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> </Show>

View file

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

View file

@ -47,12 +47,20 @@ export function getAvatarColors(key?: string) {
} }
export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant { 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 === "mint") return "cyan"
if (key === "lime") return "green" 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" return "gray"
} }

View file

@ -37,6 +37,9 @@ type PlatformBase = {
/** Open a local path in a local app (desktop only) */ /** Open a local path in a local app (desktop only) */
openPath?(path: string, app?: string): Promise<void> 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 the app */
restart(): Promise<void> restart(): Promise<void>

View file

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

View file

@ -24,6 +24,7 @@ export {
createPromptSession, createPromptSession,
createPromptState, createPromptState,
DEFAULT_PROMPT, DEFAULT_PROMPT,
isCommentItem,
isPromptEqual, isPromptEqual,
} from "./prompt-state" } from "./prompt-state"
export type { 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.description": "This tab points to a session that no longer exists on this server.",
"session.error.notFound.closeTab": "Close Tab", "session.error.notFound.closeTab": "Close Tab",
"session.error.serverConnection": "Can't connect to this server", "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.one": "Change",
"session.review.change.other": "Changes", "session.review.change.other": "Changes",
"session.review.loadingChanges": "Loading 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" import { shouldOpenSessionInBackground } from "./home-session-open"
describe("shouldOpenSessionInBackground", () => { 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", () => { test("requires only the platform primary modifier", () => {
expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: false, alt: false })).toBe(true) expect(
expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: false })).toBe(true) shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: false, alt: false }),
expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: true, alt: false })).toBe(false) ).toBe(true)
expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: true })).toBe(false) expect(
expect(shouldOpenSessionInBackground({ mac: false, meta: true, ctrl: false, shift: false, alt: false })).toBe(false) 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: { export function shouldOpenSessionInBackground(input: {
button: number
mac: boolean mac: boolean
meta: boolean meta: boolean
ctrl: boolean ctrl: boolean
shift: boolean shift: boolean
alt: boolean alt: boolean
}) { }) {
if (input.button === 1) return true
if (input.button !== 0) return false
if (input.shift || input.alt) return false if (input.shift || input.alt) return false
if (input.mac) return input.meta && !input.ctrl if (input.mac) return input.meta && !input.ctrl
return input.ctrl && !input.meta return input.ctrl && !input.meta

View file

@ -244,6 +244,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
// background without navigating, matching browser conventions. // background without navigating, matching browser conventions.
function isBackgroundOpen(event: MouseEvent) { function isBackgroundOpen(event: MouseEvent) {
return shouldOpenSessionInBackground({ return shouldOpenSessionInBackground({
button: event.button,
mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform), mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
meta: event.metaKey, meta: event.metaKey,
ctrl: event.ctrlKey, 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 { useDialog } from "@opencode-ai/ui/context/dialog"
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query" import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
import { import {
@ -57,6 +58,8 @@ import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { TerminalProvider, useTerminal } from "@/context/terminal" import { TerminalProvider, useTerminal } from "@/context/terminal"
import { PromptInput } from "@/components/prompt-input" 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 { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
import { import {
createPromptInputController, createPromptInputController,
@ -79,6 +82,7 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { sessionPanelLayout } from "@/pages/session/session-panel-layout" import { sessionPanelLayout } from "@/pages/session/session-panel-layout"
import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2" 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 { 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 { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds" 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 (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
if (composer.blocked() || isChildSession()) return 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) })) .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(() => { createEffect(() => {
const sessionID = params.id const sessionID = params.id
@ -2232,6 +2262,13 @@ export default function Page() {
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()} reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
reviewCount={reviewCount} reviewCount={reviewCount}
reviewPanel={reviewPanelV2} reviewPanel={reviewPanelV2}
reviewSidebarToggle={(disabled) => (
<SessionReviewV2SidebarToggle
opened={reviewV2State.sidebarOpened()}
disabled={disabled}
onToggle={reviewV2State.toggleSidebar}
/>
)}
fileBrowserState={reviewV2State} fileBrowserState={reviewV2State}
activeDiff={activeReviewFile()} activeDiff={activeReviewFile()}
focusReviewDiff={focusReviewDiff} 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 { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener" 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 { useFileComponent } from "@opencode-ai/ui/context/file"
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge" import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { createLineCommentController } from "@opencode-ai/session-ui/line-comment-annotations" 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 { sampledChecksum } from "@opencode-ai/core/util/encode"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { IconButton } from "@opencode-ai/ui/icon-button" 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 { Tabs } from "@opencode-ai/ui/tabs"
import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
@ -16,6 +19,7 @@ import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt" import { usePrompt } from "@/context/prompt"
import { useSettings } from "@/context/settings"
import { getSessionHandoff } from "@/pages/session/handoff" import { getSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers" 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 } type ScrollPos = { x: number; y: number }
function createScrollSync(input: { tab: () => string; view: ReturnType<typeof useSessionLayout>["view"] }) { 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 }) { 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 file = useFile()
const comments = useComments() const comments = useComments()
const language = useLanguage() const language = useLanguage()
@ -463,3 +500,294 @@ export function SessionFileView(props: { tab: string }) {
return content() 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 { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media" 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 { Tabs } from "@opencode-ai/ui/tabs"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo" import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import type { DragEvent } from "@thisbeyond/solid-dnd" 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 type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import FileTree from "@/components/file-tree" import FileTree from "@/components/file-tree"
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
const reviewTabID = "session-side-panel-review-tab" const reviewTabID = "session-side-panel-review-tab"
const reviewTabPanelID = "session-side-panel-review-tabpanel" 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 { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file" import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs" import { FileTabContent } from "@/pages/session/file-tabs"
@ -47,6 +65,7 @@ export function SessionSidePanel(props: {
reviewHasFocusableContent: () => boolean reviewHasFocusableContent: () => boolean
reviewCount: () => number reviewCount: () => number
reviewPanel: () => JSX.Element reviewPanel: () => JSX.Element
reviewSidebarToggle?: (disabled: boolean) => JSX.Element
fileBrowserState?: SessionFileBrowserState fileBrowserState?: SessionFileBrowserState
activeDiff?: string activeDiff?: string
focusReviewDiff: (path: string) => void focusReviewDiff: (path: string) => void
@ -60,7 +79,9 @@ export function SessionSidePanel(props: {
const language = useLanguage() const language = useLanguage()
const command = useCommand() const command = useCommand()
const dialog = useDialog() const dialog = useDialog()
const sdk = useSDK()
const { sessionKey, tabs, view, params } = useSessionLayout() const { sessionKey, tabs, view, params } = useSessionLayout()
const projectDirectory = createMemo(() => sdk().directory)
const isDesktop = createMediaQuery("(min-width: 768px)") const isDesktop = createMediaQuery("(min-width: 768px)")
const shown = settings.visibility.fileTree const shown = settings.visibility.fileTree
@ -92,11 +113,9 @@ export function SessionSidePanel(props: {
return "mix" as const return "mix" as const
} }
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">() const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) { 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" const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
out.set(file, kind) out.set(file, kind)
@ -153,6 +172,7 @@ export function SessionSidePanel(props: {
fileBrowser: () => !!props.fileBrowserState, fileBrowser: () => !!props.fileBrowserState,
}) })
const contextOpen = tabState.contextOpen const contextOpen = tabState.contextOpen
const openFileOpen = tabState.openFileOpen
const panelTabs = tabState.panelTabs const panelTabs = tabState.panelTabs
const openedTabs = tabState.openedTabs const openedTabs = tabState.openedTabs
const activeTab = tabState.activeTab const activeTab = tabState.activeTab
@ -170,10 +190,8 @@ export function SessionSidePanel(props: {
layout.fileTree.setTab("all") layout.fileTree.setTab("all")
} }
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
})
let fileFilter: HTMLInputElement | undefined let fileFilter: HTMLInputElement | undefined
let tabList: HTMLDivElement | undefined
const temporaryTab = tabs().preview const temporaryTab = tabs().preview
const previewTab = (value: string) => { const previewTab = (value: string) => {
const next = normalizeTab(value) const next = normalizeTab(value)
@ -196,10 +214,26 @@ export function SessionSidePanel(props: {
} }
const browserTab = createMemo(() => { const browserTab = createMemo(() => {
if (!props.fileBrowserState) return undefined 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() 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 handleDragStart = (event: unknown) => {
const id = getDraggableId(event) const id = getDraggableId(event)
@ -285,72 +319,283 @@ export function SessionSidePanel(props: {
"bg-background-base": !settings.general.newLayoutDesigns(), "bg-background-base": !settings.general.newLayoutDesigns(),
}} }}
> >
<DragDropProvider <Show
onDragStart={handleDragStart} when={props.fileBrowserState}
onDragEnd={handleDragEnd} fallback={
onDragOver={handleDragOver} <DragDropProvider
collisionDetector={closestCenter} onDragStart={handleDragStart}
> onDragEnd={handleDragEnd}
<DragDropSensors /> onDragOver={handleDragOver}
<ConstrainDragYAxis /> collisionDetector={closestCenter}
<Tabs value={activeTab()} onChange={activateTab}> >
<div class="sticky top-0 shrink-0 flex"> <DragDropSensors />
<Tabs.List <ConstrainDragYAxis />
ref={(el: HTMLDivElement) => { <Tabs value={activeTab()} onChange={activateTab}>
const stop = createFileTabListSync({ el, contextOpen }) <div class="sticky top-0 shrink-0 flex">
onCleanup(stop) <Tabs.List
}} ref={(el: HTMLDivElement) => {
> const stop = createFileTabListSync({ el, contextOpen })
<Show when={reviewTab() && props.canReview()}> onCleanup(stop)
<Tabs.Trigger }}
value="review"
id={reviewTabID}
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
> >
<div class="flex items-center gap-1.5"> <Show when={reviewTab() && props.canReview()}>
<div>{language.t("session.tab.review")}</div> <Tabs.Trigger
<Show when={props.hasReview()}> value="review"
<div>{props.reviewCount()}</div> id={reviewTabID}
</Show> aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
</div> >
</Tabs.Trigger> <div class="flex items-center gap-1.5">
</Show> <div>{language.t("session.tab.review")}</div>
<Show when={contextOpen()}> <Show when={props.hasReview()}>
<Tabs.Trigger <div>{props.reviewCount()}</div>
value="context" </Show>
closeButton={ </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 <TooltipKeybind
title={language.t("common.closeTab")} title={language.t("command.file.open")}
keybind={command.keybind("tab.close")} keybind={command.keybind("file.open")}
placement="bottom" class="flex items-center"
gutter={10}
> >
<IconButton <IconButton
icon="close-small" icon="plus-small"
variant="ghost" variant="ghost"
class="h-5 w-5" iconSize="large"
onClick={() => tabs().close("context")} class="!rounded-md"
aria-label={language.t("common.closeTab")} 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> </TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</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> </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()}> <For each={panelTabs()}>
{(tab) => ( {(tab) => (
<Show <Show
when={tab === SESSION_OPEN_FILE_TAB} when={tab === SESSION_OPEN_FILE_TAB}
fallback={ fallback={
<SortableTab <SortableTabV2
tab={tab} tab={tab}
index={() => tabs().all().indexOf(tab)}
temporary={temporaryTab() === tab} temporary={temporaryTab() === tab}
onTabClose={tabs().close} onTabClose={tabs().close}
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined} onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
@ -386,106 +631,104 @@ export function SessionSidePanel(props: {
</Show> </Show>
)} )}
</For> </For>
</SortableProvider> <div
<div class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center"
class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3" classList={{
classList={{ "bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(), "bg-background-stronger": !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"
> >
<IconButton <TooltipV2
icon="plus-small" value={
variant="ghost" <>
iconSize="large" {language.t("command.file.open")}
class="!rounded-md" <Show when={openFileKeybind().length > 0}>
onClick={() => { <KeybindV2 keys={openFileKeybind()} variant="neutral" />
if (props.fileBrowserState) { </Show>
openFileBrowser() </>
return }
} placement="bottom"
void import("@/components/dialog-select-file").then((x) => { class="flex items-center"
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />) >
}) <IconButtonV2
}} icon={<Icon name="plus-small" />}
aria-label={language.t("command.file.open")} variant="ghost-muted"
/> size="large"
</TooltipKeybind> 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> </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> </div>
</Show>
<Show when={activeTab() === "empty"}> <Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict"> <div
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> id={reviewTabPanelID}
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6"> role="tabpanel"
<Mark class="w-14 opacity-10" /> aria-labelledby={reviewTabID}
<div class="text-14-regular text-text-weak max-w-56"> tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
{language.t("session.files.selectToOpen")} 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> </div>
</div> </Tabs.Content>
</Tabs.Content> </Show>
</Show>
<Show when={activeTab() === "context"}> <Show when={activeTab() === "context"}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict"> <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"> <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab /> <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>
</div> </div>
) </Tabs.Content>
}} </Show>
</Show>
</DragOverlay> <Show when={fileBrowserMounted()}>
</DragDropProvider> <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>
</div> </div>
</Show> </Show>
@ -513,10 +756,19 @@ export function SessionSidePanel(props: {
> >
<Tabs.List> <Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}> <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{props.reviewCount()}{" "} <Show
{language.t( when={settings.general.newLayoutDesigns()}
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other", 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>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}> <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.files.all")} {language.t("session.files.all")}

View file

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

View file

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

View file

@ -38,6 +38,8 @@ export namespace Timeline {
showReasoning: boolean, showReasoning: boolean,
status: SessionStatus["type"], status: SessionStatus["type"],
isActive: boolean, isActive: boolean,
// v2 renders comments inside the user message attachments row instead of a strip row
inlineComments: boolean,
) { ) {
const rows: TimelineRow.TimelineRow[] = [] const rows: TimelineRow.TimelineRow[] = []
@ -74,7 +76,7 @@ export namespace Timeline {
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group })) : groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id })) if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id }))
if (comments.length > 0) if (comments.length > 0 && !inlineComments)
rows.push( rows.push(
new TimelineRow.CommentStrip({ new TimelineRow.CommentStrip({
userMessageID: userMessage.id, userMessageID: userMessage.id,
@ -84,7 +86,7 @@ export namespace Timeline {
rows.push( rows.push(
new TimelineRow.UserMessage({ new TimelineRow.UserMessage({
userMessageID: userMessage.id, 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, SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
SessionReviewV2, SessionReviewV2,
SessionReviewV2Sidebar, SessionReviewV2Sidebar,
SessionReviewV2SidebarToggle,
} from "@opencode-ai/session-ui/v2/session-review-v2" } from "@opencode-ai/session-ui/v2/session-review-v2"
import { SessionReviewFilePreviewV2 } from "@opencode-ai/session-ui/v2/session-review-file-preview-v2" import { SessionReviewFilePreviewV2 } from "@opencode-ai/session-ui/v2/session-review-file-preview-v2"
import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-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 searching = createMemo(() => props.state.filter().trim().length > 0)
const kinds = createMemo(() => reviewDiffKinds(diffs())) 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(() => { const activeDiff = createMemo(() => {
// A focused comment takes over the preview until the preview applies it and // 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. // 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()} />} stats={<DiffChanges changes={diffs()} />}
empty={props.empty} empty={props.empty}
sidebarOpen={props.state.sidebarOpened()} sidebarOpen={props.state.sidebarOpened()}
sidebarToggle={
<SessionReviewV2SidebarToggle opened={props.state.sidebarOpened()} onToggle={props.state.toggleSidebar} />
}
sidebar={ sidebar={
// Always mounted: the sidebar header hosts the changes-mode dropdown, // Always mounted: the sidebar header hosts the changes-mode dropdown,
// which must stay reachable when the current mode has zero diffs. // which must stay reachable when the current mode has zero diffs.
@ -126,7 +124,7 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
diffs={diffs} diffs={diffs}
filteredFiles={filteredFiles} filteredFiles={filteredFiles}
searching={searching} searching={searching}
kinds={kinds} kinds={treeKinds}
activeDiff={activeDiff} activeDiff={activeDiff}
/> />
} }

View file

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

View file

@ -1,8 +1,32 @@
import { expect, test } from "bun:test" 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 { createRoot, createSignal } from "solid-js"
import { filterVirtualIndexes } from "@/pages/session/timeline/virtual-items" 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", () => { test("reactive count updates preserve measured row sizes", () => {
createRoot((dispose) => { createRoot((dispose) => {
const [count, setCount] = createSignal(2) 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", () => { test("clamps oversized offsets with scroll margin and padding changes", () => {
createRoot((dispose) => { const options = (paddingEnd: number) => ({
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({ count: 20,
count: 2, estimateSize: () => 60,
getScrollElement: () => null, initialOffset: Number.MAX_SAFE_INTEGER,
estimateSize: () => 60, initialRect: { width: 800, height: 600 },
initialOffset: 100, scrollMargin: 64,
initialRect: { width: 800, height: 60 }, paddingEnd,
}) overscan: 1,
getScrollElement: () => null,
virtualizer.getTotalSize() scrollToFn: () => {},
virtualizer.resizeItem(0, 100) observeElementRect: () => {},
observeElementOffset: () => {},
expect(virtualizer.scrollOffset).toBe(100)
expect(virtualizer.getLogicalScrollOffset()).toBe(140)
dispose()
}) })
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", () => { 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", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/codemode", "name": "@opencode-ai/codemode",
"version": "1.17.18", "version": "1.17.20",
"description": "Effect-native confined code execution over schema-described tools", "description": "Effect-native confined code execution over schema-described tools",
"private": true, "private": true,
"type": "module", "type": "module",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.17.18", "version": "1.17.20",
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@ -64,7 +64,7 @@
"@ai-sdk/alibaba": "1.0.17", "@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82", "@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/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27", "@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/deepinfra": "2.0.41",
@ -73,7 +73,7 @@
"@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27", "@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/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26", "@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
@ -110,7 +110,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.10.0", "gitlab-ai-provider": "6.11.1",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",

View file

@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.17.18", "version": "1.17.20",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "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", () => { ipcMain.handle("read-clipboard-image", () => {
const image = clipboard.readImage() const image = clipboard.readImage()
if (image.isEmpty()) return null if (image.isEmpty()) return null

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.17.18", "version": "1.17.20",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@ -58,7 +58,7 @@
"@ai-sdk/alibaba": "1.0.17", "@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82", "@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/cerebras": "2.0.60",
"@ai-sdk/cohere": "3.0.27", "@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/deepinfra": "2.0.41",
@ -67,7 +67,7 @@
"@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27", "@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/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26", "@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
@ -122,7 +122,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.10.0", "gitlab-ai-provider": "6.11.1",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "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) 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 config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID) const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none() if (Option.isNone(resolved)) return Option.none()
@ -445,7 +457,7 @@ const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient.HttpCl
activeOrg, activeOrg,
list: repo.list, list: repo.list,
orgsByAccount, orgsByAccount,
remove: repo.remove, remove,
use: repo.use, use: repo.use,
orgs, orgs,
config, config,

View file

@ -283,8 +283,10 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
return Object.fromEntries( return Object.fromEntries(
Object.entries(provider.models) Object.entries(provider.models)
.filter(([, model]) => { .filter(([, model]) => {
if (model.options.reasoningMode === "pro") return false
if (ALLOWED_MODELS.has(model.api.id)) return true if (ALLOWED_MODELS.has(model.api.id)) return true
if (DISALLOWED_MODELS.has(model.api.id)) return false 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+)/) const match = model.api.id.match(/^gpt-(\d+\.\d+)/)
return match ? parseFloat(match[1]) > 5.4 : false return match ? parseFloat(match[1]) > 5.4 : false
}) })
@ -416,6 +418,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
const requestInit = { const requestInit = {
...init, ...init,
body: init?.body,
headers, headers,
} }
if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit) 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. // @ts-expect-error dead V1 expects raw model IDs inside normalized ModelsDev data.
const id = `${model.id}-${mode}` const id = `${model.id}-${mode}`
const base = fromModelsDevModel(provider, model) const base = fromModelsDevModel(provider, model)
const providerOptions = (
opts as {
readonly provider?: {
readonly body?: Record<string, unknown>
readonly headers?: Record<string, string>
}
}
).provider
models[id] = { models[id] = {
...base, ...base,
id: ModelV2.ID.make(id), 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)}`, name: `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`,
// @ts-expect-error dead V1 expects raw mode costs inside normalized ModelsDev data. // @ts-expect-error dead V1 expects raw mode costs inside normalized ModelsDev data.
cost: opts.cost ? mergeDeep(base.cost, cost(opts.cost)) : base.cost, 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: modeOptions(base, providerOptions?.body),
options: opts.provider?.body headers: providerOptions?.headers ?? base.headers,
? 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,
} }
} }
} }
@ -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) { function modelSuggestions(provider: Info | undefined, modelID: ModelV2.ID, enableExperimentalModels: boolean) {
const available = provider const available = provider
? Object.keys(provider.models).filter((id) => { ? 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": case "@ai-sdk/gateway":
if (model.id.includes("anthropic")) { if (model.api.id.includes("anthropic")) {
if (adaptiveEfforts) { if (adaptiveEfforts) {
return Object.fromEntries( return Object.fromEntries(
adaptiveEfforts.map((effort) => [ adaptiveEfforts.map((effort) => [
@ -794,8 +794,8 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
}, },
} }
} }
if (model.id.includes("google")) { if (model.api.id.includes("google")) {
if (id.includes("2.5")) { if (model.api.id.includes("2.5")) {
return { return {
high: { high: {
thinkingConfig: { thinkingConfig: {
@ -806,7 +806,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
max: { max: {
thinkingConfig: { thinkingConfig: {
includeThoughts: true, 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.providerID === "openai" ||
input.model.api.npm === "@ai-sdk/openai" || input.model.api.npm === "@ai-sdk/openai" ||
input.model.api.npm === "@ai-sdk/github-copilot" || 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 result["store"] = false
} }
@ -1259,7 +1260,8 @@ export function smallOptions(model: Provider.Model) {
if ( if (
model.providerID === "openai" || model.providerID === "openai" ||
model.api.npm === "@ai-sdk/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 } const base = { store: false }
return mergeDeep(base, small) 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", () => it.live("token refresh persists the new token", () =>
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")

View file

@ -149,16 +149,32 @@ describe("plugin.codex", () => {
await enabled.dispose?.() 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 hooks = await CodexAuthPlugin({} as never)
const limit = { context: 1_050_000, input: 922_000, output: 128_000 } const limit = { context: 1_050_000, input: 922_000, output: 128_000 }
const provider = { const provider = {
models: Object.fromEntries( models: {
["gpt-5.4", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"].map((id) => [ ...Object.fromEntries(
id, ["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, api: { id }, limit, cost: {} }, 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) 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-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-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.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( expect(await hooks.provider!.models!(provider as never, { auth: { type: "api" } } as never)).toBe(
provider.models as never, 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 = { const provider = {
id: "openai", id: "openai",
name: "OpenAI", name: "OpenAI",
env: [], env: [],
npm: "@ai-sdk/openai",
api: "https://api.openai.com/v1", api: "https://api.openai.com/v1",
models: { models: {
"gpt-5.4": { "gpt-5.6-sol": {
id: "gpt-5.4", id: "gpt-5.6-sol",
name: "GPT-5.4", name: "GPT-5.6 Sol",
family: "gpt", family: "gpt",
release_date: "2026-03-05", release_date: "2026-03-05",
attachment: true, 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 } 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.input).toEqual(5)
expect(model.cost.output).toEqual(30) expect(model.cost.output).toEqual(30)
expect(model.cost.cache.read).toEqual(0.5) expect(model.cost.cache.read).toEqual(0.5)
expect(model.cost.cache.write).toEqual(0) expect(model.cost.cache.write).toEqual(0)
expect(model.options["serviceTier"]).toEqual("priority") 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({ expect(model.cost.experimentalOver200K).toEqual({
input: 5, input: 5,
output: 22.5, output: 22.5,

View file

@ -155,6 +155,43 @@ describe("ProviderTransform.options - setCacheKey", () => {
expect(result.store).toBe(false) 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", () => { test("should set store=false for azure provider by default", () => {
const azureModel = { const azureModel = {
...mockModel, ...mockModel,
@ -3372,6 +3409,42 @@ describe("ProviderTransform.variants", () => {
}) })
describe("@ai-sdk/gateway", () => { 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", () => { test("anthropic sonnet 4.6 models return adaptive thinking options", () => {
const model = createMockModel({ const model = createMockModel({
id: "anthropic/claude-sonnet-4-6", id: "anthropic/claude-sonnet-4-6",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { FilePart } from "@opencode-ai/sdk/v2" 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 { function file(part: Partial<FilePart> = {}): FilePart {
return { return {
@ -52,4 +52,14 @@ describe("message-file", () => {
expect(kind(file({ mime: "image/png" }))).toBe("image") expect(kind(file({ mime: "image/png" }))).toBe("image")
expect(kind(file({ mime: "application/pdf" }))).toBe("file") 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" import type { FilePart } from "@opencode-ai/sdk/v2"
export function attached(part: FilePart) { export function attached(part: FilePart) {
@ -12,3 +14,22 @@ export function inline(part: FilePart) {
export function kind(part: FilePart) { export function kind(part: FilePart) {
return part.mime.startsWith("image/") ? "image" : "file" 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"] { &[data-type="image"] {
width: 48px; position: relative;
height: 48px; 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"] { &[data-type="file"] {
@ -1301,6 +1313,16 @@ body:not([data-new-layout]) {
&:hover { &:hover {
border-color: var(--border-strong-base); 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"] { [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 { Markdown } from "./markdown"
import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/core/util/path" 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 { checksum } from "@opencode-ai/core/util/encode"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button" 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 { patchFiles } from "./apply-patch-file"
import { animate } from "motion" import { animate } from "motion"
import { useLocation } from "@solidjs/router" 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 { readPartText } from "./message-part-text"
import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2" import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
@ -165,6 +167,7 @@ export interface MessageProps {
showAssistantCopyPartID?: string | null showAssistantCopyPartID?: string | null
showReasoningSummaries?: boolean showReasoningSummaries?: boolean
useV2Actions?: boolean useV2Actions?: boolean
comments?: UserMessageComment[]
} }
export type SessionAction = (input: { sessionID: string; messageID: string }) => Promise<void> | void 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 = { export type UserActions = {
fork?: SessionAction fork?: SessionAction
revert?: SessionAction revert?: SessionAction
openAttachment?: (file: FilePart) => void
}
export type UserMessageComment = {
path: string
comment: string
selection?: {
startLine: number
endLine: number
}
} }
export interface MessagePartProps { export interface MessagePartProps {
@ -937,6 +950,7 @@ export function Message(props: MessageProps) {
parts={props.parts} parts={props.parts}
actions={props.actions} actions={props.actions}
useV2Actions={props.useV2Actions} useV2Actions={props.useV2Actions}
comments={props.comments}
/> />
)} )}
</Match> </Match>
@ -1162,6 +1176,7 @@ export function UserMessageDisplay(props: {
parts: PartType[] parts: PartType[]
actions?: UserActions actions?: UserActions
useV2Actions?: boolean useV2Actions?: boolean
comments?: UserMessageComment[]
}) { }) {
const data = useData() const data = useData()
const dialog = useDialog() const dialog = useDialog()
@ -1183,6 +1198,8 @@ export function UserMessageDisplay(props: {
const attachments = createMemo(() => files().filter(attached)) const attachments = createMemo(() => files().filter(attached))
const messageComments = createMemo(() => (newLayout() ? (props.comments ?? []) : []))
const inlineFiles = createMemo(() => files().filter(inline)) const inlineFiles = createMemo(() => files().filter(inline))
const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? []) const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? [])
@ -1239,35 +1256,59 @@ export function UserMessageDisplay(props: {
return ( return (
<div data-component="user-message" data-timeline-part-id={textPart()?.id}> <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"> <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()}> <For each={attachments()}>
{(file) => { {(file) => {
const type = kind(file) const type = kind(file)
const name = file.filename ?? i18n.t("ui.message.attachment.alt") const name = file.filename ?? i18n.t("ui.message.attachment.alt")
return ( return (
<div <Show
data-slot="user-message-attachment" when={newLayout() && type === "file"}
data-type={type} fallback={
data-clickable={type === "image" ? "true" : undefined} <div
title={type === "file" ? name : undefined} data-slot="user-message-attachment"
onClick={() => { data-type={type}
if (type === "image") openImagePreview(file.url, name) 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 <AttachmentCardV2
when={type === "image"} title={getFilename(name)}
fallback={ hover={name}
<div data-slot="user-message-attachment-file"> clickable={!!props.actions?.openAttachment}
<FileIcon node={{ path: name, type: "file" }} /> onClick={() => props.actions?.openAttachment?.(file)}
<span data-slot="user-message-attachment-name">{name}</span>
</div>
}
> >
<img data-slot="user-message-attachment-image" src={file.url} alt={name} /> {typeLabel(name, file.mime)}
</Show> </AttachmentCardV2>
</div> </Show>
) )
}} }}
</For> </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 { useI18n } from "@opencode-ai/ui/context/i18n"
import { cloneSelectedLineRange, formatSelectedLineLabel } from "../../pierre/selection-bridge" import { cloneSelectedLineRange, formatSelectedLineLabel } from "../../pierre/selection-bridge"
import { LineCommentEditorV2, LineCommentV2 } from "@opencode-ai/ui/v2/line-comment-v2" 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> = { type LineCommentControllerV2Props<T extends LineCommentShape> = {
comments: Accessor<T[]> comments: Accessor<T[]>
@ -23,6 +24,7 @@ type LineCommentControllerV2Props<T extends LineCommentShape> = {
onDelete?: (comment: T) => void onDelete?: (comment: T) => void
renderCommentActions?: (comment: T, controls: { edit: VoidFunction; remove: VoidFunction }) => JSX.Element renderCommentActions?: (comment: T, controls: { edit: VoidFunction; remove: VoidFunction }) => JSX.Element
editSubmitLabel?: string editSubmitLabel?: string
mention?: LineCommentEditorV2Mention
} }
type CommentProps = { type CommentProps = {
@ -43,6 +45,7 @@ type DraftProps = {
onSubmit: (value: string) => void onSubmit: (value: string) => void
cancelLabel?: string cancelLabel?: string
submitLabel?: string submitLabel?: string
mention?: LineCommentEditorV2Mention
} }
function lineCommentElementV2(view: Accessor<CommentProps>) { function lineCommentElementV2(view: Accessor<CommentProps>) {
@ -70,6 +73,7 @@ function lineCommentElementV2(view: Accessor<CommentProps>) {
onSubmit={view().editor!.onSubmit} onSubmit={view().editor!.onSubmit}
cancelLabel={view().editor!.cancelLabel} cancelLabel={view().editor!.cancelLabel}
submitLabel={view().editor!.submitLabel} submitLabel={view().editor!.submitLabel}
mention={view().editor!.mention}
/> />
</div> </div>
</Show> </Show>
@ -87,6 +91,7 @@ function lineCommentDraftElementV2(view: Accessor<DraftProps>) {
onSubmit={view().onSubmit} onSubmit={view().onSubmit}
cancelLabel={view().cancelLabel} cancelLabel={view().cancelLabel}
submitLabel={view().submitLabel} submitLabel={view().submitLabel}
mention={view().mention}
/> />
</div> </div>
) )
@ -142,6 +147,7 @@ export function createLineCommentControllerV2<T extends LineCommentShape>(props:
}, },
cancelLabel: i18n.t("ui.lineComment.cancel"), cancelLabel: i18n.t("ui.lineComment.cancel"),
submitLabel: props.editSubmitLabel, submitLabel: props.editSubmitLabel,
mention: props.mention,
} }
: undefined : undefined
}, },
@ -165,6 +171,7 @@ export function createLineCommentControllerV2<T extends LineCommentShape>(props:
}, },
cancelLabel: i18n.t("ui.lineComment.cancel"), cancelLabel: i18n.t("ui.lineComment.cancel"),
submitLabel: i18n.t("ui.lineComment.submit"), submitLabel: i18n.t("ui.lineComment.submit"),
mention: props.mention,
}), }),
}) })
@ -202,6 +209,7 @@ export function createLineCommentControllerV2<T extends LineCommentShape>(props:
} }
return { return {
note,
annotations, annotations,
renderAnnotation, renderAnnotation,
renderGutterUtility, renderGutterUtility,

View file

@ -76,6 +76,11 @@
color: var(--text-strong, var(--v2-text-text-base)); 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-component="session-review-v2-sidebar-root"]
[data-slot="session-review-v2-sidebar-title"] [data-slot="session-review-v2-sidebar-title"]
[data-component="select-v2-root"] { [data-component="select-v2-root"] {
@ -97,7 +102,7 @@
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
margin-top: 2px; margin-top: 3px;
} }
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] { [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 empty?: JSX.Element
sidebarOpen?: boolean sidebarOpen?: boolean
sidebar?: JSX.Element sidebar?: JSX.Element
sidebarToggle?: JSX.Element
activeFile?: string activeFile?: string
files: string[] files: string[]
onSelectFile: (file: string) => void onSelectFile: (file: string) => void
@ -199,7 +198,6 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
const toolbarStart = () => ( const toolbarStart = () => (
<> <>
{props.sidebarToggle}
<Show when={showCollapsedMeta()}> <Show when={showCollapsedMeta()}>
<div data-slot="session-review-v2-toolbar-collapsed-meta"> <div data-slot="session-review-v2-toolbar-collapsed-meta">
<Show when={title()}> <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() const i18n = useI18n()
return ( return (
@ -330,6 +328,7 @@ export function SessionReviewV2SidebarToggle(props: { opened: boolean; onToggle:
class="session-review-v2-sidebar-toggle" class="session-review-v2-sidebar-toggle"
aria-label={i18n.t("ui.sessionReviewV2.toggleSidebar")} aria-label={i18n.t("ui.sessionReviewV2.toggleSidebar")}
aria-expanded={props.opened} aria-expanded={props.opened}
disabled={props.disabled}
onClick={props.onToggle} onClick={props.onToggle}
icon={<Icon name="filetree" />} icon={<Icon name="filetree" />}
/> />

View file

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

View file

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

View file

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

View file

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

View file

@ -2888,7 +2888,7 @@
padding: 0 8px; padding: 0 8px;
overflow: hidden; overflow: hidden;
background: var(--stats-layer-2); background: var(--stats-layer-2);
color: var(--stats-faint); color: var(--stats-hero-muted);
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
line-height: 1.1; line-height: 1.1;
@ -2897,21 +2897,7 @@
} }
[data-page="stats"] a[data-slot="lab-hero-crumb"] { [data-page="stats"] a[data-slot="lab-hero-crumb"] {
color: var(--stats-faint); color: var(--stats-hero-muted);
}
[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;
} }
[data-page="stats"] [data-slot="lab-hero-crumb"][data-current="true"] { [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"]:hover,
[data-page="stats"] a[data-slot="lab-hero-crumb"]:focus-visible, [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 {
color: var(--stats-text); color: var(--stats-text);
outline: none; outline: none;
text-decoration: none; text-decoration: none;
@ -2941,57 +2925,6 @@
color: currentColor; 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"] { [data-page="stats"] [data-slot="lab-hero-separator"] {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -3113,29 +3046,147 @@
white-space: nowrap; 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; padding-right: 4px;
} }
[data-page="stats"] [data-component="model-hero-menu"] { [data-page="stats"] [data-slot="model-hero-crumb"][data-current="true"] {
position: relative; color: var(--stats-text);
display: inline-block; }
[data-page="stats"] [data-component="stats-breadcrumb-select"] {
display: inline-flex;
min-width: 0; 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; cursor: pointer;
list-style: none;
} }
[data-page="stats"] [data-component="model-hero-menu"] summary::-webkit-details-marker { [data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"][data-current="true"],
display: none; [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"]:hover,
[data-page="stats"] a[data-slot="model-hero-crumb"]:focus-visible, [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 {
color: var(--stats-text); color: var(--stats-text);
outline: none; outline: none;
text-decoration: none; text-decoration: none;
@ -3151,58 +3202,7 @@
flex: 0 0 auto; flex: 0 0 auto;
width: 16px; width: 16px;
height: 16px; height: 16px;
color: var(--stats-faint); color: currentColor;
}
[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);
} }
[data-page="stats"] [data-slot="model-hero-separator"] { [data-page="stats"] [data-slot="model-hero-separator"] {
@ -5533,6 +5533,14 @@
white-space: nowrap; 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"]:hover,
[data-page="stats"] a[data-slot="compare-home-crumb"]:focus-visible { [data-page="stats"] a[data-slot="compare-home-crumb"]:focus-visible {
color: var(--stats-text); color: var(--stats-text);
@ -7876,6 +7884,10 @@
max-width: min(100%, 240px); 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"] { [data-page="stats"] [data-slot="model-hero-title-row"] {
flex-wrap: wrap; flex-wrap: wrap;
gap: 16px; gap: 16px;
@ -7967,10 +7979,6 @@
min-width: 48px; min-width: 48px;
} }
[data-page="stats"] [data-slot="lab-hero-options"] {
max-width: calc(100vw - 48px);
}
[data-page="stats"] [data-component="lab-related-list"] { [data-page="stats"] [data-component="lab-related-list"] {
grid-auto-columns: minmax(320px, 392px); grid-auto-columns: minmax(320px, 392px);
grid-auto-flow: column; grid-auto-flow: column;

View file

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

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-server", "name": "@opencode-ai/stats-server",
"version": "1.17.18", "version": "1.17.20",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "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])) return a.every((part, i) => JSON.stringify(part) === JSON.stringify(b[i]))
} }
export function isCommentItem(item: ContextItem) {
return !!item.comment?.trim()
}
export function createPromptState() { export function createPromptState() {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
prompt: clonePrompt(DEFAULT_PROMPT), prompt: clonePrompt(DEFAULT_PROMPT),

View file

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

View file

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

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