diff --git a/.github/TEAM_MEMBERS b/.github/TEAM_MEMBERS index f892eb95db..5eb465e0dd 100644 --- a/.github/TEAM_MEMBERS +++ b/.github/TEAM_MEMBERS @@ -1,4 +1,5 @@ adamdotdevin +arvsrn Brendonovich fwang Hona @@ -7,11 +8,14 @@ jayair jlongster kitlangton kommander +ludvigrask MrMushrooooom nexxeln R44VC0RP rekram1-node thdxr simonklee +Slickstef11 +usrnk1 vimtor starptech diff --git a/bun.lock b/bun.lock index 94ebdae5cd..752969d851 100644 --- a/bun.lock +++ b/bun.lock @@ -30,8 +30,9 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { + "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", "@dnd-kit/dom": "0.5.0", "@dnd-kit/helpers": "0.5.0", @@ -73,6 +74,7 @@ "shiki": "catalog:", "solid-js": "catalog:", "solid-list": "catalog:", + "solid-presence": "0.2.0", "tailwindcss": "catalog:", }, "devDependencies": { @@ -94,7 +96,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.17.15", + "version": "1.17.18", "bin": { "opencode2": "./bin/opencode2.cjs", }, @@ -153,7 +155,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -167,7 +169,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -203,7 +205,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -230,7 +232,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -252,7 +254,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -276,7 +278,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -296,7 +298,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.17.15", + "version": "1.17.18", "bin": { "opencode": "./bin/opencode", }, @@ -320,7 +322,7 @@ "@ai-sdk/provider-utils": "4.0.23", "@ai-sdk/togetherai": "2.0.41", "@ai-sdk/vercel": "2.0.39", - "@ai-sdk/xai": "3.0.82", + "@ai-sdk/xai": "3.0.102", "@aws-sdk/credential-providers": "3.1057.0", "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", @@ -392,7 +394,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -452,7 +454,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -466,7 +468,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "effect": "catalog:", }, @@ -478,7 +480,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -510,7 +512,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -526,7 +528,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "@effect/platform-node-shared": "4.0.0-beta.83", }, @@ -558,7 +560,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -577,7 +579,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.17.15", + "version": "1.17.18", "bin": { "opencode": "./bin/opencode", }, @@ -603,7 +605,7 @@ "@ai-sdk/provider": "3.0.8", "@ai-sdk/togetherai": "2.0.41", "@ai-sdk/vercel": "2.0.39", - "@ai-sdk/xai": "3.0.82", + "@ai-sdk/xai": "3.0.102", "@aws-sdk/credential-providers": "3.1057.0", "@clack/prompts": "1.0.0-alpha.1", "@effect/opentelemetry": "catalog:", @@ -710,7 +712,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/client": "workspace:*", @@ -799,7 +801,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "cross-spawn": "catalog:", }, @@ -814,7 +816,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "@effect/platform-node": "catalog:", "@opencode-ai/core": "workspace:*", @@ -831,7 +833,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -892,7 +894,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -905,7 +907,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "@ibm/plex": "6.4.1", "@opencode-ai/stats-core": "workspace:*", @@ -938,7 +940,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -957,7 +959,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -998,7 +1000,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", @@ -1029,7 +1031,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1080,7 +1082,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.17.15", + "version": "1.17.18", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -1123,6 +1125,7 @@ ], "patchedDependencies": { "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", + "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", @@ -1132,7 +1135,6 @@ "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", }, @@ -1145,6 +1147,7 @@ }, "catalog": { "@cloudflare/workers-types": "4.20251008.0", + "@corvu/drawer": "0.2.4", "@effect/opentelemetry": "4.0.0-beta.83", "@effect/platform-node": "4.0.0-beta.83", "@effect/sql-sqlite-bun": "4.0.0-beta.83", @@ -1270,7 +1273,7 @@ "@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="], - "@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="], + "@ai-sdk/xai": ["@ai-sdk/xai@3.0.102", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.56", "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NeQyOR7OCqDMgaLS4uNX/ep/HrwUzzFYLzXQSRoqLy2jsnqxAJhsgltRwAwf+ADjyPBIAKEOestWnIQA+LrLrQ=="], "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="], @@ -1554,6 +1557,10 @@ "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="], + "@corvu/dialog": ["@corvu/dialog@0.2.4", "", { "dependencies": { "@corvu/utils": "~0.4.2", "solid-dismissible": "~0.1.1", "solid-focus-trap": "~0.1.8", "solid-presence": "~0.2.0", "solid-prevent-scroll": "~0.1.10" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-n54vJq+fOy8GVrnYBdJpD6JXNuyx7LOeMrRxwzAvZnYGpW8+AA12tnb/P/2emJj/HjOO5otheGKb0breshdFlA=="], + + "@corvu/drawer": ["@corvu/drawer@0.2.4", "", { "dependencies": { "@corvu/dialog": "~0.2.4", "@corvu/utils": "~0.4.2", "@solid-primitives/memo": "^1.4.1", "solid-transition-size": "~0.1.4" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-7jQoGZ8ROB9CmXam2nMY2wEskU3IoFwZQywkF/7vrBc/edGsPv7mOVQ1GN6G+4nd7nrMZ3UtqHAhmRh9V0azlw=="], + "@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -2886,6 +2893,8 @@ "@solid-primitives/media": ["@solid-primitives/media@2.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="], + "@solid-primitives/memo": ["@solid-primitives/memo@1.5.1", "", { "dependencies": { "@solid-primitives/scheduled": "^1.5.3", "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-VDPrkl9epp0tbby9MvsqphGFCYCtDRC5J8FKzTqHbQiG5hhR8n6xv4MfjhTW231IaBxxPHLxS43EE8c5Q23mSQ=="], + "@solid-primitives/props": ["@solid-primitives/props@3.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-XzG6en9gSFwmvbKcATm2BxL63HegZ+BAG5fmHi8jyBppQHcaths7ffz+6vYvwYy3nlgLa20ufJLj7tst+PcHFA=="], "@solid-primitives/refs": ["@solid-primitives/refs@1.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA=="], @@ -5602,11 +5611,15 @@ "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + "solid-dismissible": ["solid-dismissible@0.1.1", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-9kcKBJIMdS+586cA1g63HYWxKh3h89leeNHbPZ1csYjuni+NvPBtNr11l0iEX2AKKEt6FHk6qNhc/gjoYAW1pA=="], + + "solid-focus-trap": ["solid-focus-trap@0.1.9", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-LTyNki6GUJPRLXV5uMWPkYClB07SUMubbr2EkAddiR0CJCF/I283txilMU9RURSr/P8EewMfXWu2o3aWrK7A5A=="], + "solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="], "solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="], - "solid-presence": ["solid-presence@0.1.8", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA=="], + "solid-presence": ["solid-presence@0.2.0", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-YM92o+jvpzX3XGaD4rLYmq/Kc2ZVh47GSCLEufHBFQQIurvZTs8SoGJxO8BJGNDxBKdcS8F3dYhW1SDXp4BNjA=="], "solid-prevent-scroll": ["solid-prevent-scroll@0.1.10", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-KplGPX2GHiWJLZ6AXYRql4M127PdYzfwvLJJXMkO+CMb8Np4VxqDAg5S8jLdwlEuBis/ia9DKw2M8dFx5u8Mhw=="], @@ -5614,6 +5627,8 @@ "solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="], + "solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="], + "solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="], "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], @@ -6242,7 +6257,11 @@ "@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], + "@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cQrN6OUn/jvsY3OdsU6Wn+ss7vp1iwIcakZKSlSRMnYqShBfyT7Qht+eqmgxs7w9ttrw6FAG6o11AiBs+iEsTA=="], + + "@ai-sdk/xai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.13", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-ZPtVYt5QIJzOta1kdUiDuCx4HhFkvNPv/rvmZ2b1iXwybYjJsCnNYR4PAw4kW7rgVfDARvHXcU64efWuqNp6bw=="], + + "@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bjYld/2KGPLt78kpqbya+fD4LYS7BqVQJyUjE3qAHrYB0FR2Q90BaWEVIBZaguTWXf/A8L6uG1zO1v9TxVlGWg=="], "@alcalzone/ansi-tokenize/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -6478,6 +6497,8 @@ "@jsx-email/doiuse-email/htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="], + "@kobalte/core/solid-presence": ["solid-presence@0.1.8", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA=="], + "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "@mdx-js/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -6776,6 +6797,8 @@ "@slack/web-api/p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="], + "@solid-primitives/memo/@solid-primitives/utils": ["@solid-primitives/utils@6.4.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-ISSB5QX1qP2ynrheIpYwc4oKR5Ny4siNuUyf1qZniy+Il+p/PtDB0QK1Dnle8noiHpwRD3gpPdubOC3qI/Zamg=="], + "@solidjs/start/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "@solidjs/start/shiki": ["shiki@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="], @@ -6862,6 +6885,8 @@ "ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], + "ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="], + "ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.8.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ=="], "ajv-keywords/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], @@ -7220,6 +7245,8 @@ "socket.io/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], + "solid-transition-size/@corvu/utils": ["@corvu/utils@0.3.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.7" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ZWlyWEE8qV9+CB9OAyo2bTrZGXQN9ZeM+JfYv89zoR+lRACKTDuoOZEdiyL8Uc7U5dUSH1uTqKhTTnaHWb+wZA=="], + "sort-keys/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="], "sst/aws4fetch": ["aws4fetch@1.0.18", "", {}, "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ=="], @@ -7366,6 +7393,8 @@ "@ai-sdk/vercel/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@ai-sdk/xai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@astrojs/check/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "@astrojs/check/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -7920,6 +7949,8 @@ "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/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], + "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], diff --git a/nix/hashes.json b/nix/hashes.json index da7fa46acc..50208fdbbe 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-KTyd2ISQ4n1E3tm1LMEHtz+rKRZga+SONn5J6H0pheQ=", - "aarch64-linux": "sha256-ZIeaaqRr4JorEmmwFYuitxfYEAtrP8C6IlD+pmFRko0=", - "aarch64-darwin": "sha256-Po8FISoBzUMwJSn6nw243/hLT/gAQCrm5HbwXe2uA0g=", - "x86_64-darwin": "sha256-5TZzrCnvg1z00YP6O9U0SXjL04r+VmF7LVrqQ9BbSn4=" + "x86_64-linux": "sha256-JTtn+wXTXg+yklvIMDLcGFaYhTU6ZrCgKT9JTNEQ3gA=", + "aarch64-linux": "sha256-gXU6zyhvAZrZirkL/PlHdkHtEof/7PVSPCaE34Jnd4U=", + "aarch64-darwin": "sha256-Q0oTG3uzOlD/X2kJingLle529lKFoTpyCW2rHXOZ6iE=", + "x86_64-darwin": "sha256-LINvKHxPibTlJeNzfACQx0x+Yj5oROT6Du3I5AtqqXk=" } } diff --git a/package.json b/package.json index 721af00009..b6329f9c96 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@shikijs/stream": "4.2.0", "ulid": "3.0.1", "@kobalte/core": "0.13.11", + "@corvu/drawer": "0.2.4", "@types/luxon": "3.7.1", "@types/node": "24.12.2", "@types/semver": "7.7.1", @@ -153,7 +154,7 @@ "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", - "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", + "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", diff --git a/packages/app/e2e/regression/legacy-new-session.spec.ts b/packages/app/e2e/regression/legacy-new-session.spec.ts new file mode 100644 index 0000000000..30233a5aae --- /dev/null +++ b/packages/app/e2e/regression/legacy-new-session.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { mockOpenCodeServer } from "../utils/mock-server" + +const draftID = "draft_legacy_new_session" +const directory = "C:/OpenCode/LegacyNewSession" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + +test("redirects a draft to the legacy new-session route", async ({ page }) => { + await mockOpenCodeServer(page, { + directory, + project: { + id: "proj_legacy_new_session", + worktree: directory, + vcs: "git", + name: "legacy-new-session", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { all: [], connected: [], default: {} }, + sessions: [], + pageMessages: () => ({ items: [] }), + }) + await page.addInitScript( + ({ directory, draftID, server }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } })) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "draft", draftID, server, directory }]), + ) + }, + { directory, draftID, server }, + ) + + await page.goto(`/new-session?draftId=${draftID}`) + + await expect(page).toHaveURL(`/${base64Encode(directory)}/session`) + await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible() + await expect(page.locator('[data-component="prompt-input"]')).toBeVisible() +}) diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts new file mode 100644 index 0000000000..f69591f608 --- /dev/null +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -0,0 +1,146 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/ReviewOpenFile" +const projectID = "proj_review_open_file" +const sessionID = "ses_review_open_file" +const title = "Review open file" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + +test.use({ viewport: { width: 1440, height: 900 } }) + +test("opens and searches project files inline", async ({ page }) => { + const searches: { query: string; dirs?: string; limit?: number }[] = [] + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "open-file-project", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [ + { + id: sessionID, + slug: sessionID, + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + vcsDiff: [fileDiff("src/changed.ts")], + fileList: (path) => { + if (path) return [] + return [ + fileNode("README.md"), + { name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false }, + ] + }, + fileContent: (path) => ({ type: "text", content: `contents:${path}` }), + findFiles: (input) => { + searches.push(input) + return input.query === "nested" ? ["src/nested.ts"] : [] + }, + pageMessages: () => ({ items: [] }), + }) + await page.addInitScript( + ({ directory, server, sessionID }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.global.dat:layout", + JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "session", server, sessionId: sessionID }]), + ) + }, + { directory, server, sessionID }, + ) + + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const panel = page.locator("#review-panel") + const 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 contextButton.click() + await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") + await panel.getByRole("button", { name: "Open file" }).click() + const filter = panel.getByRole("combobox", { name: "Filter files" }) + await expect(filter).toBeFocused() + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible() + + await panel.getByRole("button", { name: "README.md" }).click() + await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "") + await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible() + + await panel.getByRole("button", { name: "Open file" }).click() + await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0) + await filter.fill("nested") + const result = panel.getByRole("option", { name: /nested\.ts/ }) + await expect(result).toBeVisible() + const resultID = await result.getAttribute("id") + expect(resultID).toBeTruthy() + await expect(filter).toHaveAttribute("aria-activedescendant", resultID!) + await filter.press("Enter") + await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") + await expect(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 page.keyboard.press("Control+w") + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0) + await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") +}) + +function fileNode(path: string) { + return { + name: path, + path, + absolute: `${directory}/${path}`, + type: "file", + ignored: false, + } +} + +function fileDiff(file: string) { + return { + file, + before: "before\n", + after: "after\n", + additions: 1, + deletions: 1, + status: "modified", + } +} diff --git a/packages/app/e2e/regression/review-state-persistence.spec.ts b/packages/app/e2e/regression/review-state-persistence.spec.ts new file mode 100644 index 0000000000..aa42f1bb51 --- /dev/null +++ b/packages/app/e2e/regression/review-state-persistence.spec.ts @@ -0,0 +1,152 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/ReviewStatePersistence" +const projectID = "proj_review_state_persistence" +const sessionA = "ses_review_state_a" +const sessionB = "ses_review_state_b" +const titleA = "Alpha review state" +const titleB = "Beta review state" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + +test.use({ viewport: { width: 1440, height: 900 } }) + +test("restores review mode and selected file per session", async ({ page }) => { + await setup(page) + await page.goto(sessionHref(sessionA)) + await expectSessionTitle(page, titleA) + await page.getByRole("button", { name: "Toggle review" }).click() + + await selectMode(page, "Git changes", "Branch changes") + await selectFile(page, "beta.ts") + + await switchSession(page, titleB) + await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible() + await selectFile(page, "gamma.ts") + + await switchSession(page, titleA) + await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible() + await expectSelectedFile(page, "beta.ts") + await selectMode(page, "Branch changes", "Git changes") + await expectSelectedFile(page, "alpha.ts") + await selectMode(page, "Git changes", "Branch changes") + await expectSelectedFile(page, "beta.ts") + + await page.reload() + await expectSessionTitle(page, titleA) + await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible() + await expectSelectedFile(page, "beta.ts") + + await switchSession(page, titleB) + await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible() + await expectSelectedFile(page, "gamma.ts") +}) + +async function selectMode(page: Page, current: string, next: string) { + await page.getByRole("button", { name: current }).click() + await page.getByRole("option", { name: next }).click() +} + +async function selectFile(page: Page, file: string) { + await page.getByRole("button", { name: file }).click() + await expectSelectedFile(page, file) +} + +async function expectSelectedFile(page: Page, file: string) { + await expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText(file) +} + +async function switchSession(page: Page, title: string) { + await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click() + await expectSessionTitle(page, title) +} + +async function setup(page: Page) { + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "review-state-persistence", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], + pageMessages: () => ({ items: [] }), + }) + await page.route(/\/vcs(?:\?.*)?$/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ branch: "feature", default_branch: "dev" }), + }), + ) + await page.route("**/vcs/diff**", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + new URL(route.request().url()).searchParams.get("mode") === "branch" + ? [diff("src/alpha.ts"), diff("src/beta.ts")] + : [diff("src/alpha.ts"), diff("src/gamma.ts")], + ), + }), + ) + await page.addInitScript( + ({ directory, server, sessions }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))), + ) + }, + { directory, server, sessions: [sessionA, sessionB] }, + ) +} + +function session(id: string, title: string, created: number) { + return { + id, + slug: id, + projectID, + directory, + title, + version: "dev", + time: { created, updated: created }, + } +} + +function diff(file: string) { + return { + file, + additions: 1, + deletions: 1, + status: "modified", + patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`, + } +} + +function sessionHref(sessionID: string) { + return `/server/${base64Encode(server)}/session/${sessionID}` +} diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts index 1ba8ed9474..e3ba607b3d 100644 --- a/packages/app/e2e/regression/review-terminal-stacked.spec.ts +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -9,12 +9,19 @@ const title = "Review terminal stacked" const branchDiffs = [ fileDiff(".github/actions/setup-bun/action.yml", 7), ...Array.from({ length: 2_739 }, (_, index) => - fileDiff(`src/branch/generated-${String(index).padStart(4, "0")}.ts`, 100), + fileDiff( + `src/branch/d${String(Math.floor(index / 100)).padStart(5, "0")}/generated-${String(index).padStart(4, "0")}.ts`, + 100, + false, + ), ), ] test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => { test.setTimeout(120_000) + const events: Array<{ directory: string; payload: Record }> = [] + let detailVersion = 1 + let detailFailures = 1 await page.setViewportSize({ width: 1400, height: 900 }) await mockOpenCodeServer(page, { directory, @@ -48,7 +55,10 @@ test("keeps the review tree and terminal sized when both panels are open", async time: { created: 1700000000000, updated: 1700000000000 }, }, ], + sessionStatus: { [sessionID]: { type: "idle" } }, pageMessages: () => ({ items: [] }), + events: () => events.splice(0, 1), + eventRetry: 16, }) await page.route(/\/vcs(?:\?.*)?$/, (route) => route.fulfill({ @@ -57,17 +67,25 @@ test("keeps the review tree and terminal sized when both panels are open", async body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }), }), ) - await page.route("**/vcs/diff**", (route) => - route.fulfill({ + await page.route("**/vcs/diff**", (route) => { + const url = new URL(route.request().url()) + const scope = url.searchParams.get("directory")?.replaceAll("\\", "/") + const detail = scope?.endsWith("/src/branch/d00027") + if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" }) + return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify( - new URL(route.request().url()).searchParams.get("mode") === "branch" - ? branchDiffs + url.searchParams.get("mode") === "branch" + ? detail + ? branchDiffs + .filter((diff) => diff.file.startsWith("src/branch/d00027/")) + .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion)) + : branchDiffs : Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)), ), - }), - ) + }) + }) await page.route("**/pty", (route) => route.fulfill({ status: 200, @@ -96,7 +114,7 @@ test("keeps the review tree and terminal sized when both panels are open", async await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible() await page.keyboard.press("Control+Backquote") await expect(page.locator("#terminal-panel")).toBeVisible() - await expectTree(page, 2_745, "action.yml") + await expectTree(page, 2_773, "action.yml") await expectStackGeometry(page) const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport') @@ -113,41 +131,65 @@ test("keeps the review tree and terminal sized when both panels are open", async }) expect(bottomGap).toBeGreaterThanOrEqual(0) expect(bottomGap).toBeLessThanOrEqual(16) + const lazyDiff = page.waitForRequest((request) => { + const url = new URL(request.url()) + return ( + url.pathname === "/vcs/diff" && + url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true + ) + }) + await lastFile.click() + await lazyDiff + const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]') + await expect(preview).toContainText("after-1") + detailVersion = 2 + events.push(statusEvent("busy")) + await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() + const refreshedDiff = page.waitForRequest((request) => { + const url = new URL(request.url()) + return ( + url.pathname === "/vcs/diff" && + url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true + ) + }) + events.push(statusEvent("idle")) + await refreshedDiff + await expect(preview).toContainText("after-2") await selectMode(page, "Branch changes", "Git changes") await expectTree(page, 8, "git-0.ts") + await page.getByRole("button", { name: "git-0.ts" }).click() await selectMode(page, "Git changes", "Branch changes") - await expectTree(page, 2_745, "action.yml") + await expectTree(page, 2_773, "action.yml") const filter = page.getByRole("searchbox", { name: "Filter files" }) await filter.fill("generated-2738") await expectTree(page, 1, "generated-2738.ts") await filter.fill("") - await expectTree(page, 2_745, "action.yml") + await expectTree(page, 2_773, "action.yml") await page.getByRole("button", { name: "Toggle file tree" }).click() - await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveAttribute("aria-hidden", "true") - await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1) + await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0) + await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0) await page.getByRole("button", { name: "Toggle file tree" }).click() - await expectTree(page, 2_745, "action.yml") + await expectTree(page, 2_773, "action.yml") await page.keyboard.press("Control+Backquote") await expect(page.locator("#terminal-panel")).toHaveCount(0) - await expectTree(page, 2_745, "action.yml") + await expectTree(page, 2_773, "action.yml") await page.keyboard.press("Control+Backquote") await expect(page.locator("#terminal-panel")).toBeVisible() - await expectTree(page, 2_745, "action.yml") + await expectTree(page, 2_773, "action.yml") await page.getByRole("button", { name: "Toggle review" }).click() - await expect(page.locator("#review-panel")).toHaveAttribute("aria-hidden", "true") - await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1) + await expect(page.locator("#review-panel")).toHaveCount(0) await page.getByRole("button", { name: "Toggle review" }).click() - await expectTree(page, 2_745, "action.yml") + await expectTree(page, 2_773, "action.yml") await page.setViewportSize({ width: 1_000, height: 700 }) - await expectTree(page, 2_745, "action.yml") + await expectTree(page, 2_773, "action.yml") await expectStackGeometry(page) await page.setViewportSize({ width: 1_000, height: 120 }) await page.setViewportSize({ width: 1_400, height: 900 }) - await expectTree(page, 2_745, "action.yml") + await expectTree(page, 2_773, "action.yml") await expectStackGeometry(page) }) @@ -201,12 +243,21 @@ function base64Encode(value: string) { return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") } -function fileDiff(file: string, additions: number) { +function statusEvent(type: "busy" | "idle") { + return { + directory, + payload: { type: "session.status", properties: { sessionID, status: { type } } }, + } +} + +function fileDiff(file: string, additions: number, loaded = true, version = 1) { return { file, additions, deletions: 0, status: "modified", - patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`, + patch: loaded + ? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after-${version}'\n` + : `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}`, } } diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index c69382bba3..34c60ba7f4 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -21,6 +21,7 @@ export interface MockServerConfig { questions?: unknown[] | (() => unknown[]) fileList?: (path: string) => unknown | Promise fileContent?: (path: string) => unknown | Promise + findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown sessionStatus?: unknown } @@ -65,6 +66,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { return json(route, await config.fileList(url.searchParams.get("path") ?? "")) if (path === "/file/content" && config.fileContent) return json(route, await config.fileContent(url.searchParams.get("path") ?? "")) + if (path === "/find/file" && config.findFiles) + return json( + route, + await config.findFiles({ + query: url.searchParams.get("query") ?? "", + dirs: url.searchParams.get("dirs") ?? undefined, + limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined, + }), + ) if (path === "/api/reference") return json(route, { location: { diff --git a/packages/app/package.json b/packages/app/package.json index 7430a614bf..510e2015db 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.17.15", + "version": "1.17.18", "description": "", "type": "module", "exports": { @@ -47,6 +47,7 @@ "vite-plugin-solid": "catalog:" }, "dependencies": { + "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", "@dnd-kit/dom": "0.5.0", "@dnd-kit/helpers": "0.5.0", @@ -88,6 +89,7 @@ "shiki": "catalog:", "solid-js": "catalog:", "solid-list": "catalog:", + "solid-presence": "0.2.0", "tailwindcss": "catalog:" } } diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index b7f87a09cd..86263d172d 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -12,6 +12,7 @@ import { MetaProvider } from "@solidjs/meta" import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { Effect } from "effect" +import { base64Encode } from "@opencode-ai/core/util/encode" import { type Component, createEffect, @@ -174,6 +175,7 @@ function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) function DraftRoute() { const [search] = useSearchParams<{ draftId?: string }>() + const settings = useSettings() const tabs = useTabs() return ( @@ -182,7 +184,14 @@ function DraftRoute() { keyed fallback={} > - {(draft) => } + {(draft) => ( + } + > + + + )} ) diff --git a/packages/app/src/assets/help/introducing-tabs.mp4 b/packages/app/src/assets/help/introducing-tabs.mp4 new file mode 100644 index 0000000000..2bcc8aeef6 Binary files /dev/null and b/packages/app/src/assets/help/introducing-tabs.mp4 differ diff --git a/packages/app/src/assets/help/placeholder.png b/packages/app/src/assets/help/placeholder.png new file mode 100644 index 0000000000..c651d06cc9 Binary files /dev/null and b/packages/app/src/assets/help/placeholder.png differ diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index d7c6c2a84f..905abe1e2d 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -20,14 +20,11 @@ import { uniqueCommandPaletteEntries, type CommandPaletteEntry, } from "./command-palette" +import { DialogCommandPaletteV2 } from "./dialog-command-palette-v2" const DialogSelectFileV2 = lazy(() => import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })), ) -const DialogCommandPaletteV2 = lazy(() => - import("./dialog-command-palette-v2").then((module) => ({ default: module.DialogCommandPaletteV2 })), -) - type DialogSelectFileMode = "all" | "files" export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { diff --git a/packages/app/src/components/dialog-select-model-unpaid-v2.tsx b/packages/app/src/components/dialog-select-model-unpaid-v2.tsx new file mode 100644 index 0000000000..0ce9f18c16 --- /dev/null +++ b/packages/app/src/components/dialog-select-model-unpaid-v2.tsx @@ -0,0 +1,173 @@ +import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { ScrollView } from "@opencode-ai/ui/scroll-view" +import { Tag } from "@opencode-ai/ui/v2/badge-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js" +import { useLocal } from "@/context/local" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { decode64 } from "@/utils/base64" +import { useLanguage } from "@/context/language" +import { ModelTooltip } from "./model-tooltip" + +type ModelState = ReturnType["model"] + +export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => { + const local = useLocal() + const model = props.model ?? local.model + const dialog = useDialog() + const directory = () => decode64(local.slug()) + const providers = useProviders(directory) + const language = useLanguage() + const modelKey = (item: ReturnType[number]) => `${item.provider.id}:${item.id}` + const currentKey = createMemo(() => { + const c = model.current() + return c ? `${c.provider.id}:${c.id}` : undefined + }) + const isFree = (item: ReturnType[number]) => + item.provider.id === "opencode" && (!item.cost || item.cost.input === 0) + + const openProviders = (provider?: string) => { + void import("./dialog-connect-provider").then((x) => { + const controller = x.useProviderConnectController() + controller.select(provider) + void dialog.show(() => ) + }) + } + + const selectModel = (item: ReturnType[number]) => { + model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true }) + dialog.close() + } + + // Focus starts on the dialog's close button, outside the list, so listen at the + // document level while the dialog is mounted instead of on the list container. + let listEl: HTMLDivElement | undefined + onMount(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return + if (!listEl) return + const buttons = Array.from(listEl.querySelectorAll("button")) + if (buttons.length === 0) return + const index = buttons.indexOf(document.activeElement as HTMLButtonElement) + const next = + index < 0 ? (e.key === "ArrowDown" ? 0 : buttons.length - 1) : index + (e.key === "ArrowDown" ? 1 : -1) + buttons[(next + buttons.length) % buttons.length]?.focus() + e.preventDefault() + } + document.addEventListener("keydown", handleKeyDown) + onCleanup(() => document.removeEventListener("keydown", handleKeyDown)) + }) + + return ( + + + {language.t("dialog.model.select.title")} + +
+ + +
+
+
+
+ {language.t("dialog.model.unpaid.freeModels.title")} +
+
+ + {(item) => ( + } + > + + + )} + +
+ +
+
+
+
+ {language.t("dialog.model.unpaid.addMore.title")} +
+
+
+ { + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) { + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + } + return a.name.localeCompare(b.name) + })} + > + {(provider) => ( + + )} + + +
+
+
+
+
+
+ + ) +} diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 23857946d0..4fb7891ec9 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -474,7 +474,7 @@ export function ModelSelectorPopoverV2(props: { }} onSelect={() => selectModel(item)} > - {item.name} + {item.name} {language.t("model.tag.free")} diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 93ff46d5c1..718e2f3493 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -201,6 +201,7 @@ export default function FileTree(props: { kinds?: ReadonlyMap draggable?: boolean onFileClick?: (file: FileNode) => void + onFileDoubleClick?: (file: FileNode) => void _filter?: Filter _marks?: Set @@ -440,6 +441,7 @@ export default function FileTree(props: { active={props.active} draggable={props.draggable} onFileClick={props.onFileClick} + onFileDoubleClick={props.onFileDoubleClick} _filter={filter()} _marks={marks()} _deeps={deeps()} @@ -462,6 +464,7 @@ export default function FileTree(props: { as="button" type="button" onClick={() => props.onFileClick?.(node)} + onDblClick={() => props.onFileDoubleClick?.(node)} >
diff --git a/packages/app/src/components/help-button.tsx b/packages/app/src/components/help-button.tsx index 9109418fcf..de156e1b33 100644 --- a/packages/app/src/components/help-button.tsx +++ b/packages/app/src/components/help-button.tsx @@ -1,54 +1,144 @@ -import { Icon } from "@opencode-ai/ui/v2/icon" -import { Popover } from "@opencode-ai/ui/popover" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { createSignal, Show } from "solid-js" import { createStore } from "solid-js/store" +import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer" +import { usePlatform } from "@/context/platform" +import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4" +import { Persist, persisted } from "@/utils/persist" + +const helpIcon = ( + +) + +const triggerClass = + "size-7 !rounded-full shrink-0 bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]" + +// TODO: wire to changelog / seen-state when available +const showPopover = () => true export function HelpButton() { if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null - const [state, setState] = /* persisted(Persist.global("help-button"), */ createStore({ dismissed: false }) /* ) */ - const [shown, setShown] = createSignal(false) + const platform = usePlatform() return ( - - - + { + event.preventDefault() + platform.openLink(event.currentTarget.href) + }} + > + {helpIcon} + + ) +} + +// can remove this after the tabs rollout has been out for a while +export function TabsInfoPopup() { + if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null + + const [state, setState] = persisted(Persist.global("tabsInfoPopup"), createStore({ dismissed: false })) + // setState({ dismissed: false }) // for testing + const [drawerOpen, setDrawerOpen] = createSignal(false) + + return ( + + +
+ + +
+
+ +
+

+ June 16 +

+ } + /> +
+
+

+ Introducing Tabs Navigation. +

+

+ We've introduced tabs as the primary navigation in OpenCode. Your most important session are now pinned at + the top of your screen at all times. No more hunting through menus or losing your place mid-session. Switch + contexts instantly, pick up exactly where you left off, and keep your focus where it belongs: on the + sessions. +

+
+
+
) } diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 75ec3b7be5..324a789825 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -43,6 +43,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" import { useDialog } from "@opencode-ai/ui/context/dialog" import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model" +import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" +import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" import { useCommand } from "@/context/command" import { Persist, persisted } from "@/utils/persist" import { usePermission } from "@/context/permission" @@ -520,7 +522,7 @@ export const PromptInput: Component = (props) => { const setMode = (mode: "normal" | "shell") => { setStore("mode", mode) - setStore("popover", null) + setStore({ popover: null, slashMenu: false, slashMenuQuery: "" }) requestAnimationFrame(() => editorRef?.focus()) } @@ -554,7 +556,7 @@ export const PromptInput: Component = (props) => { }, ]) - const closePopover = () => setStore("popover", null) + const closePopover = () => setStore({ popover: null, slashMenu: false, slashMenuQuery: "" }) const resetHistoryNavigation = (force = false) => { if (!force && (store.historyIndex < 0 || store.applyingHistory)) return @@ -800,17 +802,30 @@ export const PromptInput: Component = (props) => { const handleSlashSelect = (cmd: SlashCommand | undefined) => { if (!cmd) return + const menu = store.slashMenu closePopover() const images = imageAttachments() if (cmd.type === "custom") { const text = `/${cmd.trigger} ` + if (menu) { + editorRef.focus() + setCursorPosition(editorRef, 0) + addPart({ type: "text", content: text, start: 0, end: text.length }) + focusEditorEnd() + return + } setEditorText(text) prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length) focusEditorEnd() return } + if (menu) { + command.trigger(cmd.id, "slash") + return + } + clearEditor() prompt.set([...DEFAULT_PROMPT, ...images], 0) command.trigger(cmd.id, "slash") @@ -1072,10 +1087,10 @@ export const PromptInput: Component = (props) => { if (atMatch) { atOnInput(atMatch[1]) - setStore("popover", "at") + setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" }) } else if (slashMatch) { slashOnInput(slashMatch[1]) - setStore("popover", "slash") + setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" }) } else { closePopover() } @@ -1171,6 +1186,28 @@ export const PromptInput: Component = (props) => { return true } + const openCommands = () => { + const populated = prompt.dirty() || commentCount() > 0 + requestAnimationFrame(() => { + if (!populated) { + if (!addPart({ type: "text", content: "/", start: 0, end: 0 })) return + slashOnInput("") + setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" }) + return + } + slashOnInput("") + setStore({ popover: "slash", slashMenu: true, slashMenuQuery: "" }) + }) + } + + const openContext = () => { + requestAnimationFrame(() => { + if (!addPart({ type: "text", content: "@", start: 0, end: 0 })) return + atOnInput("") + setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" }) + }) + } + const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { history.add(prompt, mode, mode === "shell" ? [] : historyComments()) } @@ -1199,7 +1236,7 @@ export const PromptInput: Component = (props) => { } setStore("mode", "normal") - setStore("popover", null) + closePopover() setStore("historyIndex", -1) setStore("savedPrompt", null) prompt.set(edit.prompt, promptLength(edit.prompt)) @@ -1286,7 +1323,10 @@ export const PromptInput: Component = (props) => { resetHistoryNavigation(true) }, setMode: (mode) => setStore("mode", mode), - setPopover: (popover) => setStore("popover", popover), + setPopover: (popover) => { + if (!popover) return closePopover() + setStore({ popover, slashMenu: false, slashMenuQuery: "" }) + }, newSessionWorktree: () => props.newSessionWorktree, onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, shouldQueue: props.shouldQueue, @@ -1325,7 +1365,7 @@ export const PromptInput: Component = (props) => { const cursorPosition = getCursorPosition(editorRef) if (cursorPosition === 0) { setStore("mode", "shell") - setStore("popover", null) + closePopover() event.preventDefault() return } @@ -1460,6 +1500,29 @@ export const PromptInput: Component = (props) => { } } + const handleSlashMenuKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + closePopover() + requestAnimationFrame(() => editorRef.focus()) + event.preventDefault() + return + } + + if (event.key === "Tab") { + selectPopoverActive() + event.preventDefault() + return + } + + const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey + const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter" + const ctrlNav = ctrl && (event.key === "n" || event.key === "p") + if (!nav && !ctrlNav) return + slashOnKeyDown(event) + if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) scrollSlashActiveIntoView() + event.preventDefault() + } + const agentsLoading = () => props.controls.agents.loading const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading()) const providersLoading = () => props.controls.model.loading @@ -1488,9 +1551,11 @@ export const PromptInput: Component = (props) => { style: control(), onClose: restoreFocus, onUnpaidClick: () => { - void import("@/components/dialog-select-model-unpaid").then((x) => { - dialog.show(() => ) - }) + if (props.controls.newLayoutDesigns) { + dialog.show(() => ) + return + } + dialog.show(() => ) }, })) @@ -1527,6 +1592,13 @@ export const PromptInput: Component = (props) => { slashActive={slashActive() ?? undefined} setSlashActive={setSlashActive} onSlashSelect={handleSlashSelect} + slashMenu={store.slashMenu} + slashMenuQuery={store.slashMenuQuery} + onSlashMenuInput={(value) => { + setStore("slashMenuQuery", value) + slashOnInput(value) + }} + onSlashMenuKeyDown={handleSlashMenuKeyDown} commandKeybind={command.keybind} commandKeybindParts={command.keybindParts} newLayoutDesigns={props.controls.newLayoutDesigns} @@ -1625,23 +1697,45 @@ export const PromptInput: Component = (props) => { placement="top" value={ <> - {language.t("prompt.action.attachFile")} + {language.t("prompt.menu.addImagesAndFiles")} } > - + + + + + + {language.t("prompt.menu.imagesAndFiles")} + + + + {language.t("prompt.menu.commands")} + + + {language.t("prompt.menu.context")} + + setMode("shell")} shortcut="!"> + {language.t("prompt.menu.shellCommand")} + + + + @@ -1682,7 +1776,7 @@ export const PromptInput: Component = (props) => { class="max-w-[160px] justify-start capitalize" style={control()} > - + {props.controls.model.selection.variant.current() ?? language.t("common.default")} @@ -1969,11 +2063,9 @@ export const PromptInput: Component = (props) => { class="min-w-0 max-w-[320px] text-13-regular text-text-base group" style={control()} onClick={() => { - void import("@/components/dialog-select-model-unpaid").then((x) => { - dialog.show(() => ( - - )) - }) + dialog.show(() => ( + + )) }} > @@ -2140,30 +2232,47 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) { } > - + } > - - {(providerID) => ( - - )} - - {props.state.modelName} - - - - + + + + } > diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index a84cbcca08..b9a2dc7afa 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -41,6 +41,10 @@ type PromptPopoverProps = { slashActive?: string setSlashActive: (id: string) => void onSlashSelect: (item: SlashCommand) => void + slashMenu: boolean + slashMenuQuery: string + onSlashMenuInput: (value: string) => void + onSlashMenuKeyDown: (event: KeyboardEvent) => void commandKeybind: (id: string) => string | undefined commandKeybindParts: (id: string) => string[] newLayoutDesigns: boolean @@ -254,6 +258,20 @@ export const PromptPopover: Component = (props) => { + +
+ requestAnimationFrame(() => el.focus())} + value={props.slashMenuQuery} + onInput={(event) => props.onSlashMenuInput(event.currentTarget.value)} + onKeyDown={props.onSlashMenuKeyDown} + onMouseDown={(event) => event.stopPropagation()} + aria-label={props.t("prompt.menu.commands")} + placeholder="/" + class="w-full bg-transparent outline-none text-[13px] leading-5 text-v2-text-text-base placeholder:text-v2-text-text-faint" + /> +
+
0} fallback={ diff --git a/packages/app/src/components/prompt-input/transient-state.ts b/packages/app/src/components/prompt-input/transient-state.ts index c8abf9a766..eaf35476d1 100644 --- a/packages/app/src/components/prompt-input/transient-state.ts +++ b/packages/app/src/components/prompt-input/transient-state.ts @@ -4,6 +4,8 @@ import type { PromptHistoryEntry } from "./history" export type PromptInputTransientState = { popover: "at" | "slash" | null + slashMenu: boolean + slashMenuQuery: string historyIndex: number savedPrompt: PromptHistoryEntry | null placeholder: number @@ -16,6 +18,8 @@ export type PromptInputTransientState = { function resetPromptInputTransientState(setStore: SetStoreFunction) { setStore({ popover: null, + slashMenu: false, + slashMenuQuery: "", historyIndex: -1, savedPrompt: null, draggingType: null, @@ -28,6 +32,8 @@ function resetPromptInputTransientState(setStore: SetStoreFunction, placeholder: number) { const [store, setStore] = createStore({ popover: null, + slashMenu: false, + slashMenuQuery: "", historyIndex: -1, savedPrompt: null, placeholder, diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index 7ac1aa9965..fd2d1dd1a3 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -4,6 +4,7 @@ import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2" import { Button } from "@opencode-ai/ui/button" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { createMediaQuery } from "@solid-primitives/media" import { useFile } from "@/context/file" import { useLayout } from "@/context/layout" @@ -14,6 +15,7 @@ import { useSDK } from "@/context/sdk" import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" +import { useSettings } from "@/context/settings" interface SessionContextUsageProps { variant?: "button" | "indicator" @@ -47,8 +49,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const layout = useLayout() const language = useLanguage() const sdk = useSDK() + const settings = useSettings() const providers = useProviders(() => sdk().directory) const { params, tabs, view } = useSessionLayout() + const isDesktop = createMediaQuery("(min-width: 768px)") const variant = createMemo(() => props.variant ?? "button") const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default") @@ -56,6 +60,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { tabs, pathFromTab: file.pathFromTab, normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), + fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id, }) const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : [])) const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 500cce7def..da1770cd13 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -24,6 +24,7 @@ import { focusTerminalById } from "@/pages/session/helpers" import { useSessionLayout } from "@/pages/session/session-layout" import { messageAgentColor } from "@/utils/agent" import { decode64 } from "@/utils/base64" +import { fileManagerApp } from "@/utils/file-manager" import { Persist, persisted } from "@/utils/persist" import { StatusPopover, StatusPopoverV2 } from "../status-popover" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" @@ -175,11 +176,7 @@ export function SessionHeader() { return LINUX_APPS }) - const fileManager = createMemo(() => { - 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 } - }) + const fileManager = createMemo(() => fileManagerApp(os())) createEffect(() => { if (platform.platform !== "desktop") return diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx index f04228ca66..d78f392941 100644 --- a/packages/app/src/components/session/session-sortable-tab.tsx +++ b/packages/app/src/components/session/session-sortable-tab.tsx @@ -10,7 +10,7 @@ import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { useCommand } from "@/context/command" -export function FileVisual(props: { path: string; active?: boolean }): JSX.Element { +export function FileVisual(props: { path: string; active?: boolean; temporary?: boolean }): JSX.Element { return (
- {getFilename(props.path)} + + {getFilename(props.path)} +
) } -export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element { +export function SortableTab(props: { + tab: string + temporary?: boolean + onTabClose: (tab: string) => void + onTabDoubleClick?: (tab: string) => void +}): JSX.Element { const file = useFile() const language = useLanguage() const command = useCommand() @@ -36,7 +43,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v const content = createMemo(() => { const value = path() if (!value) return - return + return }) return (
@@ -61,6 +68,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v } hideCloseButton onMiddleClick={() => props.onTabClose(props.tab)} + onDblClick={() => props.onTabDoubleClick?.(props.tab)} > {(value) => value()} diff --git a/packages/app/src/components/settings-dialog.tsx b/packages/app/src/components/settings-dialog.tsx index d2cc85c04e..46dd89ade8 100644 --- a/packages/app/src/components/settings-dialog.tsx +++ b/packages/app/src/components/settings-dialog.tsx @@ -4,7 +4,7 @@ import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useDialog } from "@opencode-ai/ui/context/dialog" -export function useSettingsDialog() { +export function useSettingsDialog(defaultValue?: string) { const dialog = useDialog() const params = useParams<{ id?: string }>() let run = 0 @@ -19,7 +19,7 @@ export function useSettingsDialog() { const sessionID = params.id void import("@/components/settings-v2").then((module) => { if (dead || run !== current) return - void dialog.show(() => ) + void dialog.show(() => ) }) } } diff --git a/packages/app/src/components/ui/drawer.tsx b/packages/app/src/components/ui/drawer.tsx new file mode 100644 index 0000000000..9ac555ec9d --- /dev/null +++ b/packages/app/src/components/ui/drawer.tsx @@ -0,0 +1,119 @@ +/** + * Taken from https://www.solid-ui.com/docs/components/drawer + * Only used in one place hence not a v2 component yet... can be promoted to ui/v2 later + */ + +import type { Component, ComponentProps, JSX, ValidComponent } from "solid-js" +import { splitProps } from "solid-js" +import type { ContentProps, DescriptionProps, DynamicProps, LabelProps, OverlayProps } from "@corvu/drawer" +import DrawerPrimitive from "@corvu/drawer" + +const Drawer = DrawerPrimitive + +const DrawerTrigger = DrawerPrimitive.Trigger + +const DrawerPortal = DrawerPrimitive.Portal + +const DrawerClose = DrawerPrimitive.Close + +type DrawerOverlayProps = OverlayProps & { class?: string } + +const DrawerOverlay = (props: DynamicProps>) => { + const [, rest] = splitProps(props as DrawerOverlayProps, ["class"]) + const drawerContext = DrawerPrimitive.useContext() + const overlayStyle = () => { + const state = drawerContext.transitionState() + if (state === "opening" || state === "closing") return undefined + const open = drawerContext.openPercentage() + return { + opacity: open, + "backdrop-filter": `blur(${4 * open}px)`, + } + } + return ( + + ) +} + +type DrawerContentProps = ContentProps & { + class?: string + children?: JSX.Element +} + +const DrawerContent = (props: DynamicProps>) => { + const [, rest] = splitProps(props as DrawerContentProps, ["class", "children"]) + return ( + + + + {props.children} + + + ) +} + +const DrawerHeader: Component> = (props) => { + const [, rest] = splitProps(props, ["class"]) + return
+} + +const DrawerFooter: Component> = (props) => { + const [, rest] = splitProps(props, ["class"]) + return
+} + +type DrawerTitleProps = LabelProps & { class?: string } + +const DrawerTitle = (props: DynamicProps>) => { + const [, rest] = splitProps(props as DrawerTitleProps, ["class"]) + return ( + + ) +} + +type DrawerDescriptionProps = DescriptionProps & { + class?: string +} + +const DrawerDescription = (props: DynamicProps>) => { + const [, rest] = splitProps(props as DrawerDescriptionProps, ["class"]) + return ( + + ) +} + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index f7668c1949..6032b81dde 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -203,12 +203,15 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ return promise } - const search = (query: string, dirs: "true" | "false") => + const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) => sdk() - .client.find.files({ query, dirs }) + .client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal }) .then( (x) => (x.data ?? []).map(path.normalize), - () => [], + (error) => { + if (options?.signal?.aborted) throw error + return [] + }, ) const stop = sdk().event.listen((e) => { @@ -284,7 +287,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ setScrollLeft, selectedLines, setSelectedLines, - searchFiles: (query: string) => search(query, "false"), + searchFiles: (query: string, options?: { limit?: number; signal?: AbortSignal }) => + search(query, "false", options), searchFilesAndDirectories: (query: string) => search(query, "true"), } }, diff --git a/packages/app/src/context/layout-tabs.test.ts b/packages/app/src/context/layout-tabs.test.ts new file mode 100644 index 0000000000..646ed086ce --- /dev/null +++ b/packages/app/src/context/layout-tabs.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test" +import { + SESSION_OPEN_FILE_TAB, + closeSessionTab, + openSessionTab, + previewSessionTab, + type SessionTabState, +} from "./layout-tabs" + +const state = (all: string[], active?: string, preview?: string): SessionTabState => ({ + tabs: { all, active }, + preview, +}) + +describe("previewSessionTab", () => { + test("appends the Open File placeholder", () => { + expect(previewSessionTab(state(["file://a.ts"], "file://a.ts"), SESSION_OPEN_FILE_TAB)).toEqual( + state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB), + ) + }) + + test("replaces the current preview in place", () => { + expect( + previewSessionTab( + state(["context", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB), + "file://a.ts", + ), + ).toEqual(state(["context", "file://a.ts", "file://b.ts"], "file://a.ts", "file://a.ts")) + }) + + test("activates a durable tab without duplicating it", () => { + expect( + previewSessionTab( + state(["file://a.ts", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB), + "file://b.ts", + ), + ).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts")) + }) + + test("replaces a restored Open File placeholder", () => { + expect( + previewSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts"), + ).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts", "file://b.ts")) + }) +}) + +describe("openSessionTab", () => { + test("pins the current preview", () => { + expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://a.ts")).toEqual( + state(["file://a.ts"], "file://a.ts"), + ) + }) + + test("replaces a preview with a directly opened file", () => { + expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://b.ts")).toEqual( + state(["file://b.ts"], "file://b.ts"), + ) + }) + + test("keeps the preview when switching to Review", () => { + expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "review")).toEqual( + state(["file://a.ts"], "review", "file://a.ts"), + ) + }) + + test("replaces a restored Open File placeholder with a direct open", () => { + expect(openSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts")).toEqual( + state(["file://a.ts", "file://b.ts"], "file://b.ts"), + ) + }) +}) + +describe("closeSessionTab", () => { + test("clears preview metadata and selects the left neighbor", () => { + expect( + closeSessionTab( + state(["file://a.ts", "file://b.ts", "file://c.ts"], "file://b.ts", "file://b.ts"), + "file://b.ts", + ), + ).toEqual(state(["file://a.ts", "file://c.ts"], "file://a.ts")) + }) +}) diff --git a/packages/app/src/context/layout-tabs.ts b/packages/app/src/context/layout-tabs.ts new file mode 100644 index 0000000000..ea4c675ff4 --- /dev/null +++ b/packages/app/src/context/layout-tabs.ts @@ -0,0 +1,103 @@ +export const SESSION_OPEN_FILE_TAB = "open-file" + +export type SessionTabs = { + active?: string + all: string[] +} + +export type SessionTabState = { + tabs: SessionTabs + preview?: string +} + +const sessionTabPreview = (current: SessionTabState) => + current.preview ?? (current.tabs.all.includes(SESSION_OPEN_FILE_TAB) ? SESSION_OPEN_FILE_TAB : undefined) + +export function previewSessionTab(current: SessionTabState, tab: string): SessionTabState { + const preview = sessionTabPreview(current) + const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1 + const existingIndex = current.tabs.all.indexOf(tab) + + if (existingIndex !== -1) { + if (previewIndex === -1 || preview === tab) { + return { tabs: { all: current.tabs.all, active: tab }, preview: preview === tab ? tab : undefined } + } + return { + tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab }, + } + } + + if (previewIndex === -1) { + return { tabs: { all: [...current.tabs.all, tab], active: tab }, preview: tab } + } + + return { + tabs: { + all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)), + active: tab, + }, + preview: tab, + } +} + +export function openSessionTab(current: SessionTabState, tab: string): SessionTabState { + const preview = sessionTabPreview(current) + if (tab === "review") { + return { + tabs: { all: current.tabs.all.filter((item) => item !== tab), active: tab }, + preview, + } + } + + if (tab === "context") { + return { + tabs: { all: [tab, ...current.tabs.all.filter((item) => item !== tab)], active: tab }, + preview, + } + } + + const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1 + const existingIndex = current.tabs.all.indexOf(tab) + if (existingIndex !== -1) { + if (previewIndex === -1 || preview === tab) { + return { tabs: { all: current.tabs.all, active: tab } } + } + return { + tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab }, + } + } + + if (previewIndex === -1) { + return { tabs: { all: [...current.tabs.all, tab], active: tab } } + } + + return { + tabs: { + all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)), + active: tab, + }, + } +} + +export function closeSessionTab(current: SessionTabState, tab: string): SessionTabState { + if (tab === "review") { + if (current.tabs.active !== tab) return current + return { + tabs: { all: current.tabs.all, active: current.tabs.all[0] }, + preview: current.preview, + } + } + + const all = current.tabs.all.filter((item) => item !== tab) + const preview = current.preview === tab ? undefined : current.preview + if (current.tabs.active !== tab) return { tabs: { ...current.tabs, all }, preview } + + const index = current.tabs.all.indexOf(tab) + return { + tabs: { + all, + active: current.tabs.all[index - 1] ?? current.tabs.all[index + 1] ?? all[0], + }, + preview, + } +} diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index bf4fc7daa9..8a26d91c46 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -19,6 +19,7 @@ import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/u import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers" import { requireServerKey } from "@/utils/session-route" import { type DraftTab, useTabs } from "./tabs" +import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs" export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } @@ -55,14 +56,11 @@ export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant { return "gray" } -type SessionTabs = { - active?: string - all: string[] -} - type SessionView = { scroll: Record reviewOpen?: string[] + reviewMode?: ReviewChangeMode + reviewFile?: string pendingMessage?: string pendingMessageAt?: number } @@ -78,6 +76,7 @@ export type LocalProject = Partial & { worktree: string; expanded: bool export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string } export type ReviewDiffStyle = "unified" | "split" +export type ReviewChangeMode = "git" | "branch" | "turn" export type ReviewPanelSource = "context-button" | "other" export type LayoutRoute = @@ -86,14 +85,6 @@ export type LayoutRoute = | { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key } | { type: "session"; sessionId: string; server?: ServerConnection.Key } -function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs { - const all = current?.all ?? [] - if (tab === "review") return { all: all.filter((x) => x !== "review"), active: tab } - if (tab === "context") return { all: [tab, ...all.filter((x) => x !== tab)], active: tab } - if (!all.includes(tab)) return { all: [...all, tab], active: tab } - return { all, active: tab } -} - const sessionPath = (key: string) => { const dir = SessionStateKey.route(key).split("/")[0] if (!dir) return @@ -307,6 +298,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( ) const [ephemeral, setEphemeral] = createStore({ reviewPanelSource: "other" as ReviewPanelSource, + sessionTabPreview: {} as Record, }) const MAX_SESSION_KEYS = 50 @@ -365,6 +357,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( scroll.drop(drop) dropSessionState(drop) + setEphemeral( + "sessionTabPreview", + produce((draft) => { + for (const key of drop) delete draft[key] + }), + ) for (const key of drop) { usage.used.delete(key) @@ -790,6 +788,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( view(sessionKey: string | Accessor) { const key = createSessionKeyReader(sessionKey, ensureKey) const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} }) + const reviewMode = createMemo(() => { + const mode = s().reviewMode + if (mode === "git" || mode === "branch" || mode === "turn") return mode + }) + const reviewFile = createMemo(() => { + const file = s().reviewFile + if (typeof file === "string") return file + }) const terminalOpened = createMemo(() => store.terminal?.opened ?? false) const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED) const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other")) @@ -861,6 +867,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, }, review: { + mode: reviewMode, + setMode(mode: ReviewChangeMode) { + const session = key() + const current = store.sessionView[session] + if (!current) { + setStore("sessionView", session, { scroll: {}, reviewMode: mode }) + prune(session) + return + } + if (current.reviewMode === mode) return + setStore("sessionView", session, "reviewMode", mode) + prune(session) + }, + file: reviewFile, + setFile(file: string) { + const session = key() + const current = store.sessionView[session] + if (!current) { + setStore("sessionView", session, { scroll: {}, reviewFile: file }) + prune(session) + return + } + if (current.reviewFile === file) return + setStore("sessionView", session, "reviewFile", file) + prune(session) + }, open: createMemo(() => s().reviewOpen ?? []), setOpen(open: string[]) { const session = key() @@ -932,10 +964,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] }) const normalize = (tab: string) => normalizeSessionTab(path(), tab) const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all) + const apply = (session: string, next: ReturnType) => { + batch(() => { + setStore("sessionTabs", session, next.tabs) + setEphemeral("sessionTabPreview", session, next.preview) + }) + } return { tabs, active: createMemo(() => tabs().active), all: createMemo(() => tabs().all.filter((tab) => tab !== "review")), + preview: createMemo(() => ephemeral.sessionTabPreview[key()]), setActive(tab: string | undefined) { const session = key() const next = tab ? normalize(tab) : tab @@ -948,40 +987,44 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setAll(all: string[]) { const session = key() const next = normalizeAll(all).filter((tab) => tab !== "review") - if (!store.sessionTabs[session]) { - setStore("sessionTabs", session, { all: next, active: undefined }) - } else { - setStore("sessionTabs", session, "all", next) - } + batch(() => { + if (!store.sessionTabs[session]) { + setStore("sessionTabs", session, { all: next, active: undefined }) + } else { + setStore("sessionTabs", session, "all", next) + } + const preview = ephemeral.sessionTabPreview[session] + if (preview && !next.includes(preview)) setEphemeral("sessionTabPreview", session, undefined) + }) }, async open(tab: string) { const session = key() - const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab)) - setStore("sessionTabs", session, next) + apply( + session, + openSessionTab( + { tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] }, + normalize(tab), + ), + ) + }, + previewTab(tab: string) { + const session = key() + apply( + session, + previewSessionTab( + { tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] }, + normalize(tab), + ), + ) }, close(tab: string) { const session = key() const current = store.sessionTabs[session] if (!current) return - - if (tab === "review") { - if (current.active !== tab) return - setStore("sessionTabs", session, "active", current.all[0]) - return - } - - const all = current.all.filter((x) => x !== tab) - if (current.active !== tab) { - setStore("sessionTabs", session, "all", all) - return - } - - const index = current.all.findIndex((f) => f === tab) - const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0] - batch(() => { - setStore("sessionTabs", session, "all", all) - setStore("sessionTabs", session, "active", next) - }) + apply( + session, + closeSessionTab({ tabs: current, preview: ephemeral.sessionTabPreview[session] }, normalize(tab)), + ) }, move(tab: string, to: number) { const session = key() diff --git a/packages/app/src/env.d.ts b/packages/app/src/env.d.ts index 39f827eb64..5754df7683 100644 --- a/packages/app/src/env.d.ts +++ b/packages/app/src/env.d.ts @@ -12,6 +12,16 @@ interface ImportMeta { readonly env: ImportMetaEnv } +declare module "*.png" { + const src: string + export default src +} + +declare module "*.mp4" { + const src: string + export default src +} + export declare module "solid-js" { namespace JSX { interface Directives { diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index b824354401..a82183adaa 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -267,6 +267,11 @@ export const dict = { "prompt.context.removeActiveFile": "إزالة الملف النشط من السياق", "prompt.context.removeFile": "إزالة الملف من السياق", "prompt.action.attachFile": "إرفاق ملف", + "prompt.menu.addImagesAndFiles": "إضافة ملفات والمزيد", + "prompt.menu.imagesAndFiles": "الصور والملفات", + "prompt.menu.commands": "الأوامر", + "prompt.menu.context": "السياق", + "prompt.menu.shellCommand": "أمر shell", "prompt.attachment.remove": "إزالة المرفق", "prompt.action.send": "إرسال", "prompt.action.stop": "توقف", @@ -548,6 +553,7 @@ export const dict = { "home.sessions.group.today": "اليوم", "home.sessions.group.yesterday": "أمس", "home.sessions.group.older": "الأقدم", + "home.providerTip": "اتصل بأكثر من 75 مزودًا لاستخدام نماذج أخرى، بما فيها Claude وGPT وGemini وغيرها", "session.tab.session": "جلسة", "session.tab.review": "مراجعة", "session.tab.context": "سياق", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index f431dbc8fc..4fffc84a28 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -267,6 +267,11 @@ export const dict = { "prompt.context.removeActiveFile": "Remover arquivo ativo do contexto", "prompt.context.removeFile": "Remover arquivo do contexto", "prompt.action.attachFile": "Anexar arquivo", + "prompt.menu.addImagesAndFiles": "Adicionar arquivos e mais", + "prompt.menu.imagesAndFiles": "Imagens e arquivos", + "prompt.menu.commands": "Comandos", + "prompt.menu.context": "Contexto", + "prompt.menu.shellCommand": "Comando shell", "prompt.attachment.remove": "Remover anexo", "prompt.action.send": "Enviar", "prompt.action.stop": "Parar", @@ -552,6 +557,8 @@ export const dict = { "home.sessions.group.today": "Hoje", "home.sessions.group.yesterday": "Ontem", "home.sessions.group.older": "Mais antigas", + "home.providerTip": + "Conecte-se a mais de 75 provedores para usar outros modelos, incluindo Claude, GPT, Gemini e muito mais", "session.tab.session": "Sessão", "session.tab.review": "Revisão", "session.tab.context": "Contexto", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index e5d48e5d19..b8ddad0667 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -287,6 +287,11 @@ export const dict = { "prompt.context.removeActiveFile": "Ukloni aktivnu datoteku iz konteksta", "prompt.context.removeFile": "Ukloni datoteku iz konteksta", "prompt.action.attachFile": "Priloži datoteku", + "prompt.menu.addImagesAndFiles": "Dodaj datoteke i više", + "prompt.menu.imagesAndFiles": "Slike i datoteke", + "prompt.menu.commands": "Komande", + "prompt.menu.context": "Kontekst", + "prompt.menu.shellCommand": "Shell naredba", "prompt.attachment.remove": "Ukloni prilog", "prompt.action.send": "Pošalji", "prompt.action.stop": "Zaustavi", @@ -603,6 +608,8 @@ export const dict = { "home.sessions.group.today": "Danas", "home.sessions.group.yesterday": "Jučer", "home.sessions.group.older": "Starije", + "home.providerTip": + "Povežite se s više od 75 pružalaca usluga kako biste koristili druge modele, uključujući Claude, GPT, Gemini i druge", "session.tab.session": "Sesija", "session.tab.review": "Pregled", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 8435d9e378..8e63f0884b 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -285,6 +285,11 @@ export const dict = { "prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst", "prompt.context.removeFile": "Fjern fil fra kontekst", "prompt.action.attachFile": "Vedhæft fil", + "prompt.menu.addImagesAndFiles": "Tilføj filer og mere", + "prompt.menu.imagesAndFiles": "Billeder og filer", + "prompt.menu.commands": "Kommandoer", + "prompt.menu.context": "Kontekst", + "prompt.menu.shellCommand": "Shell-kommando", "prompt.attachment.remove": "Fjern vedhæftning", "prompt.action.send": "Send", "prompt.action.stop": "Stop", @@ -600,6 +605,8 @@ export const dict = { "home.sessions.group.today": "I dag", "home.sessions.group.yesterday": "I går", "home.sessions.group.older": "Ældre", + "home.providerTip": + "Opret forbindelse til mere end 75 udbydere for at bruge andre modeller, herunder Claude, GPT, Gemini og flere", "session.tab.session": "Session", "session.tab.review": "Gennemgang", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 2895f57837..03a08c96f3 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -272,6 +272,11 @@ export const dict = { "prompt.context.removeActiveFile": "Aktive Datei aus dem Kontext entfernen", "prompt.context.removeFile": "Datei aus dem Kontext entfernen", "prompt.action.attachFile": "Datei anhängen", + "prompt.menu.addImagesAndFiles": "Dateien und mehr hinzufügen", + "prompt.menu.imagesAndFiles": "Bilder und Dateien", + "prompt.menu.commands": "Befehle", + "prompt.menu.context": "Kontext", + "prompt.menu.shellCommand": "Shell-Befehl", "prompt.attachment.remove": "Anhang entfernen", "prompt.action.send": "Senden", "prompt.action.stop": "Stopp", @@ -560,6 +565,8 @@ export const dict = { "home.sessions.group.today": "Heute", "home.sessions.group.yesterday": "Gestern", "home.sessions.group.older": "Älter", + "home.providerTip": + "Verbinde dich mit über 75 Anbietern, um weitere Modelle wie Claude, GPT, Gemini und andere zu nutzen", "session.tab.session": "Sitzung", "session.tab.review": "Überprüfung", "session.tab.context": "Kontext", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 559c6ba17b..37e372ee31 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -287,6 +287,11 @@ export const dict = { "prompt.context.removeActiveFile": "Remove active file from context", "prompt.context.removeFile": "Remove file from context", "prompt.action.attachFile": "Add files", + "prompt.menu.addImagesAndFiles": "Add files and more", + "prompt.menu.imagesAndFiles": "Images and files", + "prompt.menu.commands": "Commands", + "prompt.menu.context": "Context", + "prompt.menu.shellCommand": "Shell command", "prompt.attachment.remove": "Remove attachment", "prompt.action.send": "Send", "prompt.action.stop": "Stop", @@ -622,6 +627,7 @@ export const dict = { "home.sessions.group.today": "Today", "home.sessions.group.yesterday": "Yesterday", "home.sessions.group.older": "Older", + "home.providerTip": "Connect to 75+ providers to use other models, including Claude, GPT, Gemini, etc", "session.tab.session": "Session", "session.tab.review": "Review", @@ -699,6 +705,9 @@ export const dict = { "session.header.open.finder": "Finder", "session.header.open.fileExplorer": "File Explorer", "session.header.open.fileManager": "File Manager", + "session.header.reveal.finder": "Reveal in Finder", + "session.header.reveal.fileExplorer": "Reveal in File Explorer", + "session.header.reveal.containingFolder": "Open containing folder", "session.header.open.app.vscode": "VS Code", "session.header.open.app.cursor": "Cursor", "session.header.open.app.zed": "Zed", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index ec6205cf99..f406883b26 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -286,6 +286,11 @@ export const dict = { "prompt.context.removeActiveFile": "Eliminar archivo activo del contexto", "prompt.context.removeFile": "Eliminar archivo del contexto", "prompt.action.attachFile": "Adjuntar archivo", + "prompt.menu.addImagesAndFiles": "Añadir archivos y más", + "prompt.menu.imagesAndFiles": "Imágenes y archivos", + "prompt.menu.commands": "Comandos", + "prompt.menu.context": "Contexto", + "prompt.menu.shellCommand": "Comando de shell", "prompt.attachment.remove": "Eliminar adjunto", "prompt.action.send": "Enviar", "prompt.action.stop": "Detener", @@ -604,6 +609,8 @@ export const dict = { "home.sessions.group.today": "Hoy", "home.sessions.group.yesterday": "Ayer", "home.sessions.group.older": "Anteriores", + "home.providerTip": + "Conéctate a más de 75 proveedores para usar otros modelos, como Claude, GPT, Gemini y muchos más", "session.tab.session": "Sesión", "session.tab.review": "Revisión", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index c5da265344..7f3de65ec1 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -267,6 +267,11 @@ export const dict = { "prompt.context.removeActiveFile": "Retirer le fichier actif du contexte", "prompt.context.removeFile": "Retirer le fichier du contexte", "prompt.action.attachFile": "Joindre un fichier", + "prompt.menu.addImagesAndFiles": "Ajouter des fichiers et plus encore", + "prompt.menu.imagesAndFiles": "Images et fichiers", + "prompt.menu.commands": "Commandes", + "prompt.menu.context": "Contexte", + "prompt.menu.shellCommand": "Commande shell", "prompt.attachment.remove": "Supprimer la pièce jointe", "prompt.action.send": "Envoyer", "prompt.action.stop": "Arrêter", @@ -557,6 +562,8 @@ export const dict = { "home.sessions.group.today": "Aujourd'hui", "home.sessions.group.yesterday": "Hier", "home.sessions.group.older": "Plus anciennes", + "home.providerTip": + "Connectez-vous à plus de 75 fournisseurs pour utiliser d’autres modèles, notamment Claude, GPT, Gemini, etc.", "session.tab.session": "Session", "session.tab.review": "Revue", "session.tab.context": "Contexte", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 435def9b19..e2ef64c88d 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -266,6 +266,11 @@ export const dict = { "prompt.context.removeActiveFile": "コンテキストからアクティブなファイルを削除", "prompt.context.removeFile": "コンテキストからファイルを削除", "prompt.action.attachFile": "ファイルを添付", + "prompt.menu.addImagesAndFiles": "ファイルなどを追加", + "prompt.menu.imagesAndFiles": "画像とファイル", + "prompt.menu.commands": "コマンド", + "prompt.menu.context": "コンテキスト", + "prompt.menu.shellCommand": "シェルコマンド", "prompt.attachment.remove": "添付ファイルを削除", "prompt.action.send": "送信", "prompt.action.stop": "停止", @@ -550,6 +555,7 @@ export const dict = { "home.sessions.group.today": "今日", "home.sessions.group.yesterday": "昨日", "home.sessions.group.older": "それ以前", + "home.providerTip": "75以上のプロバイダーに接続して、Claude、GPT、Geminiなどの他のモデルを利用できます", "session.tab.session": "セッション", "session.tab.review": "レビュー", "session.tab.context": "コンテキスト", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index c6e7f142bc..cb940c2273 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -254,6 +254,11 @@ export const dict = { "prompt.context.removeActiveFile": "컨텍스트에서 활성 파일 제거", "prompt.context.removeFile": "컨텍스트에서 파일 제거", "prompt.action.attachFile": "파일 첨부", + "prompt.menu.addImagesAndFiles": "파일 및 기타 항목 추가", + "prompt.menu.imagesAndFiles": "이미지 및 파일", + "prompt.menu.commands": "명령어", + "prompt.menu.context": "컨텍스트", + "prompt.menu.shellCommand": "셸 명령", "prompt.attachment.remove": "첨부 파일 제거", "prompt.action.send": "전송", "prompt.action.stop": "중지", @@ -944,6 +949,7 @@ export const dict = { "home.sessions.group.today": "오늘", "home.sessions.group.yesterday": "어제", "home.sessions.group.older": "이전", + "home.providerTip": "75개 이상의 제공업체에 연결하여 Claude, GPT, Gemini 등의 다른 모델을 사용하세요", "session.tab.unknown": "알 수 없는 세션", "session.error.notFound": "이 세션을 찾을 수 없습니다", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index e066ff379b..18afb807e8 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -277,6 +277,11 @@ export const dict = { "prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst", "prompt.context.removeFile": "Fjern fil fra kontekst", "prompt.action.attachFile": "Legg ved fil", + "prompt.menu.addImagesAndFiles": "Legg til filer og mer", + "prompt.menu.imagesAndFiles": "Bilder og filer", + "prompt.menu.commands": "Kommandoer", + "prompt.menu.context": "Kontekst", + "prompt.menu.shellCommand": "Shell-kommando", "prompt.attachment.remove": "Fjern vedlegg", "prompt.action.send": "Send", "prompt.action.stop": "Stopp", @@ -1037,6 +1042,8 @@ export const dict = { "home.sessions.group.today": "I dag", "home.sessions.group.yesterday": "I går", "home.sessions.group.older": "Eldre", + "home.providerTip": + "Koble til over 75 leverandører for å bruke andre modeller, inkludert Claude, GPT, Gemini og flere", "session.tab.unknown": "Ukjent sesjon", "session.error.notFound": "Denne sesjonen finnes ikke", diff --git a/packages/app/src/i18n/parity.test.ts b/packages/app/src/i18n/parity.test.ts index 725b5f68a0..566be37e64 100644 --- a/packages/app/src/i18n/parity.test.ts +++ b/packages/app/src/i18n/parity.test.ts @@ -42,7 +42,7 @@ const domains = [ }, ] as const -describe("i18n parity", () => { +describe.skipIf(!!process.env.CI)("i18n parity", () => { test("non-English locales have every English key", async () => { for (const domain of domains) { const source = await dictionary(domain.source) diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 9bc10269fa..4b266d7a30 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -268,6 +268,11 @@ export const dict = { "prompt.context.removeActiveFile": "Usuń aktywny plik z kontekstu", "prompt.context.removeFile": "Usuń plik z kontekstu", "prompt.action.attachFile": "Załącz plik", + "prompt.menu.addImagesAndFiles": "Dodaj pliki i inne elementy", + "prompt.menu.imagesAndFiles": "Obrazy i pliki", + "prompt.menu.commands": "Polecenia", + "prompt.menu.context": "Kontekst", + "prompt.menu.shellCommand": "Polecenie powłoki", "prompt.attachment.remove": "Usuń załącznik", "prompt.action.send": "Wyślij", "prompt.action.stop": "Zatrzymaj", @@ -553,6 +558,8 @@ export const dict = { "home.sessions.group.today": "Dzisiaj", "home.sessions.group.yesterday": "Wczoraj", "home.sessions.group.older": "Starsze", + "home.providerTip": + "Połącz się z ponad 75 dostawcami, aby korzystać z innych modeli, w tym Claude, GPT, Gemini i innych", "session.tab.session": "Sesja", "session.tab.review": "Przegląd", "session.tab.context": "Kontekst", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 466f18b1c0..486e37a696 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -286,6 +286,11 @@ export const dict = { "prompt.context.removeActiveFile": "Удалить активный файл из контекста", "prompt.context.removeFile": "Удалить файл из контекста", "prompt.action.attachFile": "Прикрепить файл", + "prompt.menu.addImagesAndFiles": "Добавить файлы и другое", + "prompt.menu.imagesAndFiles": "Изображения и файлы", + "prompt.menu.commands": "Команды", + "prompt.menu.context": "Контекст", + "prompt.menu.shellCommand": "Команда оболочки", "prompt.attachment.remove": "Удалить вложение", "prompt.action.send": "Отправить", "prompt.action.stop": "Остановить", @@ -604,6 +609,8 @@ export const dict = { "home.sessions.group.today": "Сегодня", "home.sessions.group.yesterday": "Вчера", "home.sessions.group.older": "Ранее", + "home.providerTip": + "Подключитесь к более чем 75 провайдерам, чтобы использовать другие модели, включая Claude, GPT, Gemini и другие", "session.tab.session": "Сессия", "session.tab.review": "Обзор", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index cd42cc46d3..1668a1d7c7 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -286,6 +286,11 @@ export const dict = { "prompt.context.removeActiveFile": "เอาไฟล์ที่ใช้งานอยู่ออกจากบริบท", "prompt.context.removeFile": "เอาไฟล์ออกจากบริบท", "prompt.action.attachFile": "แนบไฟล์", + "prompt.menu.addImagesAndFiles": "เพิ่มไฟล์และอื่น ๆ", + "prompt.menu.imagesAndFiles": "รูปภาพและไฟล์", + "prompt.menu.commands": "คำสั่ง", + "prompt.menu.context": "บริบท", + "prompt.menu.shellCommand": "คำสั่งเชลล์", "prompt.attachment.remove": "เอาไฟล์แนบออก", "prompt.action.send": "ส่ง", "prompt.action.stop": "หยุด", @@ -599,6 +604,7 @@ export const dict = { "home.sessions.group.today": "วันนี้", "home.sessions.group.yesterday": "เมื่อวาน", "home.sessions.group.older": "ก่อนหน้านี้", + "home.providerTip": "เชื่อมต่อกับผู้ให้บริการกว่า 75 รายเพื่อใช้โมเดลอื่นๆ รวมถึง Claude, GPT, Gemini และอีกมากมาย", "session.tab.session": "เซสชัน", "session.tab.review": "ตรวจสอบ", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index c3cc62c942..7f8247d307 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -291,6 +291,11 @@ export const dict = { "prompt.context.removeActiveFile": "Aktif dosyayı bağlamdan çıkar", "prompt.context.removeFile": "Dosyayı bağlamdan çıkar", "prompt.action.attachFile": "Dosya ekle", + "prompt.menu.addImagesAndFiles": "Dosya ve daha fazlasını ekle", + "prompt.menu.imagesAndFiles": "Görseller ve dosyalar", + "prompt.menu.commands": "Komutlar", + "prompt.menu.context": "Bağlam", + "prompt.menu.shellCommand": "Kabuk komutu", "prompt.attachment.remove": "Eki kaldır", "prompt.action.send": "Gönder", "prompt.action.stop": "Durdur", @@ -608,6 +613,7 @@ export const dict = { "home.sessions.group.today": "Bugün", "home.sessions.group.yesterday": "Dün", "home.sessions.group.older": "Daha eski", + "home.providerTip": "Claude, GPT, Gemini ve diğer modelleri kullanmak için 75'ten fazla sağlayıcıya bağlanın", "session.tab.session": "Oturum", "session.tab.review": "İnceleme", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index f4bc44eb23..e16a3e5005 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -288,6 +288,11 @@ export const dict = { "prompt.context.removeActiveFile": "Видалити активний файл з контексту", "prompt.context.removeFile": "Видалити файл з контексту", "prompt.action.attachFile": "Додати файли", + "prompt.menu.addImagesAndFiles": "Додати файли та інше", + "prompt.menu.imagesAndFiles": "Зображення та файли", + "prompt.menu.commands": "Команди", + "prompt.menu.context": "Контекст", + "prompt.menu.shellCommand": "Команда оболонки", "prompt.attachment.remove": "Видалити вкладення", "prompt.action.send": "Надіслати", "prompt.action.stop": "Зупинити", @@ -626,6 +631,8 @@ export const dict = { "home.sessions.group.today": "Сьогодні", "home.sessions.group.yesterday": "Учора", "home.sessions.group.older": "Раніше", + "home.providerTip": + "Підключіться до понад 75 провайдерів, щоб використовувати інші моделі, зокрема Claude, GPT, Gemini та інші", "session.tab.session": "Сесія", "session.tab.review": "Огляд", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index bd12a9dba2..86a05d33d3 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -306,6 +306,11 @@ export const dict = { "prompt.context.removeActiveFile": "从上下文移除活动文件", "prompt.context.removeFile": "从上下文移除文件", "prompt.action.attachFile": "附加文件", + "prompt.menu.addImagesAndFiles": "添加文件及更多内容", + "prompt.menu.imagesAndFiles": "图片和文件", + "prompt.menu.commands": "命令", + "prompt.menu.context": "上下文", + "prompt.menu.shellCommand": "shell 命令", "prompt.attachment.remove": "移除附件", "prompt.action.send": "发送", "prompt.action.stop": "停止", @@ -600,6 +605,7 @@ export const dict = { "home.sessions.group.today": "今天", "home.sessions.group.yesterday": "昨天", "home.sessions.group.older": "更早", + "home.providerTip": "连接 75 个以上的提供商,使用 Claude、GPT、Gemini 等其他模型", "session.tab.session": "会话", "session.tab.review": "审查", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index b2a1a6f3ad..ea44c22ac1 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -286,6 +286,11 @@ export const dict = { "prompt.context.removeActiveFile": "從上下文移除目前檔案", "prompt.context.removeFile": "從上下文移除檔案", "prompt.action.attachFile": "附加檔案", + "prompt.menu.addImagesAndFiles": "新增檔案及更多內容", + "prompt.menu.imagesAndFiles": "圖片和檔案", + "prompt.menu.commands": "命令", + "prompt.menu.context": "上下文", + "prompt.menu.shellCommand": "shell 命令", "prompt.attachment.remove": "移除附件", "prompt.action.send": "傳送", "prompt.action.stop": "停止", @@ -595,6 +600,7 @@ export const dict = { "home.sessions.group.today": "今天", "home.sessions.group.yesterday": "昨天", "home.sessions.group.older": "更早", + "home.providerTip": "連接 75 個以上的供應商,使用 Claude、GPT、Gemini 等其他模型", "session.tab.session": "工作階段", "session.tab.review": "審查", diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index c4d84a96f8..c8a89672df 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -68,6 +68,7 @@ import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache" import { archiveHomeSession } from "./home-session-archive" import { shouldOpenSessionInBackground } from "./home-session-open" import { showToast } from "@/utils/toast" +import { fileManagerApp } from "@/utils/file-manager" const HOME_SESSION_LIMIT = 64 const HOME_SESSION_HEADER_STICKY_TOP = 12 @@ -1030,8 +1031,24 @@ function HomeProjectRow(props: { language: ReturnType }) { const global = useGlobal() + const platform = usePlatform() const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false const [state, setState] = createStore({ menuOpen: false }) + const canRevealInFileManager = () => + platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server) + const fileManagerActionLabel = () => + props.language.t( + fileManagerApp(platform.platform === "desktop" ? (platform.os ?? "unknown") : "unknown").actionLabel, + ) + const revealInFileManager = () => { + if (!platform.openPath) return + platform.openPath(props.project.worktree).catch((err: unknown) => + showToast({ + title: props.language.t("common.requestFailed"), + description: errorMessage(err, props.language.t("common.requestFailed")), + }), + ) + } return (
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index fd9d16b90a..8762d2f884 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -54,7 +54,7 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd" import { DebugBar } from "@/components/debug-bar" -import { HelpButton } from "@/components/help-button" +import { HelpButton, TabsInfoPopup } from "@/components/help-button" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" import { useDirectoryPicker } from "@/components/directory-picker" import { ServerConnection, useServer } from "@/context/server" @@ -2395,6 +2395,7 @@ export default function LegacyLayout(props: ParentProps) {
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && }
+
diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index 7831ed8514..7a8e44c5ab 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -1,8 +1,10 @@ -import { Show, createEffect, createMemo, createResource, untrack } from "solid-js" +import { Show, createEffect, createMemo, createResource, createSignal, onCleanup, untrack } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { useSearchParams } from "@solidjs/router" import { Tooltip } from "@opencode-ai/ui/tooltip" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { NewSessionDesignView } from "@/components/session" import { PromptInput } from "@/components/prompt-input" import { StatusPopoverV2 } from "@/components/status-popover" @@ -24,8 +26,14 @@ import { useComposerCommands } from "@/pages/session/use-composer-commands" import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector" import { useTitlebarRightMount } from "@/components/titlebar" +import { useProviders } from "@/hooks/use-providers" +import { useSettingsDialog } from "@/components/settings-dialog" +import { Persist, persisted } from "@/utils/persist" +import createPresence from "solid-presence" const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" +const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000 +const providerTipExitDuration = 250 /** * The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt @@ -40,6 +48,8 @@ export default function NewSessionPage() { const comments = useComments() const language = useLanguage() const settings = useSettings() + const providers = useProviders(() => sdk().directory) + const openProviderSettings = useSettingsDialog("providers") const route = useSessionKey() const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() @@ -174,9 +184,81 @@ export default function NewSessionPage() {
+ serverSync().child(sdk().directory)[0].provider_ready} + connected={() => providers.paid().length > 0} + openProviders={openProviderSettings} + />
) } + +function ProviderTip(props: { ready: () => boolean; connected: () => boolean; openProviders: () => void }) { + const language = useLanguage() + const [persistedState, setPersistedState, , persistedReady] = persisted( + Persist.global("new-session.provider-tip"), + createStore({ dismissedAt: 0 }), + ) + const visible = createMemo( + () => + props.ready() && + persistedReady() && + !props.connected() && + Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration, + ) + + function dismiss() { + setPersistedState("dismissedAt", Date.now()) + } + + const [ref, setRef] = createSignal() + const presence = createPresence({ + show: () => visible(), + element: () => ref() ?? null, + }) + + return ( + +
+
+ + + + +
+
+
+ ) +} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index bbbadb7567..2abe232346 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,4 +1,4 @@ -import type { Project, UserMessage } from "@opencode-ai/sdk/v2" +import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2" import { useDialog } from "@opencode-ai/ui/context/dialog" import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query" import { @@ -11,6 +11,7 @@ import { createMemo, createEffect, createComputed, + createSignal, on, onMount, type ParentProps, @@ -69,12 +70,18 @@ import { createTimelineModel } from "@/pages/session/timeline/model" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { useSessionLayout } from "@/pages/session/session-layout" import { syncSessionModel } from "@/pages/session/session-model-helpers" +import { + clampSessionPanelWidth, + SESSION_PANEL_WIDTH_MIN, + sessionPanelWidthMax, +} from "@/pages/session/session-panel-width" 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 { 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" import { TerminalPanel } from "@/pages/session/terminal-panel" import { TerminalPanelV2 } from "@/pages/session/terminal-panel-v2" import { useComposerCommands } from "@/pages/session/use-composer-commands" @@ -100,7 +107,6 @@ type VcsMode = "git" | "branch" const sessionViewState = () => ({ messageId: undefined as string | undefined, mobileTab: "session" as "session" | "changes", - changes: "git" as ChangeMode, }) function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undefined) { @@ -347,6 +353,8 @@ export default function Page() { const location = useLocation() const navigate = useNavigate() const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout() + const reviewMode = () => view().review.mode() ?? "git" + const reviewFile = () => view().review.file() const sessionOwnership = createSessionOwnership(sessionKey) const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns()) @@ -440,9 +448,39 @@ export default function Page() { newSessionDesign() ? desktopV2ReviewOpen() || desktopTerminalOpen() : desktopReviewOpen(), ) const desktopSidePanelOpen = createMemo(() => desktopSessionResizeOpen() || desktopFileTreeOpen()) + let panelRow: HTMLDivElement | undefined + const [panelRowWidth, setPanelRowWidth] = createSignal() + createResizeObserver( + () => panelRow, + ({ width }) => setPanelRowWidth(width), + ) + const splitReview = createMemo( + () => (newSessionDesign() ? desktopV2ReviewOpen() : desktopReviewOpen()) && layout.review.diffStyle() === "split", + ) + // The observer reports the content-box width, which already excludes the row + // padding; only the flex gap between the panels remains to subtract. + const sessionPanelAvailable = createMemo(() => { + const width = panelRowWidth() + if (width === undefined) return undefined + return width - (settings.general.newLayoutDesigns() ? 8 : 0) + }) + const sessionPanelMax = createMemo(() => { + const available = sessionPanelAvailable() + if (available === undefined) return 1000 + return sessionPanelWidthMax({ available, split: splitReview() }) + }) + // Clamp at render time so window or sidebar resizes squeeze the chat panel + // instead of the review pane, without overwriting the persisted width. + const sessionPanelResizedWidth = createMemo(() => + clampSessionPanelWidth({ + width: layout.session.width(), + available: sessionPanelAvailable(), + split: splitReview(), + }), + ) const sessionPanelWidth = createMemo(() => { if (!desktopSidePanelOpen()) return "100%" - if (desktopSessionResizeOpen()) return `${layout.session.width()}px` + if (desktopSessionResizeOpen()) return `${sessionPanelResizedWidth()}px` return `calc(100% - ${layout.fileTree.width()}px)` }) const centered = createMemo(() => isDesktop() && !desktopReviewOpen()) @@ -604,7 +642,8 @@ export default function Page() { : store.mobileTab === "changes", ) const vcsMode = createMemo(() => { - if (store.changes === "git" || store.changes === "branch") return store.changes + const mode = reviewMode() + if (mode === "git" || mode === "branch") return mode }) const vcsKey = createMemo( () => @@ -631,17 +670,63 @@ export default function Page() { }) const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100) const reviewDiffs = () => { - if (store.changes === "git" || store.changes === "branch") + if (reviewMode() === "git" || reviewMode() === "branch") // avoids suspense return vcsQuery.isFetched ? (vcsQuery.data ?? []) : [] return turnDiffs() } + const activeReviewFile = () => { + const diffs = reviewDiffs() + const selected = reviewFile() + if (selected && diffs.some((diff) => diff.file === selected)) return selected + return diffs[0]?.file + } const reviewCount = () => reviewDiffs().length const hasReview = () => reviewCount() > 0 const reviewReady = () => { - if (store.changes === "git" || store.changes === "branch") return !vcsQuery.isPending + if (reviewMode() === "git" || reviewMode() === "branch") return !vcsQuery.isPending return true } + const loadReviewDiff = async (file: string, version?: number): Promise => { + const mode = vcsMode() + if (!mode) return + const root = reviewRootDirectory(sync().project?.worktree ?? sdk().directory) + const directory = reviewDiffDirectory(root, file) + const source = reviewDiffs().find((diff) => diff.file === file) + const valid = (diff: VcsFileDiff | undefined) => { + if (!diff || !source) return + if (diff.additions !== source.additions || diff.deletions !== source.deletions) return + if (reviewDiffNeedsLoad(diff)) return + return diff + } + const request = (scope: string, context?: number) => + queryClient + .fetchQuery({ + queryKey: [serverSDK().scope, ...vcsKey(), mode, "directory", scope, context, version] as const, + staleTime: Number.POSITIVE_INFINITY, + retry: 2, + queryFn: () => + sdk() + .client.vcs.diff({ mode, directory: scope, context }) + .then((result) => result.data ?? []), + }) + .then((diffs) => diffs.find((diff) => diff.file === file)) + + if (directory !== root) { + try { + const scoped = valid(await request(directory)) + if (scoped) return scoped + } catch (error) { + console.debug("[session-review] failed to load scoped vcs diff", { mode, file, directory, error }) + } + } + try { + const bounded = valid(await request(root, 3)) + if (bounded) return bounded + } catch (error) { + console.debug("[session-review] failed to load bounded vcs diff", { mode, file, root, error }) + } + } const newSessionWorktree = createMemo(() => { if (store.newSessionWorktree === "create") return "create" @@ -924,12 +1009,15 @@ export default function Page() { } createEffect(() => { + if (!layout.ready()) return + if (sync().status !== "complete") return if (!sync().project) return const list = changesOptions() - if (list.includes(store.changes)) return + const mode = reviewMode() + if (list.includes(mode)) return const next = list[0] if (!next) return - setStore("changes", next) + view().review.setMode(next) }) createEffect( @@ -949,7 +1037,6 @@ export default function Page() { const [tree, setTree] = createStore({ reviewScroll: undefined as HTMLDivElement | undefined, pendingDiff: undefined as string | undefined, - activeDiff: undefined as string | undefined, }) createEffect( @@ -959,7 +1046,6 @@ export default function Page() { setTree({ reviewScroll: undefined, pendingDiff: undefined, - activeDiff: undefined, }) }, { defer: true }, @@ -982,6 +1068,7 @@ export default function Page() { setActiveMessage, focusInput, review: reviewTab, + fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id, }) const openReviewFile = createOpenReviewFile({ @@ -1006,9 +1093,9 @@ export default function Page() { return (