diff --git a/.opencode/tool/github-triage.ts b/.opencode/tool/github-triage.ts index f25ca48b38..e861e1e467 100644 --- a/.opencode/tool/github-triage.ts +++ b/.opencode/tool/github-triage.ts @@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin" const TEAM = { tui: ["kommander", "simonklee"], desktop_web: ["Hona", "Brendonovich"], - core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"], + core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"], inference: ["fwang", "MrMushrooooom", "starptech"], windows: ["Hona"], } as const diff --git a/bun.lock b/bun.lock index c0f7eb05a6..fd03c58e7d 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -157,7 +157,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -171,7 +171,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -207,7 +207,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -234,7 +234,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -256,7 +256,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -280,7 +280,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -300,7 +300,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.17.18", + "version": "1.17.20", "bin": { "opencode": "./bin/opencode", }, @@ -308,7 +308,7 @@ "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.49", + "@ai-sdk/azure": "3.0.88", "@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", @@ -317,7 +317,7 @@ "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", - "@ai-sdk/openai": "3.0.53", + "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", @@ -354,7 +354,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.10.0", + "gitlab-ai-provider": "6.11.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -396,7 +396,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -456,7 +456,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -470,7 +470,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "effect": "catalog:", }, @@ -482,7 +482,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -514,7 +514,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -530,7 +530,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@effect/platform-node-shared": "4.0.0-beta.83", }, @@ -562,7 +562,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -581,7 +581,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.17.18", + "version": "1.17.20", "bin": { "opencode": "./bin/opencode", }, @@ -592,7 +592,7 @@ "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.49", + "@ai-sdk/azure": "3.0.88", "@ai-sdk/cerebras": "2.0.60", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", @@ -601,7 +601,7 @@ "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", - "@ai-sdk/openai": "3.0.53", + "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", @@ -656,7 +656,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.10.0", + "gitlab-ai-provider": "6.11.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -714,7 +714,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/client": "workspace:*", @@ -801,7 +801,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "cross-spawn": "catalog:", }, @@ -816,7 +816,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@effect/platform-node": "catalog:", "@opencode-ai/core": "workspace:*", @@ -833,7 +833,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -894,7 +894,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -907,9 +907,10 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@ibm/plex": "6.4.1", + "@kobalte/core": "catalog:", "@opencode-ai/stats-core": "workspace:*", "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -940,7 +941,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -959,7 +960,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -1000,7 +1001,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", @@ -1030,7 +1031,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1081,7 +1082,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -1152,6 +1153,7 @@ ], "patchedDependencies": { "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", @@ -1159,10 +1161,8 @@ "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", - "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", }, "overrides": { @@ -1198,7 +1198,7 @@ "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "@tailwindcss/vite": "4.1.11", - "@tanstack/solid-virtual": "3.13.28", + "@tanstack/solid-virtual": "3.13.32", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", "@types/bun": "1.3.13", @@ -1218,7 +1218,7 @@ "hono": "4.10.7", "hono-openapi": "1.1.2", "luxon": "3.6.1", - "marked": "17.0.1", + "marked": "17.0.6", "marked-shiki": "1.2.1", "opentui-spinner": "0.0.7", "remeda": "2.26.0", @@ -1260,7 +1260,7 @@ "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="], - "@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="], + "@ai-sdk/azure": ["@ai-sdk/azure@3.0.88", "", { "dependencies": { "@ai-sdk/deepseek": "2.0.47", "@ai-sdk/openai": "3.0.84", "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RRjZkB1lYplh8dpBarnvkl1j7sYLHsyXua7erL3oNcMK7fHcl4bPO5C7iQhD1O/DqD/zCceDifnege1s+8yEvw=="], "@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="], @@ -1270,7 +1270,7 @@ "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="], - "@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="], + "@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MzcQ321JO8OY+TVLFI81A7cIIuoeLLxrLCDD+8C1E3Ro6UFyfMtRXo9bw9OhTMRSDMo6hgSDOo4Fekz8aJtQYQ=="], "@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EtvsWfGrqx3OhzJdoi82qH+4yzEPPKZr2utyQ+w8cHKoFeg0+8Lou9Z3uixy73WEwz8Z1+AR8QT9fZ64AWGYPA=="], @@ -3122,7 +3122,7 @@ "@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="], - "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.28", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-kRuOEL5orH/rzGgxNgfgOttsgV6cgrUeupVtrHMITb5p0rZ3hnxhbu/lhKcR9+7x+EJdfUtJIb2CVC85mlw15g=="], + "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.32", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-yhX4A4Kgn+wyTg6Mmu8+zwoMTwjz4K1ucvLfRJ8f0rPGDDAIqSaf0v6oU0yT9+SvrjmUaZQ0VX7g4byexbhNng=="], "@tanstack/start-client-core": ["@tanstack/start-client-core@1.170.13", "", { "dependencies": { "@tanstack/router-core": "1.171.14", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-storage-context": "1.167.16", "seroval": "^1.5.4" } }, "sha512-o37M3msIK5ec87kPrIYJWXb1XPnjIe5/jrkGLXiXpFuVL99z7mhoBCzftKtVPtzqI8EElnRE/VGFYT9BHNnWcw=="], @@ -3136,7 +3136,7 @@ "@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="], "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="], @@ -4344,7 +4344,7 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "gitlab-ai-provider": ["gitlab-ai-provider@6.10.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-oWEZ06rDO6JjB7INHO882wyBAQqCZVHiDHwCs5M+VPmdDj8TzhGXcYesA2CcV5RoI5lfHLKwGp5uKFB62VWpqw=="], + "gitlab-ai-provider": ["gitlab-ai-provider@6.11.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-SJ6f5qa7P8md6lPrserryER3zerLkrezlnqqYQ2AbvDPpHLbwtbyk0FYJ5kNRcmbI80i/VMcsMBP0YIRdc3ucQ=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], @@ -4854,7 +4854,7 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], "marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="], @@ -6340,7 +6340,11 @@ "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], - "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], + "@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], + + "@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + + "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], "@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], @@ -6352,9 +6356,9 @@ "@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], "@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], @@ -6844,7 +6848,7 @@ "@opencode-ai/console-mail/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="], - "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], + "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], "@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], @@ -6884,6 +6888,8 @@ "@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], @@ -7230,6 +7236,10 @@ "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="], + "ai-gateway-provider/@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="], + + "ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="], + "ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], "ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="], @@ -7534,7 +7544,7 @@ "opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="], - "opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], + "opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], @@ -8226,6 +8236,10 @@ "@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], + "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + + "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], + "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], "@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], @@ -8470,6 +8484,14 @@ "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="], + + "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], + + "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + + "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], @@ -8782,6 +8804,10 @@ "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="], + "opencode/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + + "opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], + "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], @@ -9116,6 +9142,8 @@ "@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], "@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="], @@ -9302,6 +9330,10 @@ "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], @@ -9402,6 +9434,8 @@ "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "opencode/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], diff --git a/nix/hashes.json b/nix/hashes.json index 50208fdbbe..20e5882de0 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-JTtn+wXTXg+yklvIMDLcGFaYhTU6ZrCgKT9JTNEQ3gA=", - "aarch64-linux": "sha256-gXU6zyhvAZrZirkL/PlHdkHtEof/7PVSPCaE34Jnd4U=", - "aarch64-darwin": "sha256-Q0oTG3uzOlD/X2kJingLle529lKFoTpyCW2rHXOZ6iE=", - "x86_64-darwin": "sha256-LINvKHxPibTlJeNzfACQx0x+Yj5oROT6Du3I5AtqqXk=" + "x86_64-linux": "sha256-N4zM1zNufSg8DrDWOHWJYgVpn6vDghX/CJ0pym9ItxI=", + "aarch64-linux": "sha256-Votrb6IbVt6OS5pcAlBd3L2btkZHa62Eu3mAFzKSlGM=", + "aarch64-darwin": "sha256-Ofmy6plO4CFt/DoVdyt3Sr2rk6VJhas4zXq3DnvP/6A=", + "x86_64-darwin": "sha256-LOeqfqlPbhp1c0Gq56fvKSzve7dvcCwlooTmDMFMznw=" } } diff --git a/package.json b/package.json index cef269ea54..962f4ee3c1 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@opentui/core": "0.4.3", "@opentui/keymap": "0.4.3", "@opentui/solid": "0.4.3", - "@tanstack/solid-virtual": "3.13.28", + "@tanstack/solid-virtual": "3.13.32", "@shikijs/stream": "4.2.0", "ulid": "3.0.1", "@kobalte/core": "0.13.11", @@ -76,7 +76,7 @@ "hono-openapi": "1.1.2", "fuzzysort": "3.1.0", "luxon": "3.6.1", - "marked": "17.0.1", + "marked": "17.0.6", "marked-shiki": "1.2.1", "remend": "1.3.0", "@playwright/test": "1.59.1", @@ -161,10 +161,9 @@ "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", - "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", - "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch" + "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch" } } diff --git a/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts b/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts new file mode 100644 index 0000000000..136232c211 --- /dev/null +++ b/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts @@ -0,0 +1,150 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/FileBrowserSidebar" +const projectID = "proj_file_browser_sidebar" +const sessionID = "ses_file_browser_sidebar" +const title = "File browser sidebar" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`) +// Marks the file-browser sidebar DOM node so a remount (fresh node) is detectable. +const PROBE = "original" + +test.use({ viewport: { width: 1440, height: 900 } }) + +// The file-browser sidebar must stay mounted across preview/pinned file-tab +// switches. Remounting resets scroll and filter state. +test("keeps the file-browser sidebar mounted when switching file tabs", async ({ page }) => { + await setup(page) + + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const panel = page.locator("#review-panel") + await panel.getByRole("button", { name: "Open file" }).click() + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + + const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]') + await expect(sidebar).toBeVisible() + await expect(panel.getByRole("button", { name: "file-00.ts" })).toBeVisible() + + await panel.getByRole("button", { name: "file-00.ts" }).click() + await expect(panel.getByRole("tab", { name: "file-00.ts" })).toHaveAttribute("data-selected", "") + await expect(panel.getByText("contents:file-00.ts", { exact: true })).toBeVisible() + + const viewport = panel.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport') + await viewport.hover() + await page.mouse.wheel(0, 100_000) + await expect + .poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) + const scrolled = await viewport.evaluate((element) => element.scrollTop) + expect(scrolled).toBeGreaterThan(0) + await writeProbe(page) + + await panel.getByRole("button", { name: "file-79.ts" }).click() + await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "") + await expect(panel.getByText("contents:file-79.ts", { exact: true })).toBeVisible() + expect(await readProbe(page)).toBe(PROBE) + await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled) + + await panel.getByRole("button", { name: "file-78.ts" }).dblclick() + await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "") + await panel.getByRole("button", { name: "file-79.ts" }).click() + await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "") + await panel.getByRole("tab", { name: "file-78.ts" }).click() + await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "") + expect(await readProbe(page)).toBe(PROBE) + await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled) +}) + +type Probed = HTMLElement & { __e2eProbe?: string } + +async function writeProbe(page: Page) { + await page.locator('#review-panel [data-component="session-review-v2-sidebar-root"]').evaluate((el, probe) => { + ;(el as Probed).__e2eProbe = probe + }, PROBE) +} + +async function readProbe(page: Page) { + return page + .locator('#review-panel [data-component="session-review-v2-sidebar-root"]') + .evaluate((el) => (el as Probed).__e2eProbe) +} + +async function setup(page: Page) { + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "file-browser-sidebar", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [ + { + id: sessionID, + slug: sessionID, + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + vcsDiff: [], + fileList: (path) => { + if (path) return [] + return files.map((name) => ({ + name, + path: name, + absolute: `${directory}/${name}`, + type: "file" as const, + ignored: false, + })) + }, + fileContent: (path) => ({ type: "text", content: `contents:${path}` }), + pageMessages: () => ({ items: [] }), + }) + + await page.addInitScript( + ({ directory, server, sessionID }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.global.dat:layout", + JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), + ) + localStorage.setItem( + "opencode.global.dat:review-panel-v2", + JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "session", server, sessionId: sessionID }]), + ) + }, + { directory, server, sessionID }, + ) +} diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts index 397c53f248..25ebd3a37a 100644 --- a/packages/app/e2e/regression/review-open-file.spec.ts +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -91,15 +91,17 @@ test("opens and searches project files inline", async ({ page }) => { const panel = page.locator("#review-panel") const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]') + const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" }) const contextButton = page.getByRole("button", { name: "View context usage" }) await contextButton.click() await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") await panel.getByRole("button", { name: "Open file" }).click() await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + await expect(sidebarToggle).toBeDisabled() await expect(sidebar).toBeVisible() await contextButton.click() await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") - await expect(sidebar).toHaveCount(0) + await expect(sidebar).toBeHidden() await panel.getByRole("button", { name: "Open file" }).click() const filter = panel.getByRole("combobox", { name: "Filter files" }) await expect(filter).toBeFocused() @@ -108,6 +110,7 @@ test("opens and searches project files inline", async ({ page }) => { await panel.getByRole("button", { name: "README.md" }).click() await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "") + await expect(sidebarToggle).toBeEnabled() await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible() await expect(sidebar).toHaveCount(0) @@ -122,12 +125,17 @@ test("opens and searches project files inline", async ({ page }) => { await expect(filter).toHaveAttribute("aria-activedescendant", resultID!) await filter.press("Enter") await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") + await expect(sidebarToggle).toBeEnabled() await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible() expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 }) await panel.getByRole("button", { name: "Open file" }).click() await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1) await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + await expect(sidebarToggle).toBeDisabled() + await panel.getByRole("tab", { name: /Review/ }).click() + await expect(sidebarToggle).toBeEnabled() + await panel.getByRole("tab", { name: "Open file" }).click() await page.keyboard.press("Control+w") await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0) await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") diff --git a/packages/app/e2e/regression/review-tab-switch.spec.ts b/packages/app/e2e/regression/review-tab-switch.spec.ts index c2ea406c5a..8634166e51 100644 --- a/packages/app/e2e/regression/review-tab-switch.spec.ts +++ b/packages/app/e2e/regression/review-tab-switch.spec.ts @@ -28,8 +28,8 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac await expectSessionTitle(page, titleA) await page.getByRole("button", { name: "Toggle review" }).click() - const reviewTab = page.getByRole("tab", { name: /Review/ }) - const reviewTabPanel = page.getByRole("tabpanel", { name: /Review/ }) + const reviewTab = page.locator("#session-side-panel-review-tab") + const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel") await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel") await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel") const review = page.locator('#review-panel [data-component="session-review-v2"]') diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts index e3ba607b3d..154bab48c4 100644 --- a/packages/app/e2e/regression/review-terminal-stacked.spec.ts +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -111,7 +111,7 @@ test("keeps the review tree and terminal sized when both panels are open", async await expectTree(page, 8, "git-0.ts") await selectMode(page, "Git changes", "Branch changes") - await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible() + await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740") await page.keyboard.press("Control+Backquote") await expect(page.locator("#terminal-panel")).toBeVisible() await expectTree(page, 2_773, "action.yml") diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index 036eaaef42..714d6ca96f 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -1,6 +1,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" +import { installSseTransport } from "../utils/sse-transport" import { expectSessionTitle } from "../utils/waits" const directory = "C:/OpenCode/RequestDocks" @@ -100,6 +101,67 @@ test("shows a pending permission dock", async ({ page }) => { expect(request.postDataJSON()).toEqual({ response: "once" }) }) +test("restores the draft caret before typing after a request dock closes", async ({ page }) => { + const transport = await installSseTransport(page, { + server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, + retry: 20, + }) + await mockServer(page, { questions: [] }) + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await transport.waitForConnection() + await expectSessionTitle(page, title) + + const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]') + const draft = "keep the caret at the end" + await editor.fill(draft) + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))) + for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft") + const cursor = draft.length - 4 + await expect + .poll(() => + editor.evaluate((element) => { + const selection = window.getSelection() + if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1 + const range = selection.getRangeAt(0).cloneRange() + range.selectNodeContents(element) + range.setEnd(selection.anchorNode!, selection.anchorOffset) + return range.toString().length + }), + ) + .toBe(cursor) + await transport.send({ + directory, + payload: { + type: "question.asked", + properties: { + id: "question-caret", + sessionID, + questions: [ + { + header: "Continue", + question: "Continue?", + options: [{ label: "Yes", description: "Continue the session" }], + }, + ], + tool: { messageID: "message-caret", callID: "call-caret" }, + }, + }, + }) + const question = page.locator('[data-component="dock-prompt"][data-kind="question"]') + await expect(question).toBeVisible() + await expect(editor).toHaveCount(0) + + await transport.send({ + directory, + payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } }, + }) + await expect(question).toHaveCount(0) + await expect(editor).toBeVisible() + await page.keyboard.press("x") + + await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`) +}) + async function mockServer( page: Page, requests: { diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts index 45304df365..1ebda43478 100644 --- a/packages/app/e2e/regression/session-timeline-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-projection.spec.ts @@ -150,7 +150,10 @@ test.describe("session timeline projection", () => { parentID: "msg_2000_diff_next_user", created: 1700000011000, }) - await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] }) + await setupTimeline(page, { + messages: [user, assistantMessage(), nextUser, nextAssistant], + settings: { newLayoutDesigns: false }, + }) const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) await scroller.evaluate((element) => (element.scrollTop = 0)) diff --git a/packages/app/package.json b/packages/app/package.json index 510e2015db..3a0eb6d426 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.17.18", + "version": "1.17.20", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/components/dialog-edit-project-v2.tsx b/packages/app/src/components/dialog-edit-project-v2.tsx new file mode 100644 index 0000000000..dc9714aa06 --- /dev/null +++ b/packages/app/src/components/dialog-edit-project-v2.tsx @@ -0,0 +1,156 @@ +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2" +import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" +import { Field } from "@opencode-ai/ui/v2/field-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2" +import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { For, Show } from "solid-js" +import { useLanguage } from "@/context/language" +import { getProjectAvatarVariant, type LocalProject } from "@/context/layout" +import { ServerConnection } from "@/context/server" +import { getProjectAvatarSource } from "@/pages/layout/helpers" +import { createEditProjectModel } from "./edit-project" + +export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) { + const language = useLanguage() + const model = createEditProjectModel(props) + + return ( + +
+ + {language.t("dialog.project.edit.title")} + + + + + {language.t("dialog.project.edit.name")} + model.setStore("name", event.currentTarget.value)} + /> + + +
+
+ {language.t("dialog.project.edit.icon")} +
+
+ + { + model.setIconInput(element) + }} + type="file" + accept="image/*" + class="hidden" + onChange={model.inputChange} + /> +
+ {language.t("dialog.project.edit.icon.hint")} + {language.t("dialog.project.edit.icon.recommended")} +
+
+
+ + +
+
+ {language.t("dialog.project.edit.color")} +
+
+ + {(color) => ( + + )} + +
+
+
+ + + {language.t("dialog.project.edit.worktree.startup")} + {language.t("dialog.project.edit.worktree.startup.description")} + model.setStore("startup", event.currentTarget.value)} + /> + +
+ + + {language.t("common.cancel")} + + + {model.save.isPending ? language.t("common.saving") : language.t("common.save")} + + + +
+ ) +} diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index b861492af5..86a9630359 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -1,123 +1,32 @@ import { Button } from "@opencode-ai/ui/button" -import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { TextField } from "@opencode-ai/ui/text-field" -import { useMutation } from "@tanstack/solid-query" import { Icon } from "@opencode-ai/ui/icon" -import { createMemo, For, Show } from "solid-js" -import { createStore } from "solid-js/store" +import { For, Show } from "solid-js" import { type LocalProject, getAvatarColors } from "@/context/layout" -import { getFilename } from "@opencode-ai/core/util/path" import { Avatar } from "@opencode-ai/ui/avatar" import { useLanguage } from "@/context/language" import { getProjectAvatarSource } from "@/pages/layout/helpers" import { ServerConnection } from "@/context/server" -import { useGlobal } from "@/context/global" +import { createEditProjectModel } from "./edit-project" const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) { - const dialog = useDialog() - const global = useGlobal() const language = useLanguage() - const serverCtx = createMemo(() => global.ensureServerCtx(props.server)) - const serverSDK = () => serverCtx().sdk - const serverSync = () => serverCtx().sync - - const folderName = createMemo(() => getFilename(props.project.worktree)) - const defaultName = createMemo(() => props.project.name || folderName()) - - const [store, setStore] = createStore({ - name: defaultName(), - color: props.project.icon?.color, - iconOverride: props.project.icon?.override, - startup: props.project.commands?.start ?? "", - dragOver: false, - iconHover: false, - }) - - let iconInput: HTMLInputElement | undefined - - function handleFileSelect(file: File) { - if (!file.type.startsWith("image/")) return - const reader = new FileReader() - reader.onload = (e) => { - setStore("iconOverride", e.target?.result as string) - setStore("iconHover", false) - } - reader.readAsDataURL(file) - } - - function handleDrop(e: DragEvent) { - e.preventDefault() - setStore("dragOver", false) - const file = e.dataTransfer?.files[0] - if (file) handleFileSelect(file) - } - - function handleDragOver(e: DragEvent) { - e.preventDefault() - setStore("dragOver", true) - } - - function handleDragLeave() { - setStore("dragOver", false) - } - - function handleInputChange(e: Event) { - const input = e.target as HTMLInputElement - const file = input.files?.[0] - if (file) handleFileSelect(file) - } - - function clearIcon() { - setStore("iconOverride", "") - } - - const saveMutation = useMutation(() => ({ - mutationFn: async () => { - const name = store.name.trim() === folderName() ? "" : store.name.trim() - const start = store.startup.trim() - - if (props.project.id && props.project.id !== "global") { - await serverSDK().client.project.update({ - projectID: props.project.id, - directory: props.project.worktree, - name, - icon: { color: store.color || "", override: store.iconOverride || "" }, - commands: { start }, - }) - serverSync().project.icon(props.project.worktree, store.iconOverride || undefined) - dialog.close() - return - } - - serverSync().project.meta(props.project.worktree, { - name, - icon: { color: store.color || undefined, override: store.iconOverride || undefined }, - commands: { start: start || undefined }, - }) - dialog.close() - }, - })) - - function handleSubmit(e: SubmitEvent) { - e.preventDefault() - if (saveMutation.isPending) return - saveMutation.mutate() - } + const model = createEditProjectModel(props) return ( -
+
setStore("name", v)} + placeholder={model.folderName()} + value={model.store.name} + onChange={(v) => model.setStore("name", v)} />
@@ -125,38 +34,32 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
setStore("iconHover", true)} - onMouseLeave={() => setStore("iconHover", false)} + onMouseEnter={() => model.setStore("iconHover", true)} + onMouseLeave={() => model.setStore("iconHover", false)} >
{ - if (store.iconOverride && store.iconHover) { - clearIcon() - } else { - iconInput?.click() - } + "border-text-interactive-base bg-surface-info-base/20": model.store.dragOver, + "border-border-base hover:border-border-strong": !model.store.dragOver, + "overflow-hidden": !!model.store.iconOverride, }} + onDrop={model.drop} + onDragOver={model.dragOver} + onDragLeave={model.dragLeave} + onClick={model.iconClick} >
@@ -174,8 +77,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
@@ -183,8 +86,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
@@ -193,12 +96,12 @@ export function DialogEditProject(props: { project: LocalProject; server: Server { - iconInput = el + model.setIconInput(el) }} type="file" accept="image/*" class="hidden" - onChange={handleInputChange} + onChange={model.inputChange} />
{language.t("dialog.project.edit.icon.hint")} @@ -207,7 +110,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
- +
@@ -216,21 +119,21 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
- -
diff --git a/packages/app/src/components/edit-project.ts b/packages/app/src/components/edit-project.ts new file mode 100644 index 0000000000..3ec999da06 --- /dev/null +++ b/packages/app/src/components/edit-project.ts @@ -0,0 +1,119 @@ +import { getFilename } from "@opencode-ai/core/util/path" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useMutation } from "@tanstack/solid-query" +import { createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { useGlobal } from "@/context/global" +import { type LocalProject } from "@/context/layout" +import { ServerConnection } from "@/context/server" + +export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) { + const dialog = useDialog() + const global = useGlobal() + const serverCtx = createMemo(() => global.ensureServerCtx(props.server)) + const folderName = createMemo(() => getFilename(props.project.worktree)) + const defaultName = createMemo(() => props.project.name || folderName()) + const [store, setStore] = createStore({ + name: defaultName(), + color: props.project.icon?.color, + iconOverride: props.project.icon?.override, + startup: props.project.commands?.start ?? "", + dragOver: false, + iconHover: false, + }) + let iconInput: HTMLInputElement | undefined + + function selectFile(file: File) { + if (!file.type.startsWith("image/")) return + const reader = new FileReader() + reader.onload = (event) => { + const result = event.target?.result + if (typeof result !== "string") return + setStore("iconOverride", result) + setStore("iconHover", false) + } + reader.readAsDataURL(file) + } + + function drop(event: DragEvent) { + event.preventDefault() + setStore("dragOver", false) + const file = event.dataTransfer?.files[0] + if (file) selectFile(file) + } + + function dragOver(event: DragEvent) { + event.preventDefault() + setStore("dragOver", true) + } + + function dragLeave() { + setStore("dragOver", false) + } + + function inputChange(event: Event) { + const file = (event.currentTarget as HTMLInputElement).files?.[0] + if (file) selectFile(file) + } + + function iconClick() { + if (store.iconOverride && store.iconHover) { + setStore("iconOverride", "") + return + } + iconInput?.click() + } + + const save = useMutation(() => ({ + mutationFn: async () => { + const name = store.name.trim() === folderName() ? "" : store.name.trim() + const start = store.startup.trim() + + if (props.project.id && props.project.id !== "global") { + await serverCtx().sdk.client.project.update({ + projectID: props.project.id, + directory: props.project.worktree, + name, + icon: { color: store.color || "", override: store.iconOverride || "" }, + commands: { start }, + }) + serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined) + dialog.close() + return + } + + serverCtx().sync.project.meta(props.project.worktree, { + name, + icon: { color: store.color || undefined, override: store.iconOverride || undefined }, + commands: { start: start || undefined }, + }) + dialog.close() + }, + })) + + function submit(event: SubmitEvent) { + event.preventDefault() + if (save.isPending) return + save.mutate() + } + + return { + store, + setStore, + folderName, + defaultName, + save, + submit, + drop, + dragOver, + dragLeave, + inputChange, + iconClick, + close() { + dialog.close() + }, + setIconInput(input: HTMLInputElement) { + iconInput = input + }, + } +} diff --git a/packages/app/src/components/file-tree-v2-model.test.ts b/packages/app/src/components/file-tree-v2-model.test.ts index 288f7112e9..f2a63fe86d 100644 --- a/packages/app/src/components/file-tree-v2-model.test.ts +++ b/packages/app/src/components/file-tree-v2-model.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test } from "bun:test" -import { buildFileTreeV2Model, flattenFileTreeV2 } from "./file-tree-v2-model" +import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model" +import type { FileNode } from "@opencode-ai/sdk/v2" -describe("file tree v2 model", () => { - test("builds sorted depth-first rows", () => { +describe("buildFileTreeV2Model", () => { + test("builds a sorted tree and flattens expanded directories", () => { const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"]) expect(model.total).toBe(8) @@ -18,7 +19,7 @@ describe("file tree v2 model", () => { ]) }) - test("omits descendants of collapsed directories", () => { + test("skips children of collapsed directories", () => { const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"]) expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([ @@ -28,19 +29,46 @@ describe("file tree v2 model", () => { ]) }) - test("normalizes separators and duplicate paths", () => { + test("normalizes duplicate and messy paths", () => { const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"]) const rows = flattenFileTreeV2(model, () => true) - expect(model.total).toBe(4) expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"]) expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts") }) - test("supports paths deeper than the legacy recursion limit", () => { - const file = `${Array.from({ length: 130 }, (_, index) => `dir-${index}`).join("/")}/file.ts` + test("handles deeply nested paths", () => { + const file = Array.from({ length: 130 }, (_, index) => `d${index}`).join("/") + "/leaf.ts" const model = buildFileTreeV2Model([file]) expect(flattenFileTreeV2(model, () => true)).toHaveLength(131) }) }) + +describe("flattenLiveFileTreeV2", () => { + test("flattens live children using original paths for nested lookups", () => { + const nodes: Record = { + "": [ + { name: "src", path: "src", absolute: "/repo/src", type: "directory", ignored: false }, + { name: "README.md", path: "README.md", absolute: "/repo/README.md", type: "file", ignored: false }, + ], + src: [ + { name: "a.ts", path: "src/a.ts", absolute: "/repo/src/a.ts", type: "file", ignored: false }, + { name: "lib", path: "src/lib", absolute: "/repo/src/lib", type: "directory", ignored: false }, + ], + "src/lib": [{ name: "b.ts", path: "src/lib/b.ts", absolute: "/repo/src/lib/b.ts", type: "file", ignored: false }], + } + + expect( + flattenLiveFileTreeV2( + (path) => nodes[path] ?? [], + (path) => path === "src", + ).map((row) => [row.node.path, row.node.originalPath, row.level]), + ).toEqual([ + ["src", "src", 0], + ["src/a.ts", "src/a.ts", 1], + ["src/lib", "src/lib", 1], + ["README.md", "README.md", 0], + ]) + }) +}) diff --git a/packages/app/src/components/file-tree-v2-model.ts b/packages/app/src/components/file-tree-v2-model.ts index 2127b6800f..27783a9bb1 100644 --- a/packages/app/src/components/file-tree-v2-model.ts +++ b/packages/app/src/components/file-tree-v2-model.ts @@ -75,3 +75,33 @@ export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: strin return rows } + +export function flattenLiveFileTreeV2( + children: (path: string) => readonly FileNode[], + expanded: (path: string) => boolean, +) { + const rows: FileTreeV2Row[] = [] + const stack = children("") + .toReversed() + .map((node) => ({ node: toLiveNode(node), level: 0 })) + + while (stack.length > 0) { + const row = stack.pop()! + rows.push(row) + if (row.node.type !== "directory" || !expanded(row.node.path)) continue + const nested = children(row.node.originalPath) + for (let index = nested.length - 1; index >= 0; index--) { + stack.push({ node: toLiveNode(nested[index]!), level: row.level + 1 }) + } + } + + return rows +} + +function toLiveNode(node: FileNode): FileTreeV2Node { + return { + ...node, + path: normalizeFileTreeV2Path(node.path), + originalPath: node.path, + } +} diff --git a/packages/app/src/components/file-tree-v2.tsx b/packages/app/src/components/file-tree-v2.tsx index c712a80443..26cf49d411 100644 --- a/packages/app/src/components/file-tree-v2.tsx +++ b/packages/app/src/components/file-tree-v2.tsx @@ -16,7 +16,13 @@ import type { FileNode } from "@opencode-ai/sdk/v2" import { Icon } from "@opencode-ai/ui/v2/icon" import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree" import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" -import { buildFileTreeV2Model, flattenFileTreeV2, normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" +import { + buildFileTreeV2Model, + flattenFileTreeV2, + flattenLiveFileTreeV2, + normalizeFileTreeV2Path, + type FileTreeV2Node, +} from "@/components/file-tree-v2-model" import { virtualScrollElement } from "@/components/virtual-scroll-element" export type { Kind } from "@/components/file-tree" @@ -36,7 +42,7 @@ function guideLineLeft(level: number) { export const kindLabel = (kind: Kind) => { if (kind === "add") return "A" if (kind === "del") return "D" - return "" + return "M" } export const kindChange = (kind: Kind) => { @@ -68,7 +74,7 @@ const FileTreeNodeV2 = ( "class", "classList", ]) - const kind = () => local.kinds?.get(local.node.path) + const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path)) return ( - {(_, index) => ( -
- )} + {(_, index) =>
} ) } @@ -126,12 +127,18 @@ export default function FileTreeV2(props: { kinds?: ReadonlyMap draggable?: boolean onFileClick?: (file: FileNode) => void + onFileDoubleClick?: (file: FileNode) => void }) { const file = useFile() + const live = () => props.allowed === undefined const draggable = () => props.draggable ?? true const active = () => normalizeFileTreeV2Path(props.active ?? "") - const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? [])) - const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true)) + const model = createMemo(() => (live() ? undefined : buildFileTreeV2Model(props.allowed ?? []))) + const expanded = (path: string) => file.tree.state(path)?.expanded ?? !live() + const rows = createMemo(() => { + if (live()) return flattenLiveFileTreeV2((path) => file.tree.children(path), expanded) + return flattenFileTreeV2(model()!, expanded) + }) const [root, setRoot] = createSignal() const [focused, setFocused] = createSignal() const virtualizer = createVirtualizer({ @@ -155,16 +162,49 @@ export default function FileTreeV2(props: { return [...indexes, index].sort((a, b) => a - b) }, }) + + createEffect(() => { + if (!live()) return + void file.tree.list("") + }) + + // Only scroll when the active path changes (or first appears in the tree). + // Do not re-scroll when expand/collapse reshuffles `rows()`. + let scrolledActive: string | undefined createEffect(() => { const path = active() - if (!path) return + if (!path) { + scrolledActive = undefined + return + } const index = rows().findIndex((row) => row.node.path === path) if (index < 0) return + if (scrolledActive === path) return + scrolledActive = path queueMicrotask(() => { - if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return - virtualizer.scrollToIndex(index, { align: "auto" }) + const next = rows().findIndex((row) => row.node.path === path) + if (next < 0) return + if (virtualizer.range && next >= virtualizer.range.startIndex && next <= virtualizer.range.endIndex) return + virtualizer.scrollToIndex(next, { align: "auto" }) }) }) + + const selectFile = (node: FileTreeV2Node, action?: (file: FileNode) => void) => { + action?.({ + ...node, + path: node.originalPath, + absolute: node.originalPath, + }) + } + + const toggleDirectory = (path: string, originalPath: string) => { + if (expanded(path)) { + file.tree.collapse(originalPath) + return + } + file.tree.expand(originalPath, live() ? undefined : { list: false }) + } + const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const))) const virtualItemByKey = createMemo( () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)), @@ -175,7 +215,7 @@ export default function FileTreeV2(props: {
@@ -209,13 +249,8 @@ export default function FileTreeV2(props: { class="relative" onFocus={() => setFocused(row().node.path)} onBlur={() => setFocused(undefined)} - onClick={() => - props.onFileClick?.({ - ...row().node, - path: row().node.originalPath, - absolute: row().node.originalPath, - }) - } + onClick={() => selectFile(row().node, props.onFileClick)} + onDblClick={() => selectFile(row().node, props.onFileDoubleClick)} > 0}> @@ -239,17 +274,13 @@ export default function FileTreeV2(props: { class="relative" onFocus={() => setFocused(row().node.path)} onBlur={() => setFocused(undefined)} - aria-expanded={file.tree.state(row().node.path)?.expanded ?? true} - onClick={() => - file.tree.state(row().node.path)?.expanded === false - ? file.tree.expand(row().node.path, { list: false }) - : file.tree.collapse(row().node.path) - } + aria-expanded={expanded(row().node.path)} + onClick={() => toggleDirectory(row().node.path, row().node.originalPath)} >
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index bbb71e8722..284e04447f 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -19,6 +19,7 @@ import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/f import { ContentPart, DEFAULT_PROMPT, + isCommentItem, isPromptEqual, Prompt, usePrompt, @@ -632,7 +633,9 @@ export const PromptInput: Component = (props) => { const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 const handleBlur = () => { - savedCursor = currentCursor() + const cursor = currentCursor() + savedCursor = cursor + if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor) closePopover() setComposing(false) } @@ -1626,7 +1629,7 @@ export const PromptInput: Component = (props) => { )} /> !isCommentItem(item))} active={(item) => { const active = comments.active() return !!item.commentID && item.commentID === active?.id && item.path === active?.file @@ -1636,6 +1639,7 @@ export const PromptInput: Component = (props) => { if (item.commentID) comments.remove(item.path, item.commentID) prompt.context.remove(item.key) }} + newLayoutDesigns={props.controls.newLayoutDesigns} t={(key) => language.t(key as Parameters[0])} /> = (props) => { } onRemove={removeAttachment} removeLabel={language.t("prompt.attachment.remove")} + newLayoutDesigns={props.controls.newLayoutDesigns} + comments={contextItems().filter(isCommentItem)} + commentActive={(item) => { + const active = comments.active() + return !!item.commentID && item.commentID === active?.id && item.path === active?.file + }} + onOpenComment={openComment} + onRemoveComment={(item) => { + if (item.commentID) comments.remove(item.path, item.commentID) + prompt.context.remove(item.key) + }} />
= (props) => { if (item.commentID) comments.remove(item.path, item.commentID) prompt.context.remove(item.key) }} + newLayoutDesigns={props.controls.newLayoutDesigns} t={(key) => language.t(key as Parameters[0])} /> = (props) => { } onRemove={removeAttachment} removeLabel={language.t("prompt.attachment.remove")} + newLayoutDesigns={props.controls.newLayoutDesigns} />
)} - {props.state.modelName} + {props.state.modelName} diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx index 6b154ee1e5..1f03f199ab 100644 --- a/packages/app/src/components/prompt-input/context-items.tsx +++ b/packages/app/src/components/prompt-input/context-items.tsx @@ -1,6 +1,8 @@ import { Component, For, Show } from "solid-js" +import { Dynamic } from "solid-js/web" import { FileIcon } from "@opencode-ai/ui/file-icon" import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip } from "@opencode-ai/ui/tooltip" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path" import type { ContextItem } from "@/context/prompt" @@ -12,6 +14,7 @@ type ContextItemsProps = { active: (item: PromptContextItem) => boolean openComment: (item: PromptContextItem) => void remove: (item: PromptContextItem) => void + newLayoutDesigns: boolean t: (key: string) => string } @@ -27,10 +30,17 @@ export const PromptContextItems: Component = (props) => { const selected = props.active(item) return ( - - + {directory} {filename} @@ -78,7 +88,7 @@ export const PromptContextItems: Component = (props) => { {(comment) =>
{comment()}
}
- + ) }} diff --git a/packages/app/src/components/prompt-input/image-attachments.css b/packages/app/src/components/prompt-input/image-attachments.css new file mode 100644 index 0000000000..2aaf494ebf --- /dev/null +++ b/packages/app/src/components/prompt-input/image-attachments.css @@ -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); + } +} diff --git a/packages/app/src/components/prompt-input/image-attachments.tsx b/packages/app/src/components/prompt-input/image-attachments.tsx index dd8138e5a4..3a0cc3e4dd 100644 --- a/packages/app/src/components/prompt-input/image-attachments.tsx +++ b/packages/app/src/components/prompt-input/image-attachments.tsx @@ -1,60 +1,167 @@ import { Component, For, Show } from "solid-js" import { Icon } from "@opencode-ai/ui/icon" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Tooltip } from "@opencode-ai/ui/tooltip" -import type { ImageAttachmentPart } from "@/context/prompt" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { AttachmentCardV2 } from "@opencode-ai/session-ui/v2/attachment-card-v2" +import { CommentCardV2 } from "@opencode-ai/session-ui/v2/comment-card-v2" +import { typeLabel } from "@opencode-ai/session-ui/message-file" +import type { ContextItem, ImageAttachmentPart } from "@/context/prompt" +import "./image-attachments.css" + +type PromptCommentItem = ContextItem & { key: string } type PromptImageAttachmentsProps = { attachments: ImageAttachmentPart[] onOpen: (attachment: ImageAttachmentPart) => void onRemove: (id: string) => void removeLabel: string + newLayoutDesigns: boolean + comments?: PromptCommentItem[] + commentActive?: (item: PromptCommentItem) => boolean + onOpenComment?: (item: PromptCommentItem) => void + onRemoveComment?: (item: PromptCommentItem) => void } const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base" const imageClass = "size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors" +const imageClassV2 = "w-[58px] h-[46px] rounded-[6px] object-cover" +// inset box-shadows do not paint over content, so the hairline is a separate overlay +const imageHairlineClassV2 = + "absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" const removeClass = "absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover" +const removeClassV2 = + "absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md" export const PromptImageAttachments: Component = (props) => { return ( - 0}> -
- - {(attachment) => ( - -
+ 0 || (props.newLayoutDesigns && (props.comments?.length ?? 0) > 0)}> +
+
+ + + {(item) => ( +
+ + props.onOpenComment?.(item)} + /> + + +
+ )} +
+
+ + {(attachment) => { + const image = attachment.mime.startsWith("image/") + const media = () => ( - -
+ + +
+ } + > + + {typeLabel(attachment.filename, attachment.mime)} + +
} > {attachment.filename} props.onOpen(attachment)} /> - + ) + const name = () => (
{attachment.filename}
-
-
- )} -
+ ) + const remove = () => ( + + ) + // v2 keeps the remove button outside the tooltip trigger so hovering it dismisses the tooltip + return ( + +
+ {media()} + {name()} + {remove()} +
+ + } + > +
+ + {media()} + +
+ + + {remove()} +
+
+ ) + }} + +
+ +
+
+
) diff --git a/packages/app/src/components/prompt-workspace-selector.tsx b/packages/app/src/components/prompt-workspace-selector.tsx index 27d8c97ca2..35ac4f9c6e 100644 --- a/packages/app/src/components/prompt-workspace-selector.tsx +++ b/packages/app/src/components/prompt-workspace-selector.tsx @@ -1,5 +1,6 @@ import { For, Show } from "solid-js" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { Icon } from "@opencode-ai/ui/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { getFilename } from "@opencode-ai/core/util/path" @@ -96,10 +97,17 @@ export function PromptWorkspaceSelector(props: { {(branch) => ( <> -
- - {branch()} -
+ +
+ + {branch()} +
+
)} diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts index 8e424f0f36..9e44cea17b 100644 --- a/packages/app/src/components/session/index.ts +++ b/packages/app/src/components/session/index.ts @@ -1,6 +1,7 @@ export { SessionHeader } from "./session-header" export { SessionContextTab } from "./session-context-tab" export { SortableTab, FileVisual } from "./session-sortable-tab" +export { SortableTabV2 } from "./session-sortable-tab-v2" export { SortableTerminalTab } from "./session-sortable-terminal-tab" export { NewSessionView } from "./session-new-view" export { NewSessionDesignView } from "./session-new-design-view" diff --git a/packages/app/src/components/session/open-in-app-v2.tsx b/packages/app/src/components/session/open-in-app-v2.tsx new file mode 100644 index 0000000000..e26ff2e0ea --- /dev/null +++ b/packages/app/src/components/session/open-in-app-v2.tsx @@ -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 ( + + event.stopPropagation()}> + + 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 })} + > + }> + + + + + state.setMenu("open", open)} + > + event.stopPropagation()} + > + + + + + + {language.t("session.header.openIn")} + { + state.selectApp(value as OpenApp) + }} + > + + {(option) => ( + { + state.selectApp(option.id) + state.setMenu("open", false) + state.openDir(option.id) + }} + > + + {option.label} + + )} + + + + + { + state.setMenu("open", false) + state.copyPath() + }} + > + + {language.t("session.header.open.copyPath")} + + + + + + + ) +} diff --git a/packages/app/src/components/session/open-in-app.tsx b/packages/app/src/components/session/open-in-app.tsx new file mode 100644 index 0000000000..cd363d00c4 --- /dev/null +++ b/packages/app/src/components/session/open-in-app.tsx @@ -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): 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, 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>>({ + 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>) + + 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>) + }) + }) + + 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, + } +} diff --git a/packages/app/src/components/session/session-sortable-tab-v2.tsx b/packages/app/src/components/session/session-sortable-tab-v2.tsx new file mode 100644 index 0000000000..158eba9f0b --- /dev/null +++ b/packages/app/src/components/session/session-sortable-tab-v2.tsx @@ -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 + }) + return ( +
+
+ + props.onTabClose(props.tab)} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => props.onTabClose(props.tab)} + onDblClick={() => props.onTabDoubleClick?.(props.tab)} + > + {(value) => value()} + +
+
+ ) +} diff --git a/packages/app/src/components/status-popover-indicator.test.ts b/packages/app/src/components/status-popover-indicator.test.ts new file mode 100644 index 0000000000..e3c62d2a95 --- /dev/null +++ b/packages/app/src/components/status-popover-indicator.test.ts @@ -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) + }) +}) diff --git a/packages/app/src/components/status-popover-indicator.ts b/packages/app/src/components/status-popover-indicator.ts new file mode 100644 index 0000000000..efb7473753 --- /dev/null +++ b/packages/app/src/components/status-popover-indicator.ts @@ -0,0 +1,19 @@ +import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client" + +export function hasNonBlockingServiceIssue(input: { + mcp: Array + lsp: Array +}) { + 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" +} diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index c75dd5b655..2aed45702a 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -9,6 +9,7 @@ import { ServerConnection, useServer } from "@/context/server" import { useServerSDK } from "@/context/server-sdk" import { useSync } from "@/context/sync" import { useGlobal } from "@/context/global" +import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator" const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody }))) const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody }))) @@ -19,16 +20,14 @@ export function StatusPopover() { const global = useGlobal() const sync = useSync() const [shown, setShown] = createSignal(false) - const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync().data.mcp_ready) - const mcpIssue = createMemo(() => { - const mcp = Object.values(sync().data.mcp ?? {}) - const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration") - const warn = mcp.some((item) => item.status === "needs_auth") - if (failed) return "critical" as const - if (warn) return "warning" as const - }) - const serverHealthy = () => global.servers.health[server.key]?.healthy === true - const healthy = createMemo(() => global.servers.health[server.key]?.healthy === true && !mcpIssue()) + const serverHealth = () => global.servers.health[server.key]?.healthy + const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) + const issue = createMemo(() => + hasNonBlockingServiceIssue({ + mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), + lsp: (sync().data.lsp ?? []).map((item) => item.status), + }), + ) return (
} @@ -87,21 +84,18 @@ function DirectoryStatusPopover() { const sync = useSync() const [shown, setShown] = createSignal(false) const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy - const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready) - const mcpIssue = createMemo(() => { - const mcp = Object.values(sync().data.mcp ?? {}) - const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration") - const warn = mcp.some((item) => item.status === "needs_auth") - if (failed) return "critical" as const - if (warn) return "warning" as const - }) - const healthy = createMemo(() => serverHealth() === true && !mcpIssue()) + const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) + const issue = createMemo(() => + hasNonBlockingServiceIssue({ + mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), + lsp: (sync().data.lsp ?? []).map((item) => item.status), + }), + ) const state = createMemo(() => ({ shown: shown(), ready: ready(), - healthy: healthy(), serverHealth: serverHealth(), - issue: mcpIssue(), + issue: issue(), label: language.t("status.popover.trigger"), onOpenChange: setShown, body: () => ( @@ -123,8 +117,8 @@ function ServerStatusPopover() { const state = createMemo(() => ({ shown: shown(), ready: serverHealth() !== undefined, - healthy: serverHealth() === true, serverHealth: serverHealth(), + issue: false, label: language.t("status.popover.trigger"), onOpenChange: setShown, body: () => ( @@ -140,9 +134,8 @@ function ServerStatusPopover() { type StatusPopoverState = { shown: boolean ready: boolean - healthy: boolean serverHealth: boolean | undefined - issue?: "critical" | "warning" + issue: boolean label: string onOpenChange: (value: boolean) => void body: () => JSX.Element @@ -161,16 +154,6 @@ function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) { } function StatusPopoverView(props: { state: StatusPopoverState }) { - const statusDotClass = () => ({ - "absolute rounded-full": true, - "bg-icon-success-base": props.state.ready && props.state.healthy, - "bg-icon-warning-base": props.state.ready && props.state.serverHealth === true && props.state.issue === "warning", - "bg-icon-critical-base": - props.state.serverHealth === false || - (props.state.ready && props.state.serverHealth === true && props.state.issue === "critical"), - "bg-border-weak-base": props.state.serverHealth === undefined || !props.state.ready, - }) - const popoverProps = { class: "[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl", @@ -195,8 +178,7 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
} diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 8a26d91c46..8e09539fbe 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -47,12 +47,20 @@ export function getAvatarColors(key?: string) { } export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant { - if (key === "orange") return "orange" - if (key === "pink") return "pink" - if (key === "cyan") return "cyan" - if (key === "purple") return "purple" if (key === "mint") return "cyan" if (key === "lime") return "green" + if ( + key === "orange" || + key === "yellow" || + key === "cyan" || + key === "green" || + key === "red" || + key === "pink" || + key === "blue" || + key === "purple" || + key === "gray" + ) + return key return "gray" } diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index e59bec9aaf..1a061a6675 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -37,6 +37,9 @@ type PlatformBase = { /** Open a local path in a local app (desktop only) */ openPath?(path: string, app?: string): Promise + /** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */ + revealPath?(path: string): Promise + /** Restart the app */ restart(): Promise diff --git a/packages/app/src/context/prompt-state.ts b/packages/app/src/context/prompt-state.ts index b6a68be4e7..462cfa638b 100644 --- a/packages/app/src/context/prompt-state.ts +++ b/packages/app/src/context/prompt-state.ts @@ -145,7 +145,7 @@ function contextItemKey(item: ContextItem) { return `${key}:c=${digest.slice(0, 8)}` } -function isCommentItem(item: ContextItem | (ContextItem & { key: string })) { +export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) { return item.type === "file" && !!item.comment?.trim() } diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 0ad8e70f3c..14147b6616 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -24,6 +24,7 @@ export { createPromptSession, createPromptState, DEFAULT_PROMPT, + isCommentItem, isPromptEqual, } from "./prompt-state" export type { diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 343923af76..6b54e69822 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -638,7 +638,7 @@ export const dict = { "session.error.notFound.description": "This tab points to a session that no longer exists on this server.", "session.error.notFound.closeTab": "Close Tab", "session.error.serverConnection": "Can't connect to this server", - "session.review.filesChanged": "{{count}} Files Changed", + "session.review.filesChanged": "Files Changed {{count}}", "session.review.change.one": "Change", "session.review.change.other": "Changes", "session.review.loadingChanges": "Loading changes...", diff --git a/packages/app/src/pages/home-session-open.test.ts b/packages/app/src/pages/home-session-open.test.ts index a71d58ff07..74b9426654 100644 --- a/packages/app/src/pages/home-session-open.test.ts +++ b/packages/app/src/pages/home-session-open.test.ts @@ -2,11 +2,30 @@ import { describe, expect, test } from "bun:test" import { shouldOpenSessionInBackground } from "./home-session-open" describe("shouldOpenSessionInBackground", () => { + test("opens middle clicks in the background", () => { + expect( + shouldOpenSessionInBackground({ button: 1, mac: true, meta: false, ctrl: false, shift: false, alt: false }), + ).toBe(true) + expect( + shouldOpenSessionInBackground({ button: 2, mac: true, meta: false, ctrl: false, shift: false, alt: false }), + ).toBe(false) + }) + test("requires only the platform primary modifier", () => { - expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: false, alt: false })).toBe(true) - expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: false })).toBe(true) - expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: true, alt: false })).toBe(false) - expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: true })).toBe(false) - expect(shouldOpenSessionInBackground({ mac: false, meta: true, ctrl: false, shift: false, alt: false })).toBe(false) + expect( + shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: false, alt: false }), + ).toBe(true) + expect( + shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: false }), + ).toBe(true) + expect( + shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: true, alt: false }), + ).toBe(false) + expect( + shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: true }), + ).toBe(false) + expect( + shouldOpenSessionInBackground({ button: 0, mac: false, meta: true, ctrl: false, shift: false, alt: false }), + ).toBe(false) }) }) diff --git a/packages/app/src/pages/home-session-open.ts b/packages/app/src/pages/home-session-open.ts index 8e32efb559..9f117935b7 100644 --- a/packages/app/src/pages/home-session-open.ts +++ b/packages/app/src/pages/home-session-open.ts @@ -1,10 +1,13 @@ export function shouldOpenSessionInBackground(input: { + button: number mac: boolean meta: boolean ctrl: boolean shift: boolean alt: boolean }) { + if (input.button === 1) return true + if (input.button !== 0) return false if (input.shift || input.alt) return false if (input.mac) return input.meta && !input.ctrl return input.ctrl && !input.meta diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index c8a89672df..74c9b05d29 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -244,6 +244,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) { // background without navigating, matching browser conventions. function isBackgroundOpen(event: MouseEvent) { return shouldOpenSessionInBackground({ + button: event.button, mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform), meta: event.metaKey, ctrl: event.ctrlKey, diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 9a0518fb91..2121ecf3ad 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,4 +1,5 @@ -import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FilePart, Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2" +import { getFilename } from "@opencode-ai/core/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query" import { @@ -57,6 +58,8 @@ import { useSync } from "@/context/sync" import { useTabs } from "@/context/tabs" import { TerminalProvider, useTerminal } from "@/context/terminal" import { PromptInput } from "@/components/prompt-input" +import { setCursorPosition } from "@/components/prompt-input/editor-dom" +import { promptLength } from "@/components/prompt-input/history" import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" import { createPromptInputController, @@ -79,6 +82,7 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel" import { sessionPanelLayout } from "@/pages/session/session-panel-layout" import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2" import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2" +import { SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2" import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2" import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds" @@ -1015,7 +1019,10 @@ export default function Page() { if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { if (composer.blocked() || isChildSession()) return - inputRef?.focus() + const input = inputRef + if (!input) return + input.focus() + setCursorPosition(input, prompt.cursor() ?? promptLength(prompt.current())) } } @@ -1867,7 +1874,30 @@ export default function Page() { .map((item) => ({ id: item.id, text: line(item.id) })) }) - const actions = { revert } + // attachment bytes are embedded as a data URL, so downloading always works; + // revealing requires the on-disk path captured by the client that attached the file + const openAttachment = (file: FilePart) => { + const download = () => { + const anchor = document.createElement("a") + anchor.href = file.url + anchor.download = getFilename(file.filename) || "attachment" + anchor.click() + } + const path = file.filename ?? "" + const absolute = path.startsWith("/") || path.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(path) + if (platform.revealPath && absolute) { + void platform.revealPath(path).then( + (revealed) => { + if (!revealed) download() + }, + () => download(), + ) + return + } + download() + } + + const actions = { revert, openAttachment } createEffect(() => { const sessionID = params.id @@ -2232,6 +2262,13 @@ export default function Page() { reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()} reviewCount={reviewCount} reviewPanel={reviewPanelV2} + reviewSidebarToggle={(disabled) => ( + + )} fileBrowserState={reviewV2State} activeDiff={activeReviewFile()} focusReviewDiff={focusReviewDiff} diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index dd51dd0585..b54fbc4e4c 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js" +import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" import { makeEventListener } from "@solid-primitives/event-listener" @@ -6,9 +6,12 @@ import type { FileSearchHandle } from "@opencode-ai/session-ui/file" import { useFileComponent } from "@opencode-ai/ui/context/file" import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge" import { createLineCommentController } from "@opencode-ai/session-ui/line-comment-annotations" +import { createLineCommentControllerV2 } from "@opencode-ai/session-ui/v2/line-comment-annotations-v2" import { sampledChecksum } from "@opencode-ai/core/util/encode" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { IconButton } from "@opencode-ai/ui/icon-button" +import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { Tabs } from "@opencode-ai/ui/tabs" import { ScrollView } from "@opencode-ai/ui/scroll-view" import { showToast } from "@/utils/toast" @@ -16,6 +19,7 @@ import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange import { useComments } from "@/context/comments" import { useLanguage } from "@/context/language" import { usePrompt } from "@/context/prompt" +import { useSettings } from "@/context/settings" import { getSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" @@ -53,6 +57,30 @@ function FileCommentMenu(props: { ) } +function FileCommentMenuV2(props: { + moreLabel: string + editLabel: string + deleteLabel: string + onEdit: VoidFunction + onDelete: VoidFunction +}) { + return ( +
event.stopPropagation()} onClick={(event) => event.stopPropagation()}> + + + + + + + {props.editLabel} + {props.deleteLabel} + + + +
+ ) +} + type ScrollPos = { x: number; y: number } function createScrollSync(input: { tab: () => string; view: ReturnType["view"] }) { @@ -180,6 +208,15 @@ export function FileTabContent(props: { tab: string }) { } export function SessionFileView(props: { tab: string }) { + const settings = useSettings() + return ( + }> + + + ) +} + +function SessionFileViewV1(props: { tab: string }) { const file = useFile() const comments = useComments() const language = useLanguage() @@ -463,3 +500,294 @@ export function SessionFileView(props: { tab: string }) { return content() } + +function SessionFileViewV2(props: { tab: string }) { + const file = useFile() + const comments = useComments() + const language = useLanguage() + const prompt = usePrompt() + const fileComponent = useFileComponent() + const { sessionKey, tabs, view } = useSessionLayout() + const activeFileTab = createSessionTabs({ + tabs, + pathFromTab: file.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), + }).activeFileTab + + let find: FileSearchHandle | null = null + + const search = { + register: (handle: FileSearchHandle | null) => { + find = handle + }, + } + + const path = createMemo(() => file.pathFromTab(props.tab)) + const state = createMemo(() => { + const p = path() + if (!p) return + return file.get(p) + }) + const contents = createMemo(() => state()?.content?.content ?? "") + const cacheKey = createMemo(() => sampledChecksum(contents())) + const selectedLines = createMemo(() => { + 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) => ( + + ), + }) + + createEffect(() => { + if (typeof window === "undefined") return + + const onKeyDown = (event: KeyboardEvent) => { + if (activeFileTab() !== props.tab) return + if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return + if (event.key.toLowerCase() !== "f") return + + event.preventDefault() + event.stopPropagation() + find?.focus() + } + + makeEventListener(window, "keydown", onKeyDown, { capture: true }) + }) + + createEffect( + on( + path, + () => { + commentsUi.note.reset() + }, + { defer: true }, + ), + ) + + createEffect(() => { + const focus = comments.focus() + const p = path() + if (!focus || !p) return + if (focus.file !== p) return + if (activeFileTab() !== props.tab) return + + const target = fileComments().find((comment) => comment.id === focus.id) + if (!target) return + + commentsUi.note.openComment(target.id, target.selection, { cancelDraft: true }) + requestAnimationFrame(() => comments.clearFocus()) + }) + + let prev = { + loaded: false, + ready: false, + active: false, + } + + createEffect(() => { + const loaded = !!state()?.loaded + const ready = file.ready() + const active = activeFileTab() === props.tab + const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active) + prev = { loaded, ready, active } + if (!restore) return + scrollSync.queueRestore() + }) + + const renderFile = (source: string) => ( +
+ { + scrollSync.queueRestore() + }} + annotations={commentsUi.annotations()} + renderAnnotation={commentsUi.renderAnnotation} + renderGutterUtility={commentsUi.renderGutterUtility} + onLineSelected={(range: SelectedLineRange | null) => { + commentsUi.onLineSelected(range) + }} + onLineSelectionEnd={(range: SelectedLineRange | null) => { + if (!range) { + commentsUi.note.select(null) + commentsUi.note.cancelDraft() + return + } + commentsUi.onLineSelectionEnd(range) + }} + onLineNumberSelectionEnd={(range: SelectedLineRange | null) => { + commentsUi.onLineNumberSelectionEnd(range) + }} + search={search} + class="select-text" + media={{ + mode: "auto", + path: path(), + current: state()?.content, + onLoad: scrollSync.queueRestore, + onError: (args: { kind: "image" | "audio" | "svg" }) => { + if (args.kind !== "svg") return + showToast({ + variant: "error", + title: language.t("toast.file.loadFailed.title"), + }) + }, + }} + /> +
+ ) + + const content = () => ( +
+ + + {renderFile(contents())} + +
{language.t("common.loading")}...
+
+ {(err) =>
{err()}
}
+
+
+
+ ) + + return content() +} diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index b85e7ab13d..7c84ca7078 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -1,28 +1,46 @@ import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { createMediaQuery } from "@solid-primitives/media" +import { DragDropProvider as DndKitProvider, PointerSensor } from "@dnd-kit/solid" +import { isSortable } from "@dnd-kit/solid/sortable" +import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom" +import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers" +import { RestrictToElement } from "@dnd-kit/dom/modifiers" +import { + DragDropProvider, + DragDropSensors, + DragOverlay, + SortableProvider, + closestCenter, + type DragEvent, +} from "@thisbeyond/solid-dnd" import { Tabs } from "@opencode-ai/ui/tabs" import { IconButton } from "@opencode-ai/ui/icon-button" import { Icon } from "@opencode-ai/ui/icon" import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Mark } from "@opencode-ai/ui/logo" -import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" -import type { DragEvent } from "@thisbeyond/solid-dnd" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { useDialog } from "@opencode-ai/ui/context/dialog" import FileTree from "@/components/file-tree" +import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" import { SessionContextUsage } from "@/components/session-context-usage" const reviewTabID = "session-side-panel-review-tab" const reviewTabPanelID = "session-side-panel-review-tabpanel" -import { SessionContextTab, SortableTab, FileVisual } from "@/components/session" +const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel" +import { SessionContextTab, SortableTab, SortableTabV2, FileVisual } from "@/components/session" +import { OpenInAppV2 } from "@/components/session/open-in-app-v2" import { useCommand } from "@/context/command" import { useFile, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" +import { useSDK } from "@/context/sdk" import { useSettings } from "@/context/settings" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" @@ -47,6 +65,7 @@ export function SessionSidePanel(props: { reviewHasFocusableContent: () => boolean reviewCount: () => number reviewPanel: () => JSX.Element + reviewSidebarToggle?: (disabled: boolean) => JSX.Element fileBrowserState?: SessionFileBrowserState activeDiff?: string focusReviewDiff: (path: string) => void @@ -60,7 +79,9 @@ export function SessionSidePanel(props: { const language = useLanguage() const command = useCommand() const dialog = useDialog() + const sdk = useSDK() const { sessionKey, tabs, view, params } = useSessionLayout() + const projectDirectory = createMemo(() => sdk().directory) const isDesktop = createMediaQuery("(min-width: 768px)") const shown = settings.visibility.fileTree @@ -92,11 +113,9 @@ export function SessionSidePanel(props: { return "mix" as const } - const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "") - const out = new Map() for (const diff of diffs()) { - const file = normalize(diff.file) + const file = normalizeFileTreeV2Path(diff.file) const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" out.set(file, kind) @@ -153,6 +172,7 @@ export function SessionSidePanel(props: { fileBrowser: () => !!props.fileBrowserState, }) const contextOpen = tabState.contextOpen + const openFileOpen = tabState.openFileOpen const panelTabs = tabState.panelTabs const openedTabs = tabState.openedTabs const activeTab = tabState.activeTab @@ -170,10 +190,8 @@ export function SessionSidePanel(props: { layout.fileTree.setTab("all") } - const [store, setStore] = createStore({ - activeDraggable: undefined as string | undefined, - }) let fileFilter: HTMLInputElement | undefined + let tabList: HTMLDivElement | undefined const temporaryTab = tabs().preview const previewTab = (value: string) => { const next = normalizeTab(value) @@ -196,10 +214,26 @@ export function SessionSidePanel(props: { } const browserTab = createMemo(() => { if (!props.fileBrowserState) return undefined - if (activeTab() === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB + const active = activeTab() + if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB + if (active && file.pathFromTab(active)) return active return activeFileTab() }) - const browserKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix"))) + // Keep the file-browser shell mounted while any file tab exists. Kobalte briefly + // selects Review while the tab For replaces a preview trigger, which would + // otherwise dispose the sidebar and reset scroll. + const fileBrowserMounted = createMemo(() => { + if (!props.fileBrowserState) return false + return openedTabs().length > 0 || openFileOpen() || !!browserTab() + }) + const fileBrowserVisible = createMemo(() => { + const active = activeTab() + return active !== "review" && active !== "context" && active !== "empty" + }) + const openFileKeybind = createMemo(() => command.keybindParts("file.open")) + const [store, setStore] = createStore({ + activeDraggable: undefined as string | undefined, + }) const handleDragStart = (event: unknown) => { const id = getDraggableId(event) @@ -285,72 +319,283 @@ export function SessionSidePanel(props: { "bg-background-base": !settings.general.newLayoutDesigns(), }} > - - - - -
- { - const stop = createFileTabListSync({ el, contextOpen }) - onCleanup(stop) - }} - > - - + + + +
+ { + const stop = createFileTabListSync({ el, contextOpen }) + onCleanup(stop) + }} > -
-
{language.t("session.tab.review")}
- -
{props.reviewCount()}
-
-
- - - - + +
+
{language.t("session.tab.review")}
+ +
{props.reviewCount()}
+
+
+
+
+ + + tabs().close("context")} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close("context")} + > +
+ +
{language.t("session.tab.context")}
+
+
+
+ + + {(tab) => ( + + } + > + + tabs().close(SESSION_OPEN_FILE_TAB)} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)} + > +
+ + {language.t("command.file.open")} +
+
+
+ )} +
+
+
tabs().close("context")} - aria-label={language.t("common.closeTab")} + iconSize="large" + class="!rounded-md" + onClick={() => { + void import("@/components/dialog-select-file").then((x) => { + dialog.show(() => ) + }) + }} + aria-label={language.t("command.file.open")} /> - } - hideCloseButton - onMiddleClick={() => tabs().close("context")} - > -
- -
{language.t("session.tab.context")}
- + +
+ + +
+ {props.reviewPanel()} +
- + + + +
+
+ +
+ {language.t("session.files.selectToOpen")} +
+
+
+
+
+ + + +
+ +
+
+
+ + + {(tab) => } + + + + + {(tab) => { + const path = file.pathFromTab(tab) + return ( +
+ + {(p) => } + +
+ ) + }} +
+
+ + } + > + + event.target instanceof Element && + (!!event.target.closest('[data-slot="tabs-trigger-close-button"]') || + !!event.target.closest(".session-review-v2-open-in-app-slot")), + }), + ]} + modifiers={[ + RestrictToHorizontalAxis, + RestrictToElement.configure({ element: () => tabList ?? null }), + ]} + plugins={(defaults) => [ + ...defaults.filter((plugin) => plugin !== Accessibility), + AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }), + Feedback.configure({ dropAnimation: null }), + ]} + onDragEnd={(event) => { + const source = event.operation.source + if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return + tabs().move(source.id.toString(), source.index) + }} + > + +
+ { + tabList = el + const stop = createFileTabListSync({ el, contextOpen }) + onCleanup(stop) + }} + > + + {(toggle) => ( +
+ {toggle()(activeTab() === SESSION_OPEN_FILE_TAB)} +
+ )} +
+ + + {props.hasReview() + ? language.t("session.review.filesChanged", { count: props.reviewCount() }) + : language.t("session.tab.review")} + + + + + tabs().close("context")} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close("context")} + > +
+ +
{language.t("session.tab.context")}
+
+
+
{(tab) => ( tabs().all().indexOf(tab)} temporary={temporaryTab() === tab} onTabClose={tabs().close} onTabDoubleClick={temporaryTab() === tab ? openTab : undefined} @@ -386,106 +631,104 @@ export function SessionSidePanel(props: { )} - -
- - { - if (props.fileBrowserState) { - openFileBrowser() - return - } - void import("@/components/dialog-select-file").then((x) => { - dialog.show(() => ) - }) - }} - aria-label={language.t("command.file.open")} - /> - + + {language.t("command.file.open")} + 0}> + + + + } + placement="bottom" + class="flex items-center" + > + } + variant="ghost-muted" + size="large" + onClick={() => openFileBrowser()} + aria-label={language.t("command.file.open")} + /> + +
+
+
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > +
- -
- - -
- {props.reviewPanel()}
-
- - -
-
- -
- {language.t("session.files.selectToOpen")} + +
+ {props.reviewPanel()} +
+
+ + + +
+
+ +
+ {language.t("session.files.selectToOpen")} +
-
- - + + - - -
- -
-
-
- - - previewTab(file.tab(path))} - onSelectPermanent={(path) => openTab(file.tab(path))} - filterRef={(element) => (fileFilter = element)} - /> - - - - {(tab) => } - - - - - {(tab) => { - const path = file.pathFromTab(tab) - return ( -
- - {(p) => } - + + +
+
- ) - }} -
- - + + + + +
+ previewTab(file.tab(path))} + onSelectPermanent={(path) => openTab(file.tab(path))} + filterRef={(element) => (fileFilter = element)} + /> +
+
+ + +
@@ -513,10 +756,19 @@ export function SessionSidePanel(props: { > - {props.reviewCount()}{" "} - {language.t( - props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other", - )} + + {props.reviewCount()}{" "} + {language.t( + props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other", + )} + + } + > + {language.t("session.review.filesChanged", { count: props.reviewCount() })} + {language.t("session.files.all")} diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 168bdf2f69..7438d6febd 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -327,6 +327,7 @@ export function MessageTimeline(props: { parts: getMsgParts, status: sessionStatus, showReasoningSummaries: settings.general.showReasoningSummaries, + inlineComments: settings.general.newLayoutDesigns, }) const activeMessageID = projection.activeMessageID const assistantMessagesByParent = projection.assistantMessagesByParent @@ -1135,6 +1136,10 @@ export function MessageTimeline(props: { const m = messageByID().get(userMessageRow().userMessageID) if (m?.role === "user") return m }) + const messageComments = createMemo(() => { + if (!settings.general.newLayoutDesigns()) return [] + return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []) + }) return ( @@ -1146,6 +1151,7 @@ export function MessageTimeline(props: { parts={getMsgParts(userMessageRow().userMessageID)} actions={props.actions} useV2Actions={settings.general.newLayoutDesigns()} + comments={messageComments()} />
diff --git a/packages/app/src/pages/session/timeline/projection.ts b/packages/app/src/pages/session/timeline/projection.ts index ec2a3190ba..ea8ea4f132 100644 --- a/packages/app/src/pages/session/timeline/projection.ts +++ b/packages/app/src/pages/session/timeline/projection.ts @@ -14,6 +14,7 @@ export function createTimelineProjection(input: { parts: (messageID: string) => Part[] status: Accessor showReasoningSummaries: Accessor + inlineComments: Accessor }) { const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const))) const assistantMessagesByParent = createMemo(() => { @@ -59,6 +60,7 @@ export function createTimelineProjection(input: { input.showReasoningSummaries(), input.status().type, activeMessageID() === userMessage.id, + input.inlineComments(), ), ), ), diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index 6a9cb669d0..72cd9a180c 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -38,6 +38,8 @@ export namespace Timeline { showReasoning: boolean, status: SessionStatus["type"], isActive: boolean, + // v2 renders comments inside the user message attachments row instead of a strip row + inlineComments: boolean, ) { const rows: TimelineRow.TimelineRow[] = [] @@ -74,7 +76,7 @@ export namespace Timeline { : groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group })) if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id })) - if (comments.length > 0) + if (comments.length > 0 && !inlineComments) rows.push( new TimelineRow.CommentStrip({ userMessageID: userMessage.id, @@ -84,7 +86,7 @@ export namespace Timeline { rows.push( new TimelineRow.UserMessage({ userMessageID: userMessage.id, - anchor: comments.length === 0, + anchor: inlineComments || comments.length === 0, }), ) diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx index cf9367d063..3f24b702c7 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -5,7 +5,6 @@ import { SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, SessionReviewV2, SessionReviewV2Sidebar, - SessionReviewV2SidebarToggle, } from "@opencode-ai/session-ui/v2/session-review-v2" import { SessionReviewFilePreviewV2 } from "@opencode-ai/session-ui/v2/session-review-file-preview-v2" import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-v2" @@ -65,6 +64,8 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) { ) const searching = createMemo(() => props.state.filter().trim().length > 0) const kinds = createMemo(() => reviewDiffKinds(diffs())) + // Changes-only trees omit "M" — every row is already a change; A/D stay visible. + const treeKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix"))) const activeDiff = createMemo(() => { // A focused comment takes over the preview until the preview applies it and // clears the focus; the owner then persists the file as the active selection. @@ -112,9 +113,6 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) { stats={} empty={props.empty} sidebarOpen={props.state.sidebarOpened()} - sidebarToggle={ - - } sidebar={ // Always mounted: the sidebar header hosts the changes-mode dropdown, // which must stay reachable when the current mode has zero diffs. @@ -126,7 +124,7 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) { diffs={diffs} filteredFiles={filteredFiles} searching={searching} - kinds={kinds} + kinds={treeKinds} activeDiff={activeDiff} /> } diff --git a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx index 9cbdd40df1..04ad1e3c82 100644 --- a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx +++ b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx @@ -1,14 +1,9 @@ import { createMemo, createSignal, createUniqueId, Show } from "solid-js" import { createQuery } from "@tanstack/solid-query" -import { Tabs } from "@opencode-ai/ui/tabs" import { Icon } from "@opencode-ai/ui/icon" -import { - SessionFilePanelV2, - SessionFilePanelV2Empty, - SessionFilePanelV2Title, -} from "@opencode-ai/session-ui/v2/session-file-panel-v2" -import { SessionReviewV2Sidebar, SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2" -import FileTree, { type Kind } from "@/components/file-tree" +import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode-ai/session-ui/v2/session-file-panel-v2" +import { SessionReviewV2Sidebar } from "@opencode-ai/session-ui/v2/session-review-v2" +import FileTreeV2, { type Kind } from "@/components/file-tree-v2" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" @@ -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 ( - - - - - {title()} - - - } - sidebar={ - {title()}} - filter={filter()} - onFilterChange={setFilter} - onFilterKeyDown={onFilterKeyDown} - filterAutofocus={props.placeholder} - filterRef={props.filterRef} - filterControls={resultsID} - filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined} - filterExpanded={query().length > 0 && files().length > 0} - width={props.state.sidebarWidth()} - onWidthChange={props.state.resizeSidebar} + {title()}} + filter={filter()} + onFilterChange={setFilter} + onFilterKeyDown={onFilterKeyDown} + filterAutofocus={props.placeholder} + filterRef={props.filterRef} + filterControls={resultsID} + filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined} + filterExpanded={query().length > 0 && files().length > 0} + width={props.state.sidebarWidth()} + onWidthChange={props.state.resizeSidebar} + > + props.onSelect(node.path)} + onFileDoubleClick={(node) => props.onSelectPermanent(node.path)} + /> + } > props.onSelect(node.path)} - onFileDoubleClick={(node) => props.onSelectPermanent(node.path)} - /> +
+ {language.t("common.loading")} + {language.t("common.loading.ellipsis")} +
} > 0} fallback={
- {language.t("common.loading")} - {language.t("common.loading.ellipsis")} + {language.t("palette.empty")}
} > - 0} - fallback={ -
- {language.t("palette.empty")} -
- } - > - { - setExplicitHighlight(path) - props.onSelect(path) - }} - onFileDoubleClick={props.onSelectPermanent} - /> -
+ { + setExplicitHighlight(path) + props.onSelect(path) + }} + onFileDoubleClick={props.onSelectPermanent} + />
-
+
+ + } + > + +
+ +
{language.t("command.file.open")}
+
{language.t("session.files.selectToOpen")}
+
+ } > - -
- -
{language.t("command.file.open")}
-
{language.t("session.files.selectToOpen")}
-
- - } - > -
- - {(tab) => } - -
-
- - +
+ + {(tab) => } + +
+
+ ) } diff --git a/packages/app/test-browser/solid-virtual.test.ts b/packages/app/test-browser/solid-virtual.test.ts index 716fa7fa9e..9327eacf42 100644 --- a/packages/app/test-browser/solid-virtual.test.ts +++ b/packages/app/test-browser/solid-virtual.test.ts @@ -1,8 +1,32 @@ import { expect, test } from "bun:test" -import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" +import { createVirtualizer, defaultRangeExtractor, Virtualizer } from "@tanstack/solid-virtual" import { createRoot, createSignal } from "solid-js" import { filterVirtualIndexes } from "@/pages/session/timeline/virtual-items" +test("end anchoring survives consecutive resizes when the first scroll write is clamped", () => { + const writes: { offset: number; adjustments?: number }[] = [] + const virtualizer = new Virtualizer({ + count: 5, + estimateSize: () => 50, + initialOffset: 50, + initialRect: { width: 400, height: 200 }, + anchorTo: "end", + scrollEndThreshold: 1, + getScrollElement: () => null, + scrollToFn: (offset, options) => writes.push({ offset, adjustments: options.adjustments }), + observeElementRect: () => {}, + observeElementOffset: () => {}, + }) + + virtualizer.getTotalSize() + virtualizer.resizeItem(4, 120) + expect(writes).toEqual([{ offset: 50, adjustments: 70 }]) + writes.length = 0 + + virtualizer.resizeItem(4, 200) + expect(writes).toEqual([{ offset: 120, adjustments: 80 }]) +}) + test("reactive count updates preserve measured row sizes", () => { createRoot((dispose) => { const [count, setCount] = createSignal(2) @@ -42,23 +66,26 @@ test("initial rect projects rows before a scroll element connects", () => { }) }) -test("logical scroll offset includes pending measurement adjustments", () => { - createRoot((dispose) => { - const virtualizer = createVirtualizer({ - count: 2, - getScrollElement: () => null, - estimateSize: () => 60, - initialOffset: 100, - initialRect: { width: 800, height: 60 }, - }) - - virtualizer.getTotalSize() - virtualizer.resizeItem(0, 100) - - expect(virtualizer.scrollOffset).toBe(100) - expect(virtualizer.getLogicalScrollOffset()).toBe(140) - dispose() +test("clamps oversized offsets with scroll margin and padding changes", () => { + const options = (paddingEnd: number) => ({ + count: 20, + estimateSize: () => 60, + initialOffset: Number.MAX_SAFE_INTEGER, + initialRect: { width: 800, height: 600 }, + scrollMargin: 64, + paddingEnd, + overscan: 1, + getScrollElement: () => null, + scrollToFn: () => {}, + observeElementRect: () => {}, + observeElementOffset: () => {}, }) + const virtualizer = new Virtualizer(options(64)) + + expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 18, 19]) + + virtualizer.setOptions(options(600)) + expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([18, 19]) }) test("stale pinned indexes do not produce missing virtual items after count shrinks", () => { diff --git a/packages/codemode/package.json b/packages/codemode/package.json index 263daa6a53..43072205a2 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "1.17.18", + "version": "1.17.20", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 35d31f9342..52fc4a17e4 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.17.18", + "version": "1.17.20", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index ffc7a756d2..9820650fae 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.17.18", + "version": "1.17.20", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 47ae1efd7a..7497160977 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.17.18", + "version": "1.17.20", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index d0f16d771f..fd52148535 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.17.18", + "version": "1.17.20", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/support/package.json b/packages/console/support/package.json index 159a59722b..6bd1d8a7d8 100644 --- a/packages/console/support/package.json +++ b/packages/console/support/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-support", - "version": "1.17.18", + "version": "1.17.20", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index 4a3b6b01cd..374eb40f4d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.18", + "version": "1.17.20", "name": "@opencode-ai/core", "type": "module", "license": "MIT", @@ -64,7 +64,7 @@ "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.49", + "@ai-sdk/azure": "3.0.88", "@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", @@ -73,7 +73,7 @@ "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", - "@ai-sdk/openai": "3.0.53", + "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", @@ -110,7 +110,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.10.0", + "gitlab-ai-provider": "6.11.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index ece963cbc2..8436062a5e 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.17.18", + "version": "1.17.20", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 073d288e5c..d6bc5bb3a8 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -184,6 +184,16 @@ export function registerIpcHandlers(deps: Deps) { }) }) + ipcMain.handle("reveal-path", async (_event: IpcMainInvokeEvent, path: string) => { + const exists = await stat(path).then( + () => true, + () => false, + ) + if (!exists) return false + shell.showItemInFolder(path) + return true + }) + ipcMain.handle("read-clipboard-image", () => { const image = clipboard.readImage() if (image.isEmpty()) return null diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 47a757a557..a12864c559 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -95,6 +95,7 @@ const api: ElectronAPI = { saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts), openLink: (url) => ipcRenderer.send("open-link", url), openPath: (path, app) => ipcRenderer.invoke("open-path", path, app), + revealPath: (path) => ipcRenderer.invoke("reveal-path", path), readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"), showNotification: (title, body) => ipcRenderer.send("show-notification", title, body), getWindowFocused: () => ipcRenderer.invoke("get-window-focused"), diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index b57ac83e7f..b94acd6615 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -86,6 +86,7 @@ export type ElectronAPI = { saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise openLink: (url: string) => void openPath: (path: string, app?: string) => Promise + revealPath: (path: string) => Promise readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null> showNotification: (title: string, body?: string) => void getWindowFocused: () => Promise diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 966fb0c0bf..2f14f43481 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -218,6 +218,9 @@ const createPlatform = (windowState: DesktopWindowState): Platform => { } return window.api.openPath(path, app) }, + async revealPath(path: string) { + return window.api.revealPath(path) + }, back() { window.history.back() diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 6aebc6f41d..d5d3c45c3b 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.18", + "version": "1.17.20", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index 117aab177f..9912ecda1a 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.18", + "version": "1.17.20", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index ea959c4e4f..57b73a3efb 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.17.18", + "version": "1.17.20", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index 282076fa65..5b576df63f 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.17.18", + "version": "1.17.20", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index dbb0cafca9..8306dc8ae3 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.18", + "version": "1.17.20", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes", "type": "module", diff --git a/packages/llm/package.json b/packages/llm/package.json index 8e1c674494..3ca36b1310 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.18", + "version": "1.17.20", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f1695e9d89..0f4c85d246 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.18", + "version": "1.17.20", "name": "opencode", "type": "module", "license": "MIT", @@ -58,7 +58,7 @@ "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.49", + "@ai-sdk/azure": "3.0.88", "@ai-sdk/cerebras": "2.0.60", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", @@ -67,7 +67,7 @@ "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", "@ai-sdk/mistral": "3.0.27", - "@ai-sdk/openai": "3.0.53", + "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", @@ -122,7 +122,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.10.0", + "gitlab-ai-provider": "6.11.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", diff --git a/packages/opencode/src/account/account.ts b/packages/opencode/src/account/account.ts index af8ef761eb..4b49d2a748 100644 --- a/packages/opencode/src/account/account.ts +++ b/packages/opencode/src/account/account.ts @@ -348,6 +348,18 @@ const layer: Layer.Layer + group.orgs.map((org) => ({ accountID: group.account.id, orgID: org.id })), + )[0] + if (!next) return + yield* repo.use(next.accountID, Option.some(next.orgID)) + }) + const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) { const resolved = yield* resolveAccess(accountID) if (Option.isNone(resolved)) return Option.none() @@ -445,7 +457,7 @@ const layer: Layer.Layer { + if (model.options.reasoningMode === "pro") return false if (ALLOWED_MODELS.has(model.api.id)) return true if (DISALLOWED_MODELS.has(model.api.id)) return false + if (model.api.id === "gpt-5.6") return false const match = model.api.id.match(/^gpt-(\d+\.\d+)/) return match ? parseFloat(match[1]) > 5.4 : false }) @@ -416,6 +418,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug const requestInit = { ...init, + body: init?.body, headers, } if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index c6051c8996..373e6af579 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1266,6 +1266,14 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { // @ts-expect-error dead V1 expects raw model IDs inside normalized ModelsDev data. const id = `${model.id}-${mode}` const base = fromModelsDevModel(provider, model) + const providerOptions = ( + opts as { + readonly provider?: { + readonly body?: Record + readonly headers?: Record + } + } + ).provider models[id] = { ...base, id: ModelV2.ID.make(id), @@ -1273,18 +1281,8 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { name: `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`, // @ts-expect-error dead V1 expects raw mode costs inside normalized ModelsDev data. cost: opts.cost ? mergeDeep(base.cost, cost(opts.cost)) : base.cost, - // @ts-expect-error dead V1 expects raw mode provider bodies inside normalized ModelsDev data. - options: opts.provider?.body - ? Object.fromEntries( - // @ts-expect-error dead V1 expects raw mode provider bodies inside normalized ModelsDev data. - Object.entries(opts.provider.body).map(([k, v]) => [ - k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()), - v, - ]), - ) - : base.options, - // @ts-expect-error dead V1 expects raw mode headers inside normalized ModelsDev data. - headers: opts.provider?.headers ?? base.headers, + options: modeOptions(base, providerOptions?.body), + headers: providerOptions?.headers ?? base.headers, } } } @@ -1298,6 +1296,17 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { } } +function modeOptions(model: Model, body: Record | undefined) { + if (!body) return model.options + const options = Object.fromEntries( + Object.entries(body).map(([key, value]) => [key.replace(/_([a-z])/g, (_, char) => char.toUpperCase()), value]), + ) + const reasoning = body.reasoning + if (model.api.npm !== "@ai-sdk/openai" || !isRecord(reasoning) || typeof reasoning.mode !== "string") return options + const { reasoning: _, ...rest } = options + return { ...rest, reasoningMode: reasoning.mode } +} + function modelSuggestions(provider: Info | undefined, modelID: ModelV2.ID, enableExperimentalModels: boolean) { const available = provider ? Object.keys(provider.models).filter((id) => { diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 9f2b343163..a996ab3669 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -761,7 +761,7 @@ export function variants(model: Provider.Model): Record [ @@ -794,8 +794,8 @@ export function variants(model: Provider.Model): Record }), ) +it.live("remove switches to another org when the active account is removed", () => + Effect.gen(function* () { + const first = AccountID.make("user-1") + const second = AccountID.make("user-2") + + yield* AccountRepo.Service.use((r) => + r.persistAccount({ + id: first, + email: "one@example.com", + url: "https://one.example.com", + accessToken: AccessToken.make("at_1"), + refreshToken: RefreshToken.make("rt_1"), + expiry: Date.now() + outsideEagerRefreshWindow, + orgID: Option.some(OrgID.make("org-1")), + }), + ) + + yield* AccountRepo.Service.use((r) => + r.persistAccount({ + id: second, + email: "two@example.com", + url: "https://two.example.com", + accessToken: AccessToken.make("at_2"), + refreshToken: RefreshToken.make("rt_2"), + expiry: Date.now() + outsideEagerRefreshWindow, + orgID: Option.some(OrgID.make("org-2")), + }), + ) + + const client = HttpClient.make((req) => + Effect.succeed( + req.url === "https://one.example.com/api/orgs" ? json(req, [org("org-1", "One")]) : json(req, [], 404), + ), + ) + + yield* Account.use.remove(second).pipe(Effect.provide(live(client))) + + const active = yield* AccountRepo.use.active() + expect(Option.getOrThrow(active)).toEqual( + expect.objectContaining({ + id: first, + active_org_id: OrgID.make("org-1"), + }), + ) + }), +) + it.live("token refresh persists the new token", () => Effect.gen(function* () { const id = AccountID.make("user-1") diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts index 0d789bb42b..1381c4ee8a 100644 --- a/packages/opencode/test/plugin/codex.test.ts +++ b/packages/opencode/test/plugin/codex.test.ts @@ -149,16 +149,32 @@ describe("plugin.codex", () => { await enabled.dispose?.() }) - test("uses Codex context limits for OAuth GPT models", async () => { + test("filters unsupported modes and uses Codex context limits for OAuth GPT models", async () => { const hooks = await CodexAuthPlugin({} as never) const limit = { context: 1_050_000, input: 922_000, output: 128_000 } const provider = { - models: Object.fromEntries( - ["gpt-5.4", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"].map((id) => [ - id, - { id, api: { id }, limit, cost: {} }, - ]), - ), + models: { + ...Object.fromEntries( + ["gpt-5.4", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.7-pro"].map((id) => [ + id, + { id, api: { id }, limit, cost: {}, options: {} }, + ]), + ), + "gpt-5.4-pro": { + id: "gpt-5.4-pro", + api: { id: "gpt-5.4" }, + limit, + cost: {}, + options: { reasoningMode: "pro" }, + }, + "gpt-5.6-sol-high": { + id: "gpt-5.6-sol-high", + api: { id: "gpt-5.6-sol" }, + limit, + cost: {}, + options: { reasoningEffort: "high" }, + }, + }, } const models = await hooks.provider!.models!(provider as never, { auth: { type: "oauth" } } as never) @@ -168,6 +184,9 @@ describe("plugin.codex", () => { expect(models["gpt-5.6-sol"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 }) expect(models["gpt-5.6-terra"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 }) expect(models["gpt-5.6-luna"]?.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 }) + expect(models["gpt-5.4-pro"]).toBeUndefined() + expect(models["gpt-5.7-pro"]).toBeDefined() + expect(models["gpt-5.6-sol-high"]).toBeDefined() expect(await hooks.provider!.models!(provider as never, { auth: { type: "api" } } as never)).toBe( provider.models as never, ) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 78d8930886..964c7d5dfd 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1339,16 +1339,17 @@ it.instance( }, ) -test("mode cost preserves over-200k pricing from base model", () => { +test("mode options and cost are derived from the base model", () => { const provider = { id: "openai", name: "OpenAI", env: [], + npm: "@ai-sdk/openai", api: "https://api.openai.com/v1", models: { - "gpt-5.4": { - id: "gpt-5.4", - name: "GPT-5.4", + "gpt-5.6-sol": { + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", family: "gpt", release_date: "2026-03-05", attachment: true, @@ -1384,18 +1385,29 @@ test("mode cost preserves over-200k pricing from base model", () => { }, }, }, + pro: { + provider: { + body: { + reasoning: { mode: "pro" }, + service_tier: "priority", + }, + }, + }, }, }, }, }, } as unknown as ModelsDevProvider - const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"] + const model = Provider.fromModelsDevProvider(provider).models["gpt-5.6-sol-fast"] expect(model.cost.input).toEqual(5) expect(model.cost.output).toEqual(30) expect(model.cost.cache.read).toEqual(0.5) expect(model.cost.cache.write).toEqual(0) expect(model.options["serviceTier"]).toEqual("priority") + const pro = Provider.fromModelsDevProvider(provider).models["gpt-5.6-sol-pro"] + expect(pro.api.id).toEqual("gpt-5.6-sol") + expect(pro.options).toEqual({ reasoningMode: "pro", serviceTier: "priority" }) expect(model.cost.experimentalOver200K).toEqual({ input: 5, output: 22.5, diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index e69dab70f6..2eb31ac86b 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -155,6 +155,43 @@ describe("ProviderTransform.options - setCacheKey", () => { expect(result.store).toBe(false) }) + test("should set store=false for xAI provider by default", () => { + const xaiModel = { + ...mockModel, + providerID: "xai", + api: { + id: "grok-4", + url: "https://api.x.ai", + npm: "@ai-sdk/xai", + }, + } + const result = ProviderTransform.options({ + model: xaiModel, + sessionID, + providerOptions: {}, + }) + expect(result.store).toBe(false) + expect(result.promptCacheKey).toBe(sessionID) + }) + + test("should set store=false for xAI SDK regardless of provider ID", () => { + const xaiModel = { + ...mockModel, + providerID: "custom-xai", + api: { + id: "grok-4", + url: "https://api.x.ai", + npm: "@ai-sdk/xai", + }, + } + const result = ProviderTransform.options({ + model: xaiModel, + sessionID, + providerOptions: {}, + }) + expect(result.store).toBe(false) + }) + test("should set store=false for azure provider by default", () => { const azureModel = { ...mockModel, @@ -3372,6 +3409,42 @@ describe("ProviderTransform.variants", () => { }) describe("@ai-sdk/gateway", () => { + test("configured anthropic aliases route by the API ID", () => { + const model = createMockModel({ + id: "my-claude", + providerID: "gateway", + api: { + id: "anthropic/claude-sonnet-4-6", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + }, + effort: "high", + }) + }) + + test("configured google aliases route by the API ID", () => { + const model = createMockModel({ + id: "my-gemini", + providerID: "gateway", + api: { + id: "google/gemini-2.5-pro", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + high: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }, + max: { thinkingConfig: { includeThoughts: true, thinkingBudget: 32_768 } }, + }) + }) + test("anthropic sonnet 4.6 models return adaptive thinking options", () => { const model = createMockModel({ id: "anthropic/claude-sonnet-4-6", diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 15e2251b75..33c2957372 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1095,7 +1095,7 @@ describe("session.llm.stream", () => { const agent = { name: "test", mode: "primary", - options: {}, + options: { reasoningMode: "pro" }, permission: [{ permission: "*", pattern: "*", action: "allow" }], temperature: 0.2, } satisfies Agent.Info @@ -1126,6 +1126,7 @@ describe("session.llm.stream", () => { expect(body.model).toBe(resolved.api.id) expect(body.stream).toBe(true) expect((body.reasoning as { effort?: string } | undefined)?.effort).toBe("high") + expect((body.reasoning as { mode?: string } | undefined)?.mode).toBe("pro") const maxTokens = body.max_output_tokens as number | undefined expect(maxTokens).toBe(undefined) // match codex cli behavior diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 69a6a3417b..1666138155 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.17.18", + "version": "1.17.20", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 81e184968f..6c987d51c0 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.17.18", + "version": "1.17.20", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 17507ea5b4..e695ea8f87 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "1.17.18", + "version": "1.17.20", "private": true, "type": "module", "license": "MIT", diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index cf3be61f36..0b756b68b6 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/session-ui", - "version": "1.17.18", + "version": "1.17.20", "private": true, "type": "module", "license": "MIT", diff --git a/packages/session-ui/src/components/message-file.test.ts b/packages/session-ui/src/components/message-file.test.ts index 7bdf007631..3882be027e 100644 --- a/packages/session-ui/src/components/message-file.test.ts +++ b/packages/session-ui/src/components/message-file.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { FilePart } from "@opencode-ai/sdk/v2" -import { attached, inline, kind } from "./message-file" +import { attached, inline, kind, typeLabel } from "./message-file" function file(part: Partial = {}): FilePart { return { @@ -52,4 +52,14 @@ describe("message-file", () => { expect(kind(file({ mime: "image/png" }))).toBe("image") expect(kind(file({ mime: "application/pdf" }))).toBe("file") }) + + test("labels attachment types from the basename extension", () => { + expect(typeLabel("list.md", "text/plain")).toBe("Markdown") + expect(typeLabel("/repo/src/main.ts", "text/plain")).toBe("TypeScript") + expect(typeLabel("/tmp/report.pdf", "application/pdf")).toBe("PDF") + expect(typeLabel("notes.xyz", "text/plain")).toBe("XYZ") + expect(typeLabel("/home/user/my.project/Makefile", "text/plain")).toBe("File") + expect(typeLabel(".gitignore", "text/plain")).toBe("File") + expect(typeLabel("/repo/.env", "text/plain")).toBe("File") + }) }) diff --git a/packages/session-ui/src/components/message-file.ts b/packages/session-ui/src/components/message-file.ts index ecc7456902..81ce97827f 100644 --- a/packages/session-ui/src/components/message-file.ts +++ b/packages/session-ui/src/components/message-file.ts @@ -1,3 +1,5 @@ +import { bundledLanguagesInfo } from "shiki" +import { getFilename } from "@opencode-ai/core/util/path" import type { FilePart } from "@opencode-ai/sdk/v2" export function attached(part: FilePart) { @@ -12,3 +14,22 @@ export function inline(part: FilePart) { export function kind(part: FilePart) { return part.mime.startsWith("image/") ? "image" : "file" } + +// language metadata only; grammars stay behind shiki's lazy imports +const LANGUAGE_NAMES = new Map( + bundledLanguagesInfo.flatMap((info) => + [info.id, ...(info.aliases ?? [])].map((alias) => [alias, info.name] as [string, string]), + ), +) + +// attachments carry text/plain for all text files, so the label comes from the extension; +// filename may be an absolute path, so extract the basename before looking for one +export function typeLabel(filename: string, mime: string) { + if (mime === "application/pdf") return "PDF" + const base = getFilename(filename) + // idx 0 is a dotfile like .gitignore, not an extension + const idx = base.lastIndexOf(".") + const suffix = idx <= 0 ? "" : base.slice(idx + 1).toLowerCase() + if (!suffix) return "File" + return LANGUAGE_NAMES.get(suffix) ?? suffix.toUpperCase() +} diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index b50f0902d0..d88961aae0 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -57,8 +57,20 @@ } &[data-type="image"] { - width: 48px; - height: 48px; + position: relative; + width: 58px; + height: 46px; + border: none; + + /* inset box-shadows do not paint over content, so the hairline is an overlay */ + &::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base); + pointer-events: none; + } } &[data-type="file"] { @@ -1301,6 +1313,16 @@ body:not([data-new-layout]) { &:hover { border-color: var(--border-strong-base); } + + &[data-type="image"] { + width: 48px; + height: 48px; + border: 1px solid var(--border-weak-base); + + &::after { + content: none; + } + } } [data-slot="user-message-attachment-name"] { diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index b16b0344f3..717e304678 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -45,6 +45,8 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Markdown } from "./markdown" import { ImagePreview } from "@opencode-ai/ui/image-preview" import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/core/util/path" +import { AttachmentCardV2 } from "../v2/components/attachment-card-v2" +import { CommentCardV2 } from "../v2/components/comment-card-v2" import { checksum } from "@opencode-ai/core/util/encode" import { Tooltip } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -58,7 +60,7 @@ import { ToolStatusTitle } from "./tool-status-title" import { patchFiles } from "./apply-patch-file" import { animate } from "motion" import { useLocation } from "@solidjs/router" -import { attached, inline, kind } from "./message-file" +import { attached, inline, kind, typeLabel } from "./message-file" import { readPartText } from "./message-part-text" import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2" @@ -165,6 +167,7 @@ export interface MessageProps { showAssistantCopyPartID?: string | null showReasoningSummaries?: boolean useV2Actions?: boolean + comments?: UserMessageComment[] } export type SessionAction = (input: { sessionID: string; messageID: string }) => Promise | void @@ -172,6 +175,16 @@ export type SessionAction = (input: { sessionID: string; messageID: string }) => export type UserActions = { fork?: SessionAction revert?: SessionAction + openAttachment?: (file: FilePart) => void +} + +export type UserMessageComment = { + path: string + comment: string + selection?: { + startLine: number + endLine: number + } } export interface MessagePartProps { @@ -937,6 +950,7 @@ export function Message(props: MessageProps) { parts={props.parts} actions={props.actions} useV2Actions={props.useV2Actions} + comments={props.comments} /> )} @@ -1162,6 +1176,7 @@ export function UserMessageDisplay(props: { parts: PartType[] actions?: UserActions useV2Actions?: boolean + comments?: UserMessageComment[] }) { const data = useData() const dialog = useDialog() @@ -1183,6 +1198,8 @@ export function UserMessageDisplay(props: { const attachments = createMemo(() => files().filter(attached)) + const messageComments = createMemo(() => (newLayout() ? (props.comments ?? []) : [])) + const inlineFiles = createMemo(() => files().filter(inline)) const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? []) @@ -1239,35 +1256,59 @@ export function UserMessageDisplay(props: { return (
- 0}> + 0 || messageComments().length > 0}>
+ + {(comment) => ( + + )} + {(file) => { const type = kind(file) const name = file.filename ?? i18n.t("ui.message.attachment.alt") return ( -
{ - if (type === "image") openImagePreview(file.url, name) - }} + { + if (type === "image") openImagePreview(file.url, name) + }} + > + + + {name} +
+ } + > + {name} + +
+ } > - - - {name} -
- } + props.actions?.openAttachment?.(file)} > - {name} - -
+ {typeLabel(name, file.mime)} + + ) }} diff --git a/packages/session-ui/src/v2/components/attachment-card-v2.css b/packages/session-ui/src/v2/components/attachment-card-v2.css new file mode 100644 index 0000000000..1a19dc7b5e --- /dev/null +++ b/packages/session-ui/src/v2/components/attachment-card-v2.css @@ -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; + } + } +} diff --git a/packages/session-ui/src/v2/components/attachment-card-v2.tsx b/packages/session-ui/src/v2/components/attachment-card-v2.tsx new file mode 100644 index 0000000000..2e896383a5 --- /dev/null +++ b/packages/session-ui/src/v2/components/attachment-card-v2.tsx @@ -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 ( +
props.onClick?.()} + > + {props.title} + {props.children} +
+ ) +} diff --git a/packages/session-ui/src/v2/components/comment-card-v2.tsx b/packages/session-ui/src/v2/components/comment-card-v2.tsx new file mode 100644 index 0000000000..fa2fe08c32 --- /dev/null +++ b/packages/session-ui/src/v2/components/comment-card-v2.tsx @@ -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 ( + + + + {getFilenameTruncated(props.path, 14)} + + {(sel) => + sel().startLine === sel().endLine ? `:${sel().startLine}` : `:${sel().startLine}-${sel().endLine}` + } + + + + ) +} diff --git a/packages/session-ui/src/v2/components/line-comment-annotations-v2.tsx b/packages/session-ui/src/v2/components/line-comment-annotations-v2.tsx index 4e8ed86732..cd27a1ecc7 100644 --- a/packages/session-ui/src/v2/components/line-comment-annotations-v2.tsx +++ b/packages/session-ui/src/v2/components/line-comment-annotations-v2.tsx @@ -11,6 +11,7 @@ import { import { useI18n } from "@opencode-ai/ui/context/i18n" import { cloneSelectedLineRange, formatSelectedLineLabel } from "../../pierre/selection-bridge" import { LineCommentEditorV2, LineCommentV2 } from "@opencode-ai/ui/v2/line-comment-v2" +import type { LineCommentEditorV2Mention } from "@opencode-ai/ui/v2/line-comment-v2" type LineCommentControllerV2Props = { comments: Accessor @@ -23,6 +24,7 @@ type LineCommentControllerV2Props = { onDelete?: (comment: T) => void renderCommentActions?: (comment: T, controls: { edit: VoidFunction; remove: VoidFunction }) => JSX.Element editSubmitLabel?: string + mention?: LineCommentEditorV2Mention } type CommentProps = { @@ -43,6 +45,7 @@ type DraftProps = { onSubmit: (value: string) => void cancelLabel?: string submitLabel?: string + mention?: LineCommentEditorV2Mention } function lineCommentElementV2(view: Accessor) { @@ -70,6 +73,7 @@ function lineCommentElementV2(view: Accessor) { onSubmit={view().editor!.onSubmit} cancelLabel={view().editor!.cancelLabel} submitLabel={view().editor!.submitLabel} + mention={view().editor!.mention} />
@@ -87,6 +91,7 @@ function lineCommentDraftElementV2(view: Accessor) { onSubmit={view().onSubmit} cancelLabel={view().cancelLabel} submitLabel={view().submitLabel} + mention={view().mention} />
) @@ -142,6 +147,7 @@ export function createLineCommentControllerV2(props: }, cancelLabel: i18n.t("ui.lineComment.cancel"), submitLabel: props.editSubmitLabel, + mention: props.mention, } : undefined }, @@ -165,6 +171,7 @@ export function createLineCommentControllerV2(props: }, cancelLabel: i18n.t("ui.lineComment.cancel"), submitLabel: i18n.t("ui.lineComment.submit"), + mention: props.mention, }), }) @@ -202,6 +209,7 @@ export function createLineCommentControllerV2(props: } return { + note, annotations, renderAnnotation, renderGutterUtility, diff --git a/packages/session-ui/src/v2/components/session-review-v2.css b/packages/session-ui/src/v2/components/session-review-v2.css index b116ebb43f..9db2ba580c 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.css +++ b/packages/session-ui/src/v2/components/session-review-v2.css @@ -76,6 +76,11 @@ color: var(--text-strong, var(--v2-text-text-base)); } +[data-component="session-review-v2-sidebar-root"] + [data-slot="session-review-v2-sidebar-title"]:not(:has([data-component="select-v2-root"])) { + margin-left: 8px; +} + [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-title"] [data-component="select-v2-root"] { @@ -97,7 +102,7 @@ width: 100%; max-width: 100%; min-width: 0; - margin-top: 2px; + margin-top: 3px; } [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] { diff --git a/packages/session-ui/src/v2/components/session-review-v2.tsx b/packages/session-ui/src/v2/components/session-review-v2.tsx index 3c13c75d7b..6efb2b593a 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-v2.tsx @@ -26,7 +26,6 @@ export type SessionReviewV2Props = { empty?: JSX.Element sidebarOpen?: boolean sidebar?: JSX.Element - sidebarToggle?: JSX.Element activeFile?: string files: string[] onSelectFile: (file: string) => void @@ -199,7 +198,6 @@ export function SessionReviewV2(props: SessionReviewV2Props) { const toolbarStart = () => ( <> - {props.sidebarToggle}
@@ -319,7 +317,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) { ) } -export function SessionReviewV2SidebarToggle(props: { opened: boolean; onToggle: () => void }) { +export function SessionReviewV2SidebarToggle(props: { opened: boolean; disabled?: boolean; onToggle: () => void }) { const i18n = useI18n() return ( @@ -330,6 +328,7 @@ export function SessionReviewV2SidebarToggle(props: { opened: boolean; onToggle: class="session-review-v2-sidebar-toggle" aria-label={i18n.t("ui.sessionReviewV2.toggleSidebar")} aria-expanded={props.opened} + disabled={props.disabled} onClick={props.onToggle} icon={} /> diff --git a/packages/slack/package.json b/packages/slack/package.json index 11bb354f0d..d443317ce0 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.17.18", + "version": "1.17.20", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index f1def44cec..6dc7bc45fb 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.17.18", + "version": "1.17.20", "private": true, "type": "module", "license": "MIT", @@ -13,6 +13,7 @@ }, "dependencies": { "@ibm/plex": "6.4.1", + "@kobalte/core": "catalog:", "@opencode-ai/stats-core": "workspace:*", "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index 7d14ec3d01..ea7502976a 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -39,6 +39,7 @@ import { uniqueComparisonPairs, type ComparisonModelRef, } from "../compare-cards" +import { BreadcrumbSelect } from "../breadcrumb-select" import { applyThemePreference, Footer, @@ -294,61 +295,46 @@ function ModelHero(props: { 0} fallback={ - + {props.labName} } > -
- - {props.labName} - - -
- - {(lab) => ( - - {lab.name} - - )} - -
-
+ ({ + href: language.route(`${import.meta.env.BASE_URL}${lab.id}`), + label: lab.name, + value: lab.id, + }))} + value={providerSlug(labId())} + variant="model" + />
/ 0} fallback={ - + {modelName()} } > -
- - {modelName()} - - -
- - {(model) => ( - - {model.name} - - )} - -
-
+ ({ + href: language.route(`${import.meta.env.BASE_URL}${model.id}`), + label: model.name, + value: model.id, + }))} + value={props.catalog?.id ?? ""} + variant="model" + />
@@ -432,7 +418,7 @@ function ModelHeroSparkline(props: { data: StatsModelData }) { function ChevronDownIcon() { return ( ) } diff --git a/packages/stats/app/src/routes/[lab]/index.tsx b/packages/stats/app/src/routes/[lab]/index.tsx index 01b7a4fc2f..1be2a82ca9 100644 --- a/packages/stats/app/src/routes/[lab]/index.tsx +++ b/packages/stats/app/src/routes/[lab]/index.tsx @@ -29,6 +29,7 @@ import { SectionHeading } from "../section-heading" import { runStatsEffect } from "../../stats-runtime" import { setStatsPageCacheHeaders } from "../stats-cache" import { ComparisonCardsSection, modelRefFromCatalog, uniqueComparisonPairs } from "../compare-cards" +import { BreadcrumbSelect } from "../breadcrumb-select" import { applyThemePreference, Footer, @@ -205,6 +206,7 @@ function LabHero(props: { lab: ModelCatalogLab; labs: ModelCatalogLab[] }) { function LabHeroBreadcrumb(props: { label: string; labs?: ModelCatalogLab[] }) { const language = useLanguage() const labs = () => props.labs ?? [] + const current = () => labs().find((lab) => lab.name === props.label) return ( ) diff --git a/packages/stats/app/src/routes/breadcrumb-select.tsx b/packages/stats/app/src/routes/breadcrumb-select.tsx new file mode 100644 index 0000000000..6057ea2710 --- /dev/null +++ b/packages/stats/app/src/routes/breadcrumb-select.tsx @@ -0,0 +1,71 @@ +import { Select } from "@kobalte/core/select" +import { createMemo } from "solid-js" + +export type BreadcrumbSelectOption = { + href: string + label: string + value: string +} + +export function BreadcrumbSelect(props: { + ariaLabel: string + current?: boolean + label: string + options: BreadcrumbSelectOption[] + value: string + variant: "lab" | "model" +}) { + const selected = createMemo(() => props.options.find((option) => option.value === props.value) ?? null) + + return ( + + data-component="stats-breadcrumb-select" + data-variant={props.variant} + options={props.options} + optionValue="value" + optionTextValue="label" + value={selected()} + placeholder={props.label} + disallowEmptySelection + closeOnSelection + shouldFocusWrap + placement="bottom" + gutter={8} + sameWidth={false} + itemComponent={(itemProps) => ( + + {itemProps.item.rawValue.label} + + )} + onChange={(option) => { + if (!option || option.value === props.value) return + window.location.assign(option.href) + }} + > + + data-slot="stats-breadcrumb-select-value"> + {(state) => {state.selectedOption()?.label ?? props.label}} + + + + + + + + + + + ) +} diff --git a/packages/stats/app/src/routes/compare/index.tsx b/packages/stats/app/src/routes/compare/index.tsx index 03b33c4129..b7f88d8d7e 100644 --- a/packages/stats/app/src/routes/compare/index.tsx +++ b/packages/stats/app/src/routes/compare/index.tsx @@ -134,7 +134,7 @@ export default function ModelCompareIndex() { Data / - + Compare diff --git a/packages/stats/app/src/routes/index.css b/packages/stats/app/src/routes/index.css index c37b665ca6..269a0d9ef3 100644 --- a/packages/stats/app/src/routes/index.css +++ b/packages/stats/app/src/routes/index.css @@ -2888,7 +2888,7 @@ padding: 0 8px; overflow: hidden; background: var(--stats-layer-2); - color: var(--stats-faint); + color: var(--stats-hero-muted); font-size: 13px; font-weight: 500; line-height: 1.1; @@ -2897,21 +2897,7 @@ } [data-page="stats"] a[data-slot="lab-hero-crumb"] { - color: var(--stats-faint); -} - -[data-page="stats"] [data-component="lab-hero-menu"] { - position: relative; - display: inline-block; -} - -[data-page="stats"] [data-component="lab-hero-menu"] summary { - cursor: pointer; - list-style: none; -} - -[data-page="stats"] [data-component="lab-hero-menu"] summary::-webkit-details-marker { - display: none; + color: var(--stats-hero-muted); } [data-page="stats"] [data-slot="lab-hero-crumb"][data-current="true"] { @@ -2920,9 +2906,7 @@ } [data-page="stats"] a[data-slot="lab-hero-crumb"]:hover, -[data-page="stats"] a[data-slot="lab-hero-crumb"]:focus-visible, -[data-page="stats"] summary[data-slot="lab-hero-crumb"]:hover, -[data-page="stats"] summary[data-slot="lab-hero-crumb"]:focus-visible { +[data-page="stats"] a[data-slot="lab-hero-crumb"]:focus-visible { color: var(--stats-text); outline: none; text-decoration: none; @@ -2941,57 +2925,6 @@ color: currentColor; } -[data-page="stats"] [data-component="lab-hero-menu"][open] [data-slot="lab-hero-crumb"] svg { - transform: rotate(180deg); -} - -[data-page="stats"] [data-slot="lab-hero-options"] { - position: absolute; - top: calc(100% + 8px); - left: 50%; - z-index: 5; - display: grid; - width: max-content; - min-width: 180px; - max-width: min(280px, calc(100vw - 48px)); - max-height: min(360px, calc(100vh - 160px)); - padding: 6px; - overflow: auto; - border: 1px solid var(--stats-line); - background: var(--stats-bg); - box-shadow: 0 12px 32px #0000001a; - transform: translateX(-50%); -} - -[data-page="stats"] [data-slot="lab-hero-option"] { - display: flex; - align-items: center; - min-width: 0; - height: 30px; - padding: 0 8px; - overflow: hidden; - color: var(--stats-muted); - font-size: 13px; - font-weight: 500; - line-height: 1.2; - text-decoration: none; - text-overflow: ellipsis; - white-space: nowrap; -} - -[data-page="stats"] [data-slot="lab-hero-option"]:hover, -[data-page="stats"] [data-slot="lab-hero-option"]:focus-visible { - background: var(--stats-layer-2); - color: var(--stats-text); - outline: none; - text-decoration: none; -} - -[data-page="stats"] [data-slot="lab-hero-option"][data-current="true"] { - background: var(--stats-layer-2); - color: var(--stats-text); -} - [data-page="stats"] [data-slot="lab-hero-separator"] { display: inline-flex; align-items: center; @@ -3113,29 +3046,147 @@ white-space: nowrap; } -[data-page="stats"] [data-slot="model-hero-crumb"][data-current="true"] { +[data-page="stats"] a[data-slot="model-hero-crumb"] { + color: var(--stats-hero-muted); +} + +[data-page="stats"] [data-slot="model-hero-crumb"][data-menu="true"] { padding-right: 4px; } -[data-page="stats"] [data-component="model-hero-menu"] { - position: relative; - display: inline-block; +[data-page="stats"] [data-slot="model-hero-crumb"][data-current="true"] { + color: var(--stats-text); +} + +[data-page="stats"] [data-component="stats-breadcrumb-select"] { + display: inline-flex; min-width: 0; } -[data-page="stats"] [data-component="model-hero-menu"] summary { +[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"] { + display: inline-flex; + align-items: center; + justify-content: flex-start; + gap: 4px; + flex: 0 1 auto; + max-width: min(100%, 280px); + height: 24px; + box-sizing: border-box; + margin: 0; + padding: 0 4px 0 8px; + overflow: hidden; + border: 0; + border-radius: 0; + appearance: none; + background: var(--stats-layer-2); + color: var(--stats-hero-muted); + font: inherit; + font-size: 13px; + font-weight: 500; + line-height: 1.1; + text-align: center; + white-space: nowrap; cursor: pointer; - list-style: none; } -[data-page="stats"] [data-component="model-hero-menu"] summary::-webkit-details-marker { - display: none; +[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"][data-current="true"], +[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"]:hover, +[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"][data-expanded] { + color: var(--stats-text); +} + +[data-page="stats"] [data-slot="stats-breadcrumb-select-trigger"]:focus-visible { + color: var(--stats-text); + outline: 1px solid var(--stats-text); + outline-offset: 2px; +} + +[data-page="stats"] [data-slot="stats-breadcrumb-select-value"] { + min-width: 0; + overflow: hidden; +} + +[data-page="stats"] [data-slot="stats-breadcrumb-select-value"] span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-page="stats"] [data-slot="stats-breadcrumb-select-icon"] { + display: inline-flex; + flex: 0 0 auto; + color: var(--stats-faint); +} + +[data-page="stats"] [data-slot="stats-breadcrumb-select-icon"] svg { + width: 16px; + height: 16px; + color: currentColor; +} + +[data-page="stats"] + [data-slot="stats-breadcrumb-select-trigger"][data-expanded] + [data-slot="stats-breadcrumb-select-icon"] { + transform: rotate(180deg); +} + +[data-page="stats"] [data-popper-positioner]:has([data-component="stats-breadcrumb-select-content"]) { + z-index: 20; +} + +[data-page="stats"] [data-component="stats-breadcrumb-select-content"] { + z-index: 20; + width: max-content; + min-width: 180px; + max-width: min(360px, calc(100vw - 48px)); + overflow: hidden; + border: 1px solid var(--stats-line); + background: var(--stats-bg); + box-shadow: 0 12px 32px #0000001a; +} + +[data-page="stats"] [data-component="stats-breadcrumb-select-content"][data-variant="lab"] { + max-width: min(280px, calc(100vw - 48px)); +} + +[data-page="stats"] [data-slot="stats-breadcrumb-select-listbox"] { + display: grid; + max-height: min(360px, calc(100vh - 160px)); + box-sizing: border-box; + margin: 0; + padding: 6px; + overflow: auto; + list-style: none; + outline: none; +} + +[data-page="stats"] [data-slot="stats-breadcrumb-select-option"] { + display: flex; + align-items: center; + min-width: 0; + height: 30px; + box-sizing: border-box; + padding: 0 8px; + overflow: hidden; + color: var(--stats-muted); + font-size: 13px; + font-weight: 500; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + outline: none; +} + +[data-page="stats"] [data-slot="stats-breadcrumb-select-option"][data-highlighted], +[data-page="stats"] [data-slot="stats-breadcrumb-select-option"][data-selected] { + background: var(--stats-layer-2); + color: var(--stats-text); } [data-page="stats"] a[data-slot="model-hero-crumb"]:hover, -[data-page="stats"] a[data-slot="model-hero-crumb"]:focus-visible, -[data-page="stats"] summary[data-slot="model-hero-crumb"]:hover, -[data-page="stats"] summary[data-slot="model-hero-crumb"]:focus-visible { +[data-page="stats"] a[data-slot="model-hero-crumb"]:focus-visible { color: var(--stats-text); outline: none; text-decoration: none; @@ -3151,58 +3202,7 @@ flex: 0 0 auto; width: 16px; height: 16px; - color: var(--stats-faint); -} - -[data-page="stats"] [data-component="model-hero-menu"][open] [data-slot="model-hero-crumb"] svg { - transform: rotate(180deg); -} - -[data-page="stats"] [data-slot="model-hero-options"] { - position: absolute; - top: calc(100% + 8px); - left: 50%; - z-index: 5; - display: grid; - width: max-content; - min-width: 180px; - max-width: min(360px, calc(100vw - 48px)); - max-height: min(360px, calc(100vh - 160px)); - padding: 6px; - overflow: auto; - border: 1px solid var(--stats-line); - background: var(--stats-bg); - box-shadow: 0 12px 32px #0000001a; - transform: translateX(-50%); -} - -[data-page="stats"] [data-slot="model-hero-option"] { - display: flex; - align-items: center; - min-width: 0; - height: 30px; - padding: 0 8px; - overflow: hidden; - color: var(--stats-muted); - font-size: 13px; - font-weight: 500; - line-height: 1.2; - text-decoration: none; - text-overflow: ellipsis; - white-space: nowrap; -} - -[data-page="stats"] [data-slot="model-hero-option"]:hover, -[data-page="stats"] [data-slot="model-hero-option"]:focus-visible { - background: var(--stats-layer-2); - color: var(--stats-text); - outline: none; - text-decoration: none; -} - -[data-page="stats"] [data-slot="model-hero-option"][data-current="true"] { - background: var(--stats-layer-2); - color: var(--stats-text); + color: currentColor; } [data-page="stats"] [data-slot="model-hero-separator"] { @@ -5533,6 +5533,14 @@ white-space: nowrap; } +[data-page="stats"] a[data-slot="compare-home-crumb"] { + color: var(--stats-hero-muted); +} + +[data-page="stats"] [data-component="compare-home-breadcrumb"] [data-slot="compare-home-crumb"][data-current="true"] { + color: var(--stats-text); +} + [data-page="stats"] a[data-slot="compare-home-crumb"]:hover, [data-page="stats"] a[data-slot="compare-home-crumb"]:focus-visible { color: var(--stats-text); @@ -7876,6 +7884,10 @@ max-width: min(100%, 240px); } + [data-page="stats"] [data-component="model-hero-breadcrumb"] [data-slot="stats-breadcrumb-select-trigger"] { + max-width: min(100%, 240px); + } + [data-page="stats"] [data-slot="model-hero-title-row"] { flex-wrap: wrap; gap: 16px; @@ -7967,10 +7979,6 @@ min-width: 48px; } - [data-page="stats"] [data-slot="lab-hero-options"] { - max-width: calc(100vw - 48px); - } - [data-page="stats"] [data-component="lab-related-list"] { grid-auto-columns: minmax(320px, 392px); grid-auto-flow: column; diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index 6733366530..a907acb570 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-core", - "version": "1.17.18", + "version": "1.17.20", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index a4d44b874d..e40dc1275a 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-server", - "version": "1.17.18", + "version": "1.17.20", "private": true, "type": "module", "license": "MIT", diff --git a/packages/storybook/.storybook/mocks/app/context/prompt.ts b/packages/storybook/.storybook/mocks/app/context/prompt.ts index 1ea1fc7983..c41ba553fe 100644 --- a/packages/storybook/.storybook/mocks/app/context/prompt.ts +++ b/packages/storybook/.storybook/mocks/app/context/prompt.ts @@ -60,6 +60,10 @@ export function isPromptEqual(a: Prompt, b: Prompt) { return a.every((part, i) => JSON.stringify(part) === JSON.stringify(b[i])) } +export function isCommentItem(item: ContextItem) { + return !!item.comment?.trim() +} + export function createPromptState() { const [store, setStore] = createStore({ prompt: clonePrompt(DEFAULT_PROMPT), diff --git a/packages/tui/package.json b/packages/tui/package.json index fe3cdeb69c..162b3ddb94 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "1.17.18", + "version": "1.17.20", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index 26ffadbcf6..9723660afa 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.17.18", + "version": "1.17.20", "type": "module", "license": "MIT", "repository": { diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index cd26b0cd50..0978ec5ad2 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -698,3 +698,104 @@ body[data-new-layout] #terminal-panel [data-component="tabs"][data-variant="norm } } } + +body[data-new-layout] #review-panel [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"] { + [data-slot="tabs-list"] { + height: 52px; + padding-right: 12px; + gap: 8px; + --tabs-review-fade: 8px; + } + + [data-slot="tabs-trigger-wrapper"] { + height: 28px; + padding-inline: 10px; + border: 0; + background-color: transparent; + box-shadow: none; + + &::after { + display: none; + } + + [data-slot="tabs-trigger"] { + padding: 0 !important; + } + + &:has([data-slot="tabs-trigger-close-button"]) { + padding-right: 10px; + } + + &:has([data-selected]) { + background-color: var(--v2-background-bg-layer-02); + box-shadow: none; + } + } + + [data-slot="tabs-trigger"] { + font-family: Inter, sans-serif; + font-style: normal; + font-weight: 440; + font-size: 13px; + line-height: 16px; + letter-spacing: -0.04px; + color: var(--v2-text-text-muted); + font-variation-settings: "slnt" 0; + font-variant-numeric: tabular-nums; + + &[data-selected] { + color: var(--v2-text-text-base); + } + + .tab-fileicon-color, + .tab-fileicon-mono, + [data-slot="icon-svg"] { + margin-left: -2px; + } + } + + /* Preview/temporary tabs: beat trigger + .text-14-medium upright defaults */ + [data-slot="tabs-trigger"] .italic { + font-style: italic !important; + font-variation-settings: "slnt" -10 !important; + } +} + +body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-tabs-bar { + width: 100%; + min-width: 0; + gap: 8px; + box-sizing: border-box; + border-bottom: 1px solid var(--border-weaker-base, var(--v2-border-border-weak)); + background-color: var(--v2-background-bg-base); +} + +body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-tabs-bar [data-slot="tabs-list"], +body[data-new-layout] + #review-panel + [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"] + .session-review-v2-tabs-bar + [data-slot="tabs-list"] { + flex: 1 1 0; + min-width: 0; + width: auto; + border-bottom: none; +} + +body[data-new-layout] + #review-panel + [data-component="tabs"] + .session-review-v2-tabs-bar + [data-slot="tabs-list"] + > .sticky { + padding-right: 0; +} + +body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-open-in-app-slot { + flex-shrink: 0; + margin-left: auto; +} + +body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-open-in-app { + flex-shrink: 0; +} diff --git a/packages/ui/src/context/marked-code-span.test.ts b/packages/ui/src/context/marked-code-span.test.ts new file mode 100644 index 0000000000..ba67b2f678 --- /dev/null +++ b/packages/ui/src/context/marked-code-span.test.ts @@ -0,0 +1,13 @@ +import { expect, test } from "bun:test" +import { Marked } from "marked" +import { markedCodeSpanBoundary } from "./marked-code-span" + +test("preserves code spans adjacent to tildes", async () => { + const marked = new Marked(markedCodeSpanBoundary) + + expect(await marked.parse("~`0.1576` to measurement-window-only `0.00092`")).toBe( + "

~0.1576 to measurement-window-only 0.00092

\n", + ) + expect(await marked.parse("`before`~`after`")).toBe("

before~after

\n") + expect(await marked.parse("~~`deleted code`~~")).toBe("

deleted code

\n") +}) diff --git a/packages/ui/src/context/marked-code-span.ts b/packages/ui/src/context/marked-code-span.ts new file mode 100644 index 0000000000..895935c311 --- /dev/null +++ b/packages/ui/src/context/marked-code-span.ts @@ -0,0 +1,17 @@ +import type { MarkedExtension } from "marked" + +// Keep adjacent tilde and backtick runs separate until markedjs/marked#4011 is released. +export const markedCodeSpanBoundary = { + tokenizer: { + inlineText(src) { + const match = /^(`+(?=~)|~+(?=`))/.exec(src) + if (!match) return false + return { + type: "text", + raw: match[0], + text: match[0], + escaped: this.lexer.state.inRawBlock, + } + }, + }, +} satisfies MarkedExtension diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index a4f264ac67..cead61ba60 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -3,6 +3,7 @@ import markedShiki from "marked-shiki" import katex from "katex" import { bundledLanguages, type BundledLanguage } from "shiki" import { createSimpleContext } from "./helper" +import { markedCodeSpanBoundary } from "./marked-code-span" import { getSharedHighlighter, registerCustomTheme, ThemeRegistrationResolved } from "@pierre/diffs" export const OpenCodeTheme = { @@ -521,6 +522,7 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext( name: "Marked", init: (props: { nativeParser?: NativeMarkdownParser }) => { const jsParser = marked.use( + markedCodeSpanBoundary, { renderer: { link({ href, title, text }) { diff --git a/packages/ui/src/v2/components/file-tree-v2.css b/packages/ui/src/v2/components/file-tree-v2.css index e4236bcccd..ba88d09228 100644 --- a/packages/ui/src/v2/components/file-tree-v2.css +++ b/packages/ui/src/v2/components/file-tree-v2.css @@ -14,12 +14,14 @@ justify-content: flex-start; gap: 6px; padding-right: 8px; + overflow: visible; border: none; border-radius: 6px; background-color: transparent; color: var(--v2-text-text-muted); text-align: left; cursor: pointer; + scroll-margin-block: 8px; transition: background-color 120ms ease, color 120ms ease; @@ -42,6 +44,21 @@ background-color: var(--v2-overlay-simple-overlay-pressed); } +[data-component="file-tree-v2"] [data-slot="file-tree-v2-guide"] { + position: absolute; + top: 0; + margin-top: -2px; + height: calc(100% + 2px); + width: 1px; + pointer-events: none; + background-color: var(--v2-border-border-muted); + opacity: 0; +} + +[data-component="file-tree-v2"]:hover [data-slot="file-tree-v2-guide"] { + opacity: 0.5; +} + [data-component="file-tree-v2"] .filetree-icon--mono { color: var(--v2-icon-icon-muted); } @@ -108,7 +125,7 @@ } [data-component="file-tree-v2"] [data-slot="file-tree-v2-change"][data-change="modified"] { - opacity: 0; + color: var(--v2-state-fg-info); } [data-component="file-tree-v2"] [data-slot="file-tree-v2-change"][data-change="added"] { diff --git a/packages/ui/src/v2/components/icon.tsx b/packages/ui/src/v2/components/icon.tsx index b0b70bf37f..b22d3140fe 100644 --- a/packages/ui/src/v2/components/icon.tsx +++ b/packages/ui/src/v2/components/icon.tsx @@ -89,6 +89,10 @@ const icons = { viewBox: "0 0 16 16", body: ``, }, + "outline-xmark": { + viewBox: "0 0 16 16", + body: ``, + }, "outline-chevron-down": { viewBox: "0 0 16 16", body: ``, @@ -129,6 +133,10 @@ const icons = { viewBox: "0 0 16 16", body: ``, }, + "outline-share": { + viewBox: "0 0 16 16", + body: ``, + }, reset: { viewBox: "0 0 20 20", body: ``, diff --git a/packages/ui/src/v2/components/line-comment-v2.css b/packages/ui/src/v2/components/line-comment-v2.css index 31be02ae28..e46500469e 100644 --- a/packages/ui/src/v2/components/line-comment-v2.css +++ b/packages/ui/src/v2/components/line-comment-v2.css @@ -203,3 +203,56 @@ gap: 8px; flex-shrink: 0; } + +[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-list"] { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 180px; + overflow: auto; + margin-top: 8px; + padding: 4px; + border: 0.5px solid var(--v2-border-border-muted); + border-radius: 6px; + background: var(--v2-background-bg-layer-02); +} + +[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-item"] { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; + padding: 6px 8px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--v2-text-text-base); + text-align: left; + cursor: pointer; +} + +[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-item"][data-active] { + background: var(--v2-background-bg-layer-03); +} + +[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-path"] { + display: flex; + align-items: center; + min-width: 0; + font-size: 12px; + line-height: 16px; +} + +[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-dir"] { + min-width: 0; + color: var(--v2-text-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-mention-file"] { + color: var(--v2-text-text-base); + white-space: nowrap; +} diff --git a/packages/ui/src/v2/components/line-comment-v2.tsx b/packages/ui/src/v2/components/line-comment-v2.tsx index 29242fad31..ae0c000a80 100644 --- a/packages/ui/src/v2/components/line-comment-v2.tsx +++ b/packages/ui/src/v2/components/line-comment-v2.tsx @@ -1,4 +1,6 @@ -import { type ComponentProps, type JSX, Show, onMount, splitProps } from "solid-js" +import { For, Show, createSignal, onMount, splitProps, type ComponentProps, type JSX } from "solid-js" +import { FileIcon } from "../../components/file-icon" +import { useFilteredList } from "../../hooks" import { ButtonV2 } from "./button-v2" import "./line-comment-v2.css" @@ -53,6 +55,10 @@ export function LineCommentV2(props: LineCommentV2Props) { ) } +export type LineCommentEditorV2Mention = { + items: (query: string) => string[] | Promise +} + export interface LineCommentEditorV2Props extends Omit, "children" | "onInput" | "onSubmit"> { /** Visible field label above the textarea (default: “Comment”). */ heading?: JSX.Element | string @@ -66,10 +72,22 @@ export interface LineCommentEditorV2Props extends Omit, "c cancelLabel?: string submitLabel?: string autofocus?: boolean + mention?: LineCommentEditorV2Mention +} + +function pathFilename(path: string) { + const index = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + return index === -1 ? path : path.slice(index + 1) +} + +function pathDirectory(path: string) { + const index = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + return index === -1 ? "" : path.slice(0, index + 1) } export function LineCommentEditorV2(props: LineCommentEditorV2Props) { let textareaRef: HTMLTextAreaElement | undefined + const [mentionOpen, setMentionOpen] = createSignal(false) const [local, rest] = splitProps(props, [ "heading", @@ -83,6 +101,7 @@ export function LineCommentEditorV2(props: LineCommentEditorV2Props) { "cancelLabel", "submitLabel", "autofocus", + "mention", "class", "classList", ]) @@ -90,6 +109,78 @@ export function LineCommentEditorV2(props: LineCommentEditorV2Props) { const heading = () => local.heading ?? "Comment" const canSubmit = () => local.value.trim().length > 0 + const closeMention = () => { + setMentionOpen(false) + mention.clear() + } + + const currentMention = () => { + const textarea = textareaRef + if (!textarea) return + if (!local.mention) return + if (textarea.selectionStart !== textarea.selectionEnd) return + + const end = textarea.selectionStart + const match = textarea.value.slice(0, end).match(/@(\S*)$/) + if (!match) return + + return { + query: match[1] ?? "", + start: end - match[0].length, + end, + } + } + + function selectMention(item: { path: string } | undefined) { + if (!item) return + + const textarea = textareaRef + const query = currentMention() + if (!textarea || !query) return + + const value = `${textarea.value.slice(0, query.start)}@${item.path} ${textarea.value.slice(query.end)}` + const cursor = query.start + item.path.length + 2 + + local.onInput(value) + closeMention() + + requestAnimationFrame(() => { + textarea.focus() + textarea.setSelectionRange(cursor, cursor) + }) + } + + const mention = useFilteredList<{ path: string }>({ + items: async (query) => { + if (!local.mention) return [] + if (!query.trim()) return [] + const paths = await local.mention.items(query) + return paths.map((path) => ({ path })) + }, + key: (item) => item.path, + filterKeys: ["path"], + skipFilter: () => true, + onSelect: selectMention, + }) + + const syncMention = () => { + const item = currentMention() + if (!item) { + closeMention() + return + } + + setMentionOpen(true) + mention.onInput(item.query) + } + + const selectActiveMention = () => { + const items = mention.flat() + if (items.length === 0) return + const active = mention.active() + selectMention(items.find((item) => item.path === active) ?? items[0]) + } + const submit = () => { const v = local.value.trim() if (!v) return @@ -122,9 +213,39 @@ export function LineCommentEditorV2(props: LineCommentEditorV2Props) { rows={local.rows ?? 3} placeholder={local.placeholder ?? "Add context for this change"} value={local.value} - onInput={(e) => local.onInput(e.currentTarget.value)} + onInput={(e) => { + local.onInput(e.currentTarget.value) + syncMention() + }} + onClick={() => syncMention()} + onSelect={() => syncMention()} onKeyDown={(e) => { e.stopPropagation() + if (e.isComposing || e.keyCode === 229) return + + if (mentionOpen()) { + if (e.key === "Escape") { + e.preventDefault() + closeMention() + return + } + + if (e.key === "Tab") { + if (mention.flat().length === 0) return + e.preventDefault() + selectActiveMention() + return + } + + const nav = e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter" + const ctrlNav = e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && (e.key === "n" || e.key === "p") + if ((nav || ctrlNav) && mention.flat().length > 0) { + mention.onKeyDown(e) + e.preventDefault() + return + } + } + if (e.key === "Escape") { e.preventDefault() e.currentTarget.blur() @@ -137,6 +258,34 @@ export function LineCommentEditorV2(props: LineCommentEditorV2Props) { } }} /> + 0}> +
+ + {(item) => { + const directory = item.path.endsWith("/") ? item.path : pathDirectory(item.path) + const name = item.path.endsWith("/") ? "" : pathFilename(item.path) + return ( + + ) + }} + +
+
{local.selection}
diff --git a/packages/ui/src/v2/components/project-avatar-v2.tsx b/packages/ui/src/v2/components/project-avatar-v2.tsx index 22da7f6b72..cbc571ab53 100644 --- a/packages/ui/src/v2/components/project-avatar-v2.tsx +++ b/packages/ui/src/v2/components/project-avatar-v2.tsx @@ -38,7 +38,6 @@ export interface ProjectAvatarProps extends ComponentProps<"div"> { export function ProjectAvatar(props: ProjectAvatarProps) { const [split, rest] = splitProps(props, ["fallback", "src", "variant", "unread", "class", "classList", "style"]) - const src = split.src return (
- + {(value) => }
diff --git a/packages/ui/src/v2/components/split-button-v2.css b/packages/ui/src/v2/components/split-button-v2.css new file mode 100644 index 0000000000..e8469f494d --- /dev/null +++ b/packages/ui/src/v2/components/split-button-v2.css @@ -0,0 +1,99 @@ +[data-component="split-button-v2"] { + display: inline-flex; + align-items: stretch; + flex-shrink: 0; + height: 28px; + border-radius: 6px; + background-color: transparent; + box-shadow: none; + overflow: hidden; +} + +[data-component="split-button-v2"]:is(:hover, :has([data-component="split-button-v2-menu-trigger"][data-expanded])) { + box-shadow: inset 0 0 0 1px var(--v2-border-border-muted); +} + +[data-component="split-button-v2-action"], +[data-component="split-button-v2-menu-trigger"] { + display: inline-flex; + align-items: center; + justify-content: center; + height: 28px; + margin: 0; + padding: 0; + border: none; + background: transparent; + color: var(--v2-icon-icon-base); + cursor: default; + outline: none; +} + +[data-component="split-button-v2-action"] { + width: 28px; + flex-shrink: 0; +} + +[data-component="split-button-v2-action"]:hover:not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-hover); +} + +[data-component="split-button-v2-action"]:is(:active, [data-pressed]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-pressed); +} + +[data-component="split-button-v2-action"]:is(:focus-visible, [data-focus]) { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: -2px; + z-index: 1; +} + +[data-component="split-button-v2-action"]:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +[data-component="split-button-v2-menu-trigger"] { + width: 20px; + flex-shrink: 0; +} + +[data-component="split-button-v2-menu-trigger"]:is(:hover, [data-expanded]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-hover); +} + +[data-component="split-button-v2-menu-trigger"]:is(:active, [data-pressed]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-pressed); +} + +[data-component="split-button-v2-menu-trigger"]:is(:focus-visible, [data-focus]) { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: -2px; + z-index: 1; +} + +[data-component="split-button-v2-menu-trigger"]:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +[data-component="split-button-v2-action"] [data-component="app-icon"] { + width: 16px; + height: 16px; +} + +[data-component="menu-v2-content"].open-in-app-v2-menu [data-component="app-icon"] { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +[data-component="menu-v2-content"].open-in-app-v2-menu [data-component="icon"] { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +[data-component="menu-v2-content"].open-in-app-v2-menu [data-component="icon"] [data-slot="icon-svg"] { + width: 16px; + height: 16px; +} diff --git a/packages/ui/src/v2/components/split-button-v2.tsx b/packages/ui/src/v2/components/split-button-v2.tsx new file mode 100644 index 0000000000..9585f8a4a7 --- /dev/null +++ b/packages/ui/src/v2/components/split-button-v2.tsx @@ -0,0 +1,48 @@ +import { splitProps, type ComponentProps, type ParentProps } from "solid-js" +import "./split-button-v2.css" + +export function SplitButtonV2(props: ParentProps>) { + const [split, rest] = splitProps(props, ["class", "classList", "children"]) + return ( +
+ {split.children} +
+ ) +} + +export function SplitButtonV2Action(props: ComponentProps<"button">) { + const [split, rest] = splitProps(props, ["class", "classList"]) + return ( +