From 42cfe1fc53819b977eb24d70b83d78e209150ec4 Mon Sep 17 00:00:00 2001 From: Alejandro Tamayo Castillo Date: Fri, 17 Apr 2026 13:02:29 +0200 Subject: [PATCH 01/33] Bump Claude Agent SDK and add Opus 4.7 models (#1) - @anthropic-ai/claude-agent-sdk 0.2.45 -> 0.2.112 - Bundled Claude Code CLI 2.1.45 -> 2.1.112 (required for Opus 4.7) - Model picker: Opus 4.7, Opus 4.7 1M, Sonnet 4.6, Haiku 4.5 - 1M variant uses the opus[1m] alias; Claude Code parses the suffix and enables the 1M context window on the same underlying model Co-authored-by: Claude Opus 4.7 (1M context) --- bun.lock | 54 ++++++++++++------- package.json | 6 +-- src/renderer/features/agents/atoms/index.ts | 1 + src/renderer/features/agents/lib/models.ts | 3 +- .../agents/ui/agent-context-indicator.tsx | 1 + 5 files changed, 43 insertions(+), 22 deletions(-) diff --git a/bun.lock b/bun.lock index f338c9b34..fee91b2e0 100644 --- a/bun.lock +++ b/bun.lock @@ -1,11 +1,12 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "21st-desktop", "dependencies": { "@ai-sdk/react": "^3.0.14", - "@anthropic-ai/claude-agent-sdk": "0.2.32", + "@anthropic-ai/claude-agent-sdk": "0.2.112", "@git-diff-view/react": "^0.0.35", "@git-diff-view/shiki": "^0.0.36", "@mcpc-tech/acp-ai-provider": "^0.2.4", @@ -42,7 +43,7 @@ "@xterm/addon-serialize": "^0.14.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/addon-webgl": "^0.19.0", - "@zed-industries/codex-acp": "^0.9.3", + "@zed-industries/codex-acp": "0.9.3", "ai": "^6.0.14", "async-mutex": "^0.5.0", "better-sqlite3": "^12.6.2", @@ -90,6 +91,7 @@ "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^4.0.0", "@electron/rebuild": "^4.0.3", + "@tailwindcss/container-queries": "^0.1.1", "@types/better-sqlite3": "^7.6.13", "@types/diff": "^8.0.0", "@types/node": "^20.17.50", @@ -126,7 +128,9 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.32", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-8AtsSx/M9jxd0ihS08eqa7VireTEuwQy0i1+6ZJX93LECT6Svlf47dPJiAm7JB+BhVMmwTfQeS6x1akIcCfvbQ=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.112", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-vMFoiDKlOive8p3tphpV1gQaaytOipwGJ+uw9mvvaLQUODSC2+fCdRDAY25i2Tsv+lOtxzXBKctmaDuWqZY7ig=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="], "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.8.2", "", {}, "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA=="], @@ -166,6 +170,8 @@ "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], @@ -284,35 +290,37 @@ "@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="], + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="], + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="], + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="], + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="], + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="], + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="], + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="], + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="], + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="], + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="], + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="], + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], @@ -648,6 +656,8 @@ "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], + "@tailwindcss/container-queries": ["@tailwindcss/container-queries@0.1.1", "", { "peerDependencies": { "tailwindcss": ">=3.2.0" } }, "sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA=="], + "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], "@tanstack/query-core": ["@tanstack/query-core@5.90.19", "", {}, "sha512-GLW5sjPVIvH491VV1ufddnfldyVB+teCnpPIvweEfkpRx7CfUmUGhoh9cdcUKBh/KwVxk22aNEDxeTsvmyB/WA=="], @@ -1502,6 +1512,8 @@ "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], @@ -2146,6 +2158,8 @@ "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], @@ -2270,6 +2284,8 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@anthropic-ai/claude-agent-sdk/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -2474,6 +2490,8 @@ "zip-stream/archiver-utils": ["archiver-utils@3.0.4", "", { "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw=="], + "@anthropic-ai/claude-agent-sdk/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="], + "@develar/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], diff --git a/package.json b/package.json index da2a5e747..b9896552f 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,8 @@ "dist": "electron-builder", "dist:manifest": "node scripts/generate-update-manifest.mjs", "dist:upload": "node scripts/upload-release.mjs", - "claude:download": "node scripts/download-claude-binary.mjs --version=2.1.45", - "claude:download:all": "node scripts/download-claude-binary.mjs --version=2.1.45 --all", + "claude:download": "node scripts/download-claude-binary.mjs --version=2.1.112", + "claude:download:all": "node scripts/download-claude-binary.mjs --version=2.1.112 --all", "codex:download": "node scripts/download-codex-binary.mjs --version=0.98.0", "codex:download:all": "node scripts/download-codex-binary.mjs --version=0.98.0 --all", "release": "rm -rf release && bun i && bun run claude:download && bun run codex:download && bun run build && bun run package:mac && bun run dist:manifest && ./scripts/upload-release-wrangler.sh", @@ -36,7 +36,7 @@ }, "dependencies": { "@ai-sdk/react": "^3.0.14", - "@anthropic-ai/claude-agent-sdk": "0.2.45", + "@anthropic-ai/claude-agent-sdk": "0.2.112", "@git-diff-view/react": "^0.0.35", "@git-diff-view/shiki": "^0.0.36", "@mcpc-tech/acp-ai-provider": "^0.2.4", diff --git a/src/renderer/features/agents/atoms/index.ts b/src/renderer/features/agents/atoms/index.ts index 666975a20..226726fb2 100644 --- a/src/renderer/features/agents/atoms/index.ts +++ b/src/renderer/features/agents/atoms/index.ts @@ -345,6 +345,7 @@ export const subChatModeAtomFamily = atomFamily((subChatId: string) => // Model ID to full Claude model string mapping export const MODEL_ID_MAP: Record = { opus: "opus", + "opus[1m]": "opus[1m]", sonnet: "sonnet", haiku: "haiku", } diff --git a/src/renderer/features/agents/lib/models.ts b/src/renderer/features/agents/lib/models.ts index 26fd580e4..ac5f1ac4a 100644 --- a/src/renderer/features/agents/lib/models.ts +++ b/src/renderer/features/agents/lib/models.ts @@ -1,5 +1,6 @@ export const CLAUDE_MODELS = [ - { id: "opus", name: "Opus", version: "4.6" }, + { id: "opus", name: "Opus", version: "4.7" }, + { id: "opus[1m]", name: "Opus", version: "4.7 1M" }, { id: "sonnet", name: "Sonnet", version: "4.6" }, { id: "haiku", name: "Haiku", version: "4.5" }, ] diff --git a/src/renderer/features/agents/ui/agent-context-indicator.tsx b/src/renderer/features/agents/ui/agent-context-indicator.tsx index 06b837b00..a3860306b 100644 --- a/src/renderer/features/agents/ui/agent-context-indicator.tsx +++ b/src/renderer/features/agents/ui/agent-context-indicator.tsx @@ -11,6 +11,7 @@ import { cn } from "../../../lib/utils" // Claude model context windows const CONTEXT_WINDOWS = { opus: 200_000, + "opus[1m]": 1_000_000, sonnet: 200_000, haiku: 200_000, } as const From 302876d55b543c5b9c8ab8580a34cb8deb5a2864 Mon Sep 17 00:00:00 2001 From: Alejandro Tamayo Castillo Date: Fri, 17 Apr 2026 15:47:19 +0200 Subject: [PATCH 02/33] Swap Enter/Shift+Enter in agents prompt editor (#3) Enter now inserts a newline and Shift+Enter submits (Opt+Shift+Enter force-submits). Applies to both the new-workspace prompt and the active-chat input, since they share AgentsMentionsEditor. Multi-line prompts are much easier to compose without an accidental send. Co-authored-by: Claude Opus 4.7 (1M context) --- src/renderer/features/agents/main/active-chat.tsx | 2 +- src/renderer/features/agents/main/chat-input-area.tsx | 4 ++-- src/renderer/features/agents/main/new-chat-form.tsx | 2 +- .../features/agents/mentions/agents-mentions-editor.tsx | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index cf85ed178..f518945bb 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -4178,7 +4178,7 @@ const ChatViewInner = memo(function ChatViewInner({ removeFromQueue(subChatId, itemId) }, [subChatId, removeFromQueue]) - // Force send - stop stream and send immediately, bypassing queue (Opt+Enter) + // Force send - stop stream and send immediately, bypassing queue (Opt+Shift+Enter) const handleForceSend = useCallback(async () => { // Block sending while sandbox is still being set up if (sandboxSetupStatus !== "ready") { diff --git a/src/renderer/features/agents/main/chat-input-area.tsx b/src/renderer/features/agents/main/chat-input-area.tsx index 30ad3f25b..7b76b3966 100644 --- a/src/renderer/features/agents/main/chat-input-area.tsx +++ b/src/renderer/features/agents/main/chat-input-area.tsx @@ -151,7 +151,7 @@ export interface ChatInputAreaProps { fileInputRef: React.RefObject // Core callbacks onSend: () => void - onForceSend: () => void // Opt+Enter: stop stream and send immediately, bypassing queue + onForceSend: () => void // Opt+Shift+Enter: stop stream and send immediately, bypassing queue onStop: () => Promise onCompact: () => void onCreateNewSubChat?: () => void @@ -1075,7 +1075,7 @@ export const ChatInputArea = memo(function ChatInputArea({ } // For all other commands (builtin prompts and custom): - // insert the command and let user add arguments or press Enter to send + // insert the command and let user add arguments or press Shift+Enter to send editorRef.current?.setValue(`/${command.name} `) }, [subChatMode, updateMode, onCreateNewSubChat, onCompact, editorRef], diff --git a/src/renderer/features/agents/main/new-chat-form.tsx b/src/renderer/features/agents/main/new-chat-form.tsx index 6f0f61381..d559087d3 100644 --- a/src/renderer/features/agents/main/new-chat-form.tsx +++ b/src/renderer/features/agents/main/new-chat-form.tsx @@ -1386,7 +1386,7 @@ export function NewChatForm({ } // For all other commands (builtin prompts and custom): - // insert the command and let user add arguments or press Enter to send + // insert the command and let user add arguments or press Shift+Enter to send editorRef.current?.setValue(`/${command.name} `) }, [agentMode], diff --git a/src/renderer/features/agents/mentions/agents-mentions-editor.tsx b/src/renderer/features/agents/mentions/agents-mentions-editor.tsx index 548334e42..8fa06f2a6 100644 --- a/src/renderer/features/agents/mentions/agents-mentions-editor.tsx +++ b/src/renderer/features/agents/mentions/agents-mentions-editor.tsx @@ -75,7 +75,7 @@ type AgentsMentionsEditorProps = { placeholder?: string className?: string onSubmit?: () => void - onForceSubmit?: () => void // Opt+Enter: bypass queue, stop stream and send immediately + onForceSubmit?: () => void // Opt+Shift+Enter: bypass queue, stop stream and send immediately disabled?: boolean onPaste?: (e: React.ClipboardEvent) => void onShiftTab?: () => void // callback for Shift+Tab (e.g., mode switching) @@ -1042,13 +1042,13 @@ export const AgentsMentionsEditor = memo( } // Prevent submission during IME composition (e.g., Chinese/Japanese/Korean input) - if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { + if (e.key === "Enter" && e.shiftKey && !e.nativeEvent.isComposing) { if (triggerActive.current || slashTriggerActive.current) { // Let dropdown handle Enter return } e.preventDefault() - // Opt+Enter = force submit (bypass queue, stop stream and send immediately) + // Opt+Shift+Enter = force submit (bypass queue, stop stream and send immediately) if (e.altKey && onForceSubmit) { onForceSubmit() } else { From 16bc196d6cf680b87cec6caceea6ade09bb72239 Mon Sep 17 00:00:00 2001 From: Alejandro Tamayo Castillo Date: Fri, 17 Apr 2026 17:14:05 +0200 Subject: [PATCH 03/33] Bugfix/white page startup (#4) * FIX: White screen issue when loading * FIX: Updated nullable ollama --- src/renderer/features/agents/main/chat-input-area.tsx | 8 ++++---- src/renderer/features/agents/main/new-chat-form.tsx | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/renderer/features/agents/main/chat-input-area.tsx b/src/renderer/features/agents/main/chat-input-area.tsx index 7b76b3966..b4e2a87f6 100644 --- a/src/renderer/features/agents/main/chat-input-area.tsx +++ b/src/renderer/features/agents/main/chat-input-area.tsx @@ -116,10 +116,10 @@ function useAvailableModels() { const baseModels = CLAUDE_MODELS - const isOffline = ollamaStatus ? !ollamaStatus.internet.online : false - const hasOllama = ollamaStatus?.ollama.available && (ollamaStatus.ollama.models?.length ?? 0) > 0 - const ollamaModels = ollamaStatus?.ollama.models || [] - const recommendedModel = ollamaStatus?.ollama.recommendedModel + const isOffline = ollamaStatus ? !(ollamaStatus.internet?.online ?? true) : false + const hasOllama = ollamaStatus?.ollama?.available && (ollamaStatus.ollama?.models?.length ?? 0) > 0 + const ollamaModels = ollamaStatus?.ollama?.models || [] + const recommendedModel = ollamaStatus?.ollama?.recommendedModel // Only show offline models if: // 1. Debug flag is enabled (showOfflineFeatures) diff --git a/src/renderer/features/agents/main/new-chat-form.tsx b/src/renderer/features/agents/main/new-chat-form.tsx index d559087d3..cc4dbcbcd 100644 --- a/src/renderer/features/agents/main/new-chat-form.tsx +++ b/src/renderer/features/agents/main/new-chat-form.tsx @@ -136,10 +136,10 @@ function useAvailableModels() { const baseModels = CLAUDE_MODELS - const isOffline = ollamaStatus ? !ollamaStatus.internet.online : false - const hasOllama = ollamaStatus?.ollama.available && (ollamaStatus.ollama.models?.length ?? 0) > 0 - const ollamaModels = ollamaStatus?.ollama.models || [] - const recommendedModel = ollamaStatus?.ollama.recommendedModel + const isOffline = ollamaStatus ? !(ollamaStatus.internet?.online ?? true) : false + const hasOllama = ollamaStatus?.ollama?.available && (ollamaStatus.ollama?.models?.length ?? 0) > 0 + const ollamaModels = ollamaStatus?.ollama?.models || [] + const recommendedModel = ollamaStatus?.ollama?.recommendedModel // Only show offline models if: // 1. Debug flag is enabled (showOfflineFeatures) From 71698f9cccd66fc38310a9c9e8fddaed47e0abcd Mon Sep 17 00:00:00 2001 From: Alejandro Tamayo Castillo Date: Fri, 17 Apr 2026 17:15:16 +0200 Subject: [PATCH 04/33] Feature/aside version (#5) * FIX: White screen issue when loading * FIX: Updated nullable ollama * FEATURE: Aside version --- .claude/worktrees/fervent-volhard-90de58 | 1 + .claude/worktrees/vibrant-gates-8bad30 | 1 + package.json | 2 +- src/main/index.ts | 3 +-- src/main/windows/main.ts | 5 +---- 5 files changed, 5 insertions(+), 7 deletions(-) create mode 160000 .claude/worktrees/fervent-volhard-90de58 create mode 160000 .claude/worktrees/vibrant-gates-8bad30 diff --git a/.claude/worktrees/fervent-volhard-90de58 b/.claude/worktrees/fervent-volhard-90de58 new file mode 160000 index 000000000..9f1bc76fa --- /dev/null +++ b/.claude/worktrees/fervent-volhard-90de58 @@ -0,0 +1 @@ +Subproject commit 9f1bc76fa4372c18c565b5a4f8daf38ae3595f0e diff --git a/.claude/worktrees/vibrant-gates-8bad30 b/.claude/worktrees/vibrant-gates-8bad30 new file mode 160000 index 000000000..36f047064 --- /dev/null +++ b/.claude/worktrees/vibrant-gates-8bad30 @@ -0,0 +1 @@ +Subproject commit 36f047064d798507b9177c0651cfbdb3e307e5a8 diff --git a/package.json b/package.json index b9896552f..49484f82f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "21st-desktop", - "version": "0.0.72", + "version": "1.0.0-aletc1", "private": true, "description": "1Code - UI for parallel work with AI agents", "homepage": "https://21st.dev", diff --git a/src/main/index.ts b/src/main/index.ts index 57af873f0..d4e008f4c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -621,8 +621,7 @@ if (gotTheLock) { // Function to build and set application menu const buildMenu = () => { - // Show devtools menu item only in dev mode or when unlocked - const showDevTools = !app.isPackaged || devToolsUnlocked + const showDevTools = true const template: Electron.MenuItemConstructorOptions[] = [ { label: app.name, diff --git a/src/main/windows/main.ts b/src/main/windows/main.ts index 15dcdd137..4b7a8e2ad 100644 --- a/src/main/windows/main.ts +++ b/src/main/windows/main.ts @@ -258,12 +258,9 @@ function registerIpcHandlers(): void { } }) - // DevTools - only allowed in dev mode or when unlocked ipcMain.handle("window:toggle-devtools", (event) => { const win = getWindowFromEvent(event) - // Check if devtools are unlocked (or in dev mode) - const isUnlocked = !app.isPackaged || (global as any).__devToolsUnlocked - if (win && isUnlocked) { + if (win) { win.webContents.toggleDevTools() } }) From e66c65740387f20249d7ee4ac9e1c329852764c1 Mon Sep 17 00:00:00 2001 From: Alejandro Tamayo Castillo Date: Fri, 17 Apr 2026 17:22:15 +0200 Subject: [PATCH 05/33] fix(windows): normalize git paths to POSIX so sidebar tree view works (#7) The sidebar files section rendered as a flat list on Windows because paths occasionally contained backslashes, so the tree builder's `path.split("/")` produced a single segment. Normalize all paths emitted by the git status/diff parsers to forward slashes at the single backend boundary. Git stores paths with "/" on every platform, and Node fs / path.join accept "/" on Windows, so downstream functionality (file reads, diffs, numstat lookups) is unaffected. The renderer's many `split("/")` call sites keep working unchanged on both platforms. Co-authored-by: Claude Opus 4.7 (1M context) --- src/main/lib/git/utils/parse-status.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/main/lib/git/utils/parse-status.ts b/src/main/lib/git/utils/parse-status.ts index d99870751..401980bee 100644 --- a/src/main/lib/git/utils/parse-status.ts +++ b/src/main/lib/git/utils/parse-status.ts @@ -15,13 +15,20 @@ function mapGitStatus(gitIndex: string, gitWorking: string): FileStatus { return "modified"; } +// Normalize to POSIX separators so the renderer can safely split on "/" +// regardless of platform. Git itself stores paths with "/" on every OS, and +// Node fs / path.join accept "/" on Windows, so functionality is preserved. +function toPosix(p: string): string { + return p.replace(/\\/g, "/"); +} + function toChangedFile( path: string, gitIndex: string, gitWorking: string, ): ChangedFile { return { - path, + path: toPosix(path), status: mapGitStatus(gitIndex, gitWorking), additions: 0, deletions: 0, @@ -47,8 +54,9 @@ export function parseGitStatus( if (index && index !== " " && index !== "?") { staged.push({ - path, - oldPath: file.path !== file.from ? file.from : undefined, + path: toPosix(path), + oldPath: + file.path !== file.from && file.from ? toPosix(file.from) : undefined, status: mapGitStatus(index, " "), additions: 0, deletions: 0, @@ -57,7 +65,7 @@ export function parseGitStatus( if (working && working !== " " && working !== "?") { unstaged.push({ - path, + path: toPosix(path), status: mapGitStatus(" ", working), additions: 0, deletions: 0, @@ -139,12 +147,12 @@ export function parseDiffNumstat( const renameMatch = rawPath.match(/^(.+) => (.+)$/); if (renameMatch) { - const oldPath = renameMatch[1]; - const newPath = renameMatch[2]; + const oldPath = toPosix(renameMatch[1]); + const newPath = toPosix(renameMatch[2]); stats.set(newPath, statEntry); stats.set(oldPath, statEntry); } else { - stats.set(rawPath, statEntry); + stats.set(toPosix(rawPath), statEntry); } } @@ -188,8 +196,8 @@ export function parseNameStatus(nameStatusOutput: string): ChangedFile[] { } files.push({ - path, - oldPath, + path: toPosix(path), + oldPath: oldPath ? toPosix(oldPath) : undefined, status, additions: 0, deletions: 0, From d9d6df762c81a10b5a42a8e16fb81a269e725d40 Mon Sep 17 00:00:00 2001 From: Alejandro Tamayo Castillo Date: Fri, 17 Apr 2026 17:47:04 +0200 Subject: [PATCH 06/33] ui(agents): widen chat column from max-w-2xl to max-w-4xl (#8) Chat content was capped at 672px, leaving excessive whitespace on wide windows and cramping bash/tool output. Bumps the message column, input area, status cards, and loading wrapper to 896px (+33%) so they stay aligned and tool renderers (which inherit width) breathe. Co-authored-by: Claude Opus 4.7 (1M context) --- src/renderer/features/agents/main/active-chat.tsx | 10 +++++----- src/renderer/features/agents/main/chat-input-area.tsx | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index f518945bb..297f116db 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -4647,7 +4647,7 @@ const ChatViewInner = memo(function ChatViewInner({ /> {/* Workspace subtitle: repo • branch */} {(workspaceRepoName || workspaceBranch) && ( -
+
{[workspaceRepoName, workspaceBranch].filter(Boolean).join(" • ")} @@ -4692,7 +4692,7 @@ const ChatViewInner = memo(function ChatViewInner({ >
-
+
-
+
{/* Queue indicator card - top card */} {queue.length > 0 && ( -
+
-
+
Date: Fri, 17 Apr 2026 18:47:19 +0200 Subject: [PATCH 07/33] feat(agents): add Claude thinking effort selector Replaces the binary on/off toggle with a per-subChat Off/Low/Medium/High/XHigh/Max selector (filtered per model) using the SDK's new `effort` option; drops the deprecated `maxThinkingTokens`. A one-time migration seeds the new atom from the old boolean so existing preferences carry over. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/lib/trpc/routers/claude.ts | 8 +- .../settings-tabs/agents-preferences-tab.tsx | 42 ++++++--- src/renderer/features/agents/atoms/index.ts | 60 ++++++++++++ .../components/agent-model-selector.tsx | 93 ++++++++++--------- .../features/agents/lib/ipc-chat-transport.ts | 16 ++-- src/renderer/features/agents/lib/models.ts | 36 ++++++- .../features/agents/main/chat-input-area.tsx | 53 ++++++++++- .../features/agents/main/new-chat-form.tsx | 39 +++++++- src/renderer/lib/atoms/index.ts | 10 -- 9 files changed, 266 insertions(+), 91 deletions(-) diff --git a/src/main/lib/trpc/routers/claude.ts b/src/main/lib/trpc/routers/claude.ts index 9e5eadffe..a987b06eb 100644 --- a/src/main/lib/trpc/routers/claude.ts +++ b/src/main/lib/trpc/routers/claude.ts @@ -810,7 +810,9 @@ export const claudeRouter = router({ baseUrl: z.string().min(1), }) .optional(), - maxThinkingTokens: z.number().optional(), // Enable extended thinking + effort: z + .enum(["low", "medium", "high", "xhigh", "max"]) + .optional(), // Thinking/reasoning effort level images: z.array(imageAttachmentSchema).optional(), // Image attachments historyEnabled: z.boolean().optional(), offlineModeEnabled: z.boolean().optional(), // Whether offline mode (Ollama) is enabled in settings @@ -1994,9 +1996,7 @@ ${prompt} ...(!resumeSessionId && { continue: true }), ...(resolvedModel && { model: resolvedModel }), // fallbackModel: "claude-opus-4-5-20251101", - ...(input.maxThinkingTokens && { - maxThinkingTokens: input.maxThinkingTokens, - }), + ...(input.effort && { effort: input.effort }), }, } diff --git a/src/renderer/components/dialogs/settings-tabs/agents-preferences-tab.tsx b/src/renderer/components/dialogs/settings-tabs/agents-preferences-tab.tsx index 7d5af6e64..bd39e8ed8 100644 --- a/src/renderer/components/dialogs/settings-tabs/agents-preferences-tab.tsx +++ b/src/renderer/components/dialogs/settings-tabs/agents-preferences-tab.tsx @@ -6,7 +6,6 @@ import { ctrlTabTargetAtom, defaultAgentModeAtom, desktopNotificationsEnabledAtom, - extendedThinkingEnabledAtom, notifyWhenFocusedAtom, soundNotificationsEnabledAtom, preferredEditorAtom, @@ -14,6 +13,11 @@ import { type AutoAdvanceTarget, type CtrlTabTarget, } from "../../../lib/atoms" +import { lastSelectedClaudeThinkingAtom } from "../../../features/agents/atoms" +import { + formatClaudeThinkingLabel, + type ClaudeThinkingLevel, +} from "../../../features/agents/lib/models" import { APP_META, type ExternalApp } from "../../../../shared/external-apps" // Editor icon imports @@ -142,8 +146,8 @@ function useIsNarrowScreen(): boolean { } export function AgentsPreferencesTab() { - const [thinkingEnabled, setThinkingEnabled] = useAtom( - extendedThinkingEnabledAtom, + const [claudeThinking, setClaudeThinking] = useAtom( + lastSelectedClaudeThinkingAtom, ) const [soundEnabled, setSoundEnabled] = useAtom(soundNotificationsEnabledAtom) const [desktopNotificationsEnabled, setDesktopNotificationsEnabled] = useAtom(desktopNotificationsEnabledAtom) @@ -197,18 +201,34 @@ export function AgentsPreferencesTab() {
- Extended Thinking + Thinking Effort - Enable deeper reasoning with more thinking tokens (uses more - credits).{" "} - Disables response streaming. + Default effort level for Claude's reasoning. Higher levels think + longer and use more credits.
- +
diff --git a/src/renderer/features/agents/atoms/index.ts b/src/renderer/features/agents/atoms/index.ts index 666975a20..ada0526e3 100644 --- a/src/renderer/features/agents/atoms/index.ts +++ b/src/renderer/features/agents/atoms/index.ts @@ -234,6 +234,34 @@ export const lastSelectedCodexThinkingAtom = atomWithStorage( + "agents:lastSelectedClaudeThinking", + readInitialClaudeThinking(), + undefined, + { getOnInit: true }, +) + // Storage for per-subChat Claude model selection. // Falls back to lastSelectedModelIdAtom when sub-chat has no explicit selection yet. const subChatModelIdsStorageAtom = atomWithStorage>( @@ -323,6 +351,38 @@ export const subChatCodexThinkingAtomFamily = atomFamily((subChatId: string) => ), ) +// Storage for per-subChat Claude thinking level. +// Falls back to lastSelectedClaudeThinkingAtom when sub-chat has no explicit selection yet. +const subChatClaudeThinkingStorageAtom = atomWithStorage< + Record +>( + "agents:subChatClaudeThinking", + {}, + undefined, + { getOnInit: true }, +) + +export const subChatClaudeThinkingAtomFamily = atomFamily((subChatId: string) => + atom( + (get) => { + if (!subChatId) return get(lastSelectedClaudeThinkingAtom) + return ( + get(subChatClaudeThinkingStorageAtom)[subChatId] ?? + get(lastSelectedClaudeThinkingAtom) + ) + }, + (get, set, newThinking: ClaudeThinkingPreference) => { + if (!subChatId) { + set(lastSelectedClaudeThinkingAtom, newThinking) + return + } + const current = get(subChatClaudeThinkingStorageAtom) + if (current[subChatId] === newThinking) return + set(subChatClaudeThinkingStorageAtom, { ...current, [subChatId]: newThinking }) + }, + ), +) + // Storage for all sub-chat modes (persisted per subChatId) const subChatModesStorageAtom = atomWithStorage>( "agents:subChatModes", diff --git a/src/renderer/features/agents/components/agent-model-selector.tsx b/src/renderer/features/agents/components/agent-model-selector.tsx index 56cc4333c..035cfd53c 100644 --- a/src/renderer/features/agents/components/agent-model-selector.tsx +++ b/src/renderer/features/agents/components/agent-model-selector.tsx @@ -13,8 +13,7 @@ import { CommandList, CommandSeparator, } from "../../../components/ui/command" -import { CheckIcon, ClaudeCodeIcon, IconChevronDown, ThinkingIcon } from "../../../components/ui/icons" -import { Switch } from "../../../components/ui/switch" +import { CheckIcon, ClaudeCodeIcon, IconChevronDown } from "../../../components/ui/icons" import { Checkbox } from "../../../components/ui/checkbox" import { Button } from "../../../components/ui/button" import { @@ -23,8 +22,11 @@ import { PopoverTrigger, } from "../../../components/ui/popover" import { cn } from "../../../lib/utils" -import type { CodexThinkingLevel } from "../lib/models" -import { formatCodexThinkingLabel } from "../lib/models" +import type { ClaudeThinkingLevel, CodexThinkingLevel } from "../lib/models" +import { + formatClaudeThinkingLabel, + formatCodexThinkingLabel, +} from "../lib/models" const CROSS_PROVIDER_DIALOG_DISMISSED_KEY = "agent-model-selector:skip-cross-provider-dialog" @@ -40,6 +42,7 @@ type ClaudeModelOption = { id: string name: string version: string + thinkings: ClaudeThinkingLevel[] } type CodexModelOption = { @@ -70,8 +73,8 @@ interface AgentModelSelectorProps { recommendedOllamaModel?: string onSelectOllamaModel: (modelId: string) => void isConnected: boolean - thinkingEnabled: boolean - onThinkingChange: (enabled: boolean) => void + selectedThinking: ClaudeThinkingLevel + onSelectThinking: (thinking: ClaudeThinkingLevel) => void } codex: { models: CodexModelOption[] @@ -89,14 +92,16 @@ type FlatModelItem = | { type: "ollama"; modelName: string; isRecommended: boolean } | { type: "custom" } -function CodexThinkingSubMenu({ - thinkings, - selectedThinking, - onSelectThinking, +function ThinkingSubMenu({ + levels, + selected, + onSelect, + formatLabel, }: { - thinkings: CodexThinkingLevel[] - selectedThinking: CodexThinkingLevel - onSelectThinking: (thinking: CodexThinkingLevel) => void + levels: T[] + selected: T + onSelect: (level: T) => void + formatLabel: (level: T) => string }) { const triggerRef = useRef(null) const subMenuRef = useRef(null) @@ -167,9 +172,7 @@ function CodexThinkingSubMenu({ Thinking
- - {formatCodexThinkingLabel(selectedThinking)} - + {formatLabel(selected)}
@@ -183,15 +186,15 @@ function CodexThinkingSubMenu({ className="fixed z-50 min-w-[180px] overflow-auto rounded-[10px] border border-border bg-popover text-sm text-popover-foreground shadow-lg py-1 animate-in fade-in-0 zoom-in-95 slide-in-from-left-2" style={{ top: subPos.top, left: subPos.left }} > - {thinkings.map((thinking) => { - const isSelected = selectedThinking === thinking + {levels.map((level) => { + const isSelected = selected === level return (
diff --git a/src/renderer/features/agents/lib/ipc-chat-transport.ts b/src/renderer/features/agents/lib/ipc-chat-transport.ts index 3988f972c..216147f2b 100644 --- a/src/renderer/features/agents/lib/ipc-chat-transport.ts +++ b/src/renderer/features/agents/lib/ipc-chat-transport.ts @@ -94,16 +94,9 @@ const ERROR_TOAST_CONFIG: Record< "Your previous chat session expired. Send your message again to start fresh.", }, EXECUTABLE_NOT_FOUND: { - title: "Claude CLI not found", + title: "Claude binary missing", description: - "Install Claude Code CLI: npm install -g @anthropic-ai/claude-code", - action: { - label: "Copy command", - onClick: () => - navigator.clipboard.writeText( - "npm install -g @anthropic-ai/claude-code", - ), - }, + "The bundled Claude binary could not be found. Reinstalling 1Code should restore it.", }, NETWORK_ERROR: { title: "Network error", From f083771c327353ac69a16e75bf49798a711495e5 Mon Sep 17 00:00:00 2001 From: Alejandro Tamayo Castillo Date: Fri, 17 Apr 2026 21:04:35 +0200 Subject: [PATCH 09/33] feat(agents): per-mode default model settings with autoswitch (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Default Plan / Agent / Review Mode Model selectors in Settings → Agents, defaulting to Opus 4.7 1M / Sonnet 4.6 / Opus 4.7 (falling back to Opus if missing). The chat input selector now automatically switches to the mode's default at every mode-change point — plan approval, /plan, /agent, Shift+Tab toggle, new-chat initialization, and /review (transiently). The Settings dropdowns list every enabled model — Claude and Codex alike, filtered by hiddenModelsAtom — so users can pin a Codex model as the Plan / Agent / Review default. Cross-provider autoswitch writes to the right atom family (subChatModelIdAtomFamily vs subChatCodexModelIdAtomFamily), sets a new subChatProviderOverride atom, and syncs lastSelectedAgentIdAtom so the transport follows. subChatProviderOverrides moves from local React state in ChatViewInner to a runtime-only Jotai atom (cleared on chat switch) so the model-switching helper can write to it from outside React. Co-authored-by: Claude Opus 4.7 (1M context) --- .../settings-tabs/agents-preferences-tab.tsx | 133 +++++++++++++++++- src/renderer/features/agents/atoms/index.ts | 66 +++++++++ .../features/agents/lib/model-switching.ts | 74 ++++++++++ .../features/agents/main/active-chat.tsx | 25 +++- .../features/agents/main/chat-input-area.tsx | 10 +- .../features/agents/main/new-chat-form.tsx | 60 +++++++- 6 files changed, 352 insertions(+), 16 deletions(-) create mode 100644 src/renderer/features/agents/lib/model-switching.ts diff --git a/src/renderer/components/dialogs/settings-tabs/agents-preferences-tab.tsx b/src/renderer/components/dialogs/settings-tabs/agents-preferences-tab.tsx index bd39e8ed8..44a820eee 100644 --- a/src/renderer/components/dialogs/settings-tabs/agents-preferences-tab.tsx +++ b/src/renderer/components/dialogs/settings-tabs/agents-preferences-tab.tsx @@ -1,11 +1,12 @@ -import { useAtom } from "jotai" -import { useEffect, useState } from "react" +import { useAtom, useAtomValue } from "jotai" +import { useEffect, useMemo, useState } from "react" import { analyticsOptOutAtom, autoAdvanceTargetAtom, ctrlTabTargetAtom, defaultAgentModeAtom, desktopNotificationsEnabledAtom, + hiddenModelsAtom, notifyWhenFocusedAtom, soundNotificationsEnabledAtom, preferredEditorAtom, @@ -13,8 +14,15 @@ import { type AutoAdvanceTarget, type CtrlTabTarget, } from "../../../lib/atoms" -import { lastSelectedClaudeThinkingAtom } from "../../../features/agents/atoms" import { + defaultAgentModeModelAtom, + defaultPlanModeModelAtom, + defaultReviewModeModelAtom, + lastSelectedClaudeThinkingAtom, +} from "../../../features/agents/atoms" +import { + CLAUDE_MODELS, + CODEX_MODELS, formatClaudeThinkingLabel, type ClaudeThinkingLevel, } from "../../../features/agents/lib/models" @@ -145,6 +153,31 @@ function useIsNarrowScreen(): boolean { return isNarrow } +type ModelOption = { + id: string + label: string + provider: "claude-code" | "codex" +} + +function buildModelOptions(hiddenModels: string[]): ModelOption[] { + const hidden = new Set(hiddenModels) + const claude = CLAUDE_MODELS.filter((m) => !hidden.has(m.id)).map((m) => ({ + id: m.id, + label: `${m.name} ${m.version}`, + provider: "claude-code" as const, + })) + const codex = CODEX_MODELS.filter((m) => !hidden.has(m.id)).map((m) => ({ + id: m.id, + label: m.name, + provider: "codex" as const, + })) + return [...claude, ...codex] +} + +function formatModelLabel(modelId: string, options: ModelOption[]): string { + return options.find((m) => m.id === modelId)?.label ?? modelId +} + export function AgentsPreferencesTab() { const [claudeThinking, setClaudeThinking] = useAtom( lastSelectedClaudeThinkingAtom, @@ -156,6 +189,18 @@ export function AgentsPreferencesTab() { const [ctrlTabTarget, setCtrlTabTarget] = useAtom(ctrlTabTargetAtom) const [autoAdvanceTarget, setAutoAdvanceTarget] = useAtom(autoAdvanceTargetAtom) const [defaultAgentMode, setDefaultAgentMode] = useAtom(defaultAgentModeAtom) + const [defaultPlanModel, setDefaultPlanModel] = useAtom(defaultPlanModeModelAtom) + const [defaultAgentModel, setDefaultAgentModel] = useAtom( + defaultAgentModeModelAtom, + ) + const [defaultReviewModel, setDefaultReviewModel] = useAtom( + defaultReviewModeModelAtom, + ) + const hiddenModels = useAtomValue(hiddenModelsAtom) + const modelOptions = useMemo( + () => buildModelOptions(hiddenModels), + [hiddenModels], + ) const [preferredEditor, setPreferredEditor] = useAtom(preferredEditorAtom) const isNarrowScreen = useIsNarrowScreen() @@ -254,6 +299,88 @@ export function AgentsPreferencesTab() {
+
+
+ + Default Plan Mode Model + + + Model used when a chat starts or switches to Plan mode + +
+ +
+
+
+ + Default Agent Mode Model + + + Model used when a chat starts or switches to Agent mode (e.g. after + approving a plan) + +
+ +
+
+
+ + Default Review Mode Model + + + Model used when running /review or /security-review + +
+ +
diff --git a/src/renderer/features/agents/atoms/index.ts b/src/renderer/features/agents/atoms/index.ts index baf0ad0f1..e64bb43e9 100644 --- a/src/renderer/features/agents/atoms/index.ts +++ b/src/renderer/features/agents/atoms/index.ts @@ -218,6 +218,41 @@ export const lastSelectedModelIdAtom = atomWithStorage( { getOnInit: true }, ) +// Available Claude model IDs (kept in sync with CLAUDE_MODELS in lib/models.ts) +const AVAILABLE_CLAUDE_MODEL_IDS = [ + "opus", + "opus[1m]", + "sonnet", + "haiku", +] as const + +function sanitizeModelId(candidate: string, fallback: string): string { + return (AVAILABLE_CLAUDE_MODEL_IDS as readonly string[]).includes(candidate) + ? candidate + : fallback +} + +export const defaultPlanModeModelAtom = atomWithStorage( + "preferences:default-plan-mode-model", + sanitizeModelId("opus[1m]", "opus"), + undefined, + { getOnInit: true }, +) + +export const defaultAgentModeModelAtom = atomWithStorage( + "preferences:default-agent-mode-model", + sanitizeModelId("sonnet", "opus"), + undefined, + { getOnInit: true }, +) + +export const defaultReviewModeModelAtom = atomWithStorage( + "preferences:default-review-mode-model", + sanitizeModelId("opus", "opus"), + undefined, + { getOnInit: true }, +) + export const lastSelectedCodexModelIdAtom = atomWithStorage( "agents:lastSelectedCodexModelId", "gpt-5.3-codex", @@ -410,6 +445,37 @@ export const MODEL_ID_MAP: Record = { haiku: "haiku", } +// Per-subChat provider override (Claude vs Codex). Runtime-only (not +// persisted); cleared when the active chat changes. Replaces the previous +// local React state so the model-switching helper can write to it from +// non-React contexts (e.g. autoswitch on plan approval or /review). +export const subChatProviderOverridesAtom = atom< + Record +>({}) + +export const subChatProviderOverrideAtomFamily = atomFamily( + (subChatId: string) => + atom( + (get) => + get(subChatProviderOverridesAtom)[subChatId] as + | "claude-code" + | "codex" + | undefined, + (get, set, next: "claude-code" | "codex" | null) => { + const current = get(subChatProviderOverridesAtom) + const prev = current[subChatId] ?? null + if (prev === next) return + const updated = { ...current } + if (next === null) { + delete updated[subChatId] + } else { + updated[subChatId] = next + } + set(subChatProviderOverridesAtom, updated) + }, + ), +) + // Sidebar state - window-scoped so each window has independent sidebar visibility export const agentsSidebarOpenAtom = atomWithWindowStorage( "agents-sidebar-open", diff --git a/src/renderer/features/agents/lib/model-switching.ts b/src/renderer/features/agents/lib/model-switching.ts new file mode 100644 index 000000000..d8c08fb26 --- /dev/null +++ b/src/renderer/features/agents/lib/model-switching.ts @@ -0,0 +1,74 @@ +import { appStore } from "../../../lib/jotai-store" +import type { AgentMode } from "../atoms" +import { + defaultAgentModeModelAtom, + defaultPlanModeModelAtom, + defaultReviewModeModelAtom, + lastSelectedAgentIdAtom, + subChatCodexModelIdAtomFamily, + subChatModelIdAtomFamily, + subChatProviderOverrideAtomFamily, +} from "../atoms" +import { CLAUDE_MODELS, CODEX_MODELS } from "./models" + +export type ModeContext = AgentMode | "review" +export type Provider = "claude-code" | "codex" + +const CLAUDE_MODEL_IDS = new Set(CLAUDE_MODELS.map((m) => m.id)) +const CODEX_MODEL_IDS = new Set(CODEX_MODELS.map((m) => m.id)) + +export function getProviderForModelId(modelId: string): Provider { + if (CODEX_MODEL_IDS.has(modelId)) return "codex" + if (CLAUDE_MODEL_IDS.has(modelId)) return "claude-code" + // Unknown id — fall back to Claude (matches historical behavior). + return "claude-code" +} + +export function getDefaultModelForMode(mode: ModeContext): string { + switch (mode) { + case "plan": + return appStore.get(defaultPlanModeModelAtom) + case "agent": + return appStore.get(defaultAgentModeModelAtom) + case "review": + return appStore.get(defaultReviewModeModelAtom) + } +} + +export function getSubChatModel(subChatId: string): string { + return appStore.get(subChatModelIdAtomFamily(subChatId)) +} + +/** + * Write `modelId` as the active model for the given sub-chat and update the + * provider override so the correct transport is used on the next send. + * + * Works for both Claude and Codex model IDs: + * - Claude IDs (opus, opus[1m], sonnet, haiku) → subChatModelIdAtomFamily + * - Codex IDs (gpt-5.3-codex, etc.) → subChatCodexModelIdAtomFamily + * + * In both cases the per-sub-chat provider override is set so the chat input + * selector and the transport stay in sync. + */ +export function setSubChatModel(subChatId: string, modelId: string): Provider { + const provider = getProviderForModelId(modelId) + if (provider === "codex") { + appStore.set(subChatCodexModelIdAtomFamily(subChatId), modelId) + } else { + appStore.set(subChatModelIdAtomFamily(subChatId), modelId) + } + appStore.set(subChatProviderOverrideAtomFamily(subChatId), provider) + // Keep the global "last selected agent" in sync so new chats created + // shortly after the switch pick the same provider by default. + appStore.set(lastSelectedAgentIdAtom, provider) + return provider +} + +export function applyModeDefaultModel( + subChatId: string, + mode: ModeContext, +): { modelId: string; provider: Provider } { + const modelId = getDefaultModelForMode(mode) + const provider = setSubChatModel(subChatId, modelId) + return { modelId, provider } +} diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index 297f116db..d6c3994e4 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -147,6 +147,7 @@ import { subChatCodexThinkingAtomFamily, subChatModelIdAtomFamily, subChatModeAtomFamily, + subChatProviderOverridesAtom, suppressInputFocusAtom, undoStackAtom, workspaceDiffCacheAtomFamily, @@ -175,6 +176,7 @@ import { getSubChatDraftFull } from "../lib/drafts" import { IPCChatTransport } from "../lib/ipc-chat-transport" +import { applyModeDefaultModel } from "../lib/model-switching" import { createQueueItem, createTextPreview, generateQueueId, toQueuedFile, @@ -3154,6 +3156,9 @@ const ChatViewInner = memo(function ChatViewInner({ // Update atomFamily state (for UI) - this also syncs to store via effect setSubChatMode("agent") + // Autoswitch to the Agent-mode default model before sending + applyModeDefaultModel(subChatId, "agent") + // Enable auto-scroll and immediately scroll to bottom shouldAutoScrollRef.current = true scrollToBottom() @@ -3897,6 +3902,13 @@ const ChatViewInner = memo(function ChatViewInner({ const builtinNames = new Set( BUILTIN_SLASH_COMMANDS.map((cmd) => cmd.name), ) + // Autoswitch to the Review-mode default model for review-type commands. + // Done transiently: we set the model before the transport reads it; we + // don't restore, so the chat input selector remains visibly on the + // review model until the next mode change or manual pick. + if (commandName === "review" || commandName === "security-review") { + applyModeDefaultModel(subChatId, "review") + } if (!builtinNames.has(commandName)) { try { const commands = await trpcClient.commands.list.query({ @@ -4218,6 +4230,10 @@ const ChatViewInner = memo(function ChatViewInner({ const builtinNames = new Set( BUILTIN_SLASH_COMMANDS.map((cmd) => cmd.name), ) + // Autoswitch to the Review-mode default model for review-type commands. + if (commandName === "review" || commandName === "security-review") { + applyModeDefaultModel(subChatId, "review") + } if (!builtinNames.has(commandName)) { try { const commands = await trpcClient.commands.list.query({ @@ -5301,14 +5317,13 @@ export function ChatView({ splitPaneIds: state.splitPaneIds, })) ) - const [ - subChatProviderOverrides, - setSubChatProviderOverrides, - ] = useState>({}) + const [subChatProviderOverrides, setSubChatProviderOverrides] = useAtom( + subChatProviderOverridesAtom, + ) useEffect(() => { setSubChatProviderOverrides({}) - }, [chatId]) + }, [chatId, setSubChatProviderOverrides]) // Clear sub-chat "unseen changes" indicator when sub-chat becomes active useEffect(() => { diff --git a/src/renderer/features/agents/main/chat-input-area.tsx b/src/renderer/features/agents/main/chat-input-area.tsx index 4e8520cad..288765fcb 100644 --- a/src/renderer/features/agents/main/chat-input-area.tsx +++ b/src/renderer/features/agents/main/chat-input-area.tsx @@ -82,6 +82,7 @@ import { type ClaudeThinkingLevel, type CodexThinkingLevel, } from "../lib/models" +import { applyModeDefaultModel } from "../lib/model-switching" import type { DiffTextContext, SelectedTextContext } from "../lib/queue-utils" import { AgentsFileMention, @@ -729,13 +730,16 @@ export const ChatInputArea = memo(function ChatInputArea({ const [subChatMode, setSubChatMode] = useAtom(subChatModeAtom) // Helper to update mode (atomFamily + Zustand store sync) + // Also applies the mode's default model so the chat input selector reflects + // the switch immediately (Plan → Opus 4.7 1M, Agent → Sonnet 4.6, etc.) const updateMode = useCallback((newMode: AgentMode) => { if (onModeChange) { onModeChange(newMode) - return + } else { + setSubChatMode(newMode) + useAgentSubChatStore.getState().updateSubChatMode(subChatId, newMode) } - setSubChatMode(newMode) - useAgentSubChatStore.getState().updateSubChatMode(subChatId, newMode) + applyModeDefaultModel(subChatId, newMode) }, [onModeChange, setSubChatMode, subChatId]) // Toggle mode helper diff --git a/src/renderer/features/agents/main/new-chat-form.tsx b/src/renderer/features/agents/main/new-chat-form.tsx index f08364df3..c7be4ad17 100644 --- a/src/renderer/features/agents/main/new-chat-form.tsx +++ b/src/renderer/features/agents/main/new-chat-form.tsx @@ -45,6 +45,10 @@ import { getNextMode, type AgentMode, } from "../atoms" +import { + getDefaultModelForMode, + getProviderForModelId, +} from "../lib/model-switching" import { defaultAgentModeAtom } from "../../../lib/atoms" import { ProjectSelector } from "../components/project-selector" import { WorkModeSelector } from "../components/work-mode-selector" @@ -332,10 +336,16 @@ export function NewChatForm({ lastSelectedClaudeThinkingAtom, ) - const [selectedModel, setSelectedModel] = useState( - () => - availableModels.models.find((m) => m.id === lastSelectedModelId) || availableModels.models[0], - ) + const [selectedModel, setSelectedModel] = useState(() => { + // Initial model comes from the mode's default (Plan or Agent preference in Settings). + // Falls back to the legacy lastSelectedModelId, then to the first available model. + const modeDefaultId = getDefaultModelForMode(agentMode) + return ( + availableModels.models.find((m) => m.id === modeDefaultId) || + availableModels.models.find((m) => m.id === lastSelectedModelId) || + availableModels.models[0] + ) + }) // Sync selectedModel when atom value changes (e.g., after localStorage hydration) useEffect(() => { @@ -345,6 +355,40 @@ export function NewChatForm({ } }, [lastSelectedModelId]) + // When the mode changes (e.g. user toggles Plan ↔ Agent in the form, or the + // Settings default changes before first render), switch the selected model + // to that mode's default. Works for both Claude and Codex defaults — the + // active agent is swapped accordingly so the right transport is used. + useEffect(() => { + const modeDefaultId = getDefaultModelForMode(agentMode) + const provider = getProviderForModelId(modeDefaultId) + if (provider === "codex") { + const codexAgent = + enabledAgents.find((agent) => agent.id === "codex") || fallbackAgent + if (codexAgent.id !== selectedAgent.id) { + setSelectedAgent(codexAgent) + } + if (lastSelectedCodexModelId !== modeDefaultId) { + setLastSelectedCodexModelId(modeDefaultId) + } + return + } + // Claude default + if (selectedAgent.id !== "claude-code") { + const next = + enabledAgents.find((agent) => agent.id === "claude-code") || + fallbackAgent + setSelectedAgent(next) + } + const model = availableModels.models.find((m) => m.id === modeDefaultId) + if (model && model.id !== selectedModel.id) { + setSelectedModel(model) + } + // Only fire on mode change — manual picks via setSelectedModel should not + // be overridden by this effect re-running. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [agentMode]) + const storedCodexApiKey = useAtomValue(codexApiKeyAtom) const hasAppCodexApiKey = Boolean(normalizeCodexApiKey(storedCodexApiKey)) const hiddenModels = useAtomValue(hiddenModelsAtom) @@ -1144,6 +1188,7 @@ export function NewChatForm({ // Check if message is a slash command with arguments (e.g. "/hello world") // Note: 's' flag makes '.' match newlines, so multi-line arguments are captured const slashMatch = message.match(/^\/(\S+)\s*(.*)$/s) + let reviewModelOverride: string | null = null if (slashMatch) { const [, commandName, args] = slashMatch @@ -1151,6 +1196,11 @@ export function NewChatForm({ const builtinNames = new Set( BUILTIN_SLASH_COMMANDS.map((cmd) => cmd.name), ) + // Review-type commands should create the new chat on the Review-mode + // default model instead of the agent/plan default. + if (commandName === "review" || commandName === "security-review") { + reviewModelOverride = getDefaultModelForMode("review") + } if (!builtinNames.has(commandName)) { // This is a custom command - load content and replace $ARGUMENTS try { @@ -1240,7 +1290,7 @@ export function NewChatForm({ createChatMutation.mutate({ projectId: selectedProject.id, name: message.trim().slice(0, 50), // Use first 50 chars as chat name - model: selectedChatModel, + model: reviewModelOverride ?? selectedChatModel, initialMessageParts: parts.length > 0 ? parts : undefined, baseBranch: workMode === "worktree" ? selectedBranch || undefined : undefined, From f1f3e7e5cdbdc1589cc52aaf514cd95da072f61d Mon Sep 17 00:00:00 2001 From: Alejandro Tamayo Castillo Date: Fri, 17 Apr 2026 21:46:57 +0200 Subject: [PATCH 10/33] ui(agents): light-theme overlays + draggable top strip (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overlays (Dropdown, Popover, ContextMenu, Select) used to hardcode a `dark` class on their content, producing a dark surface in the otherwise light UI. Replaced the hardcoded class with an opt-in `forceDark` prop (default `false`) across all four primitives, so every dropdown / popover / context menu / select follows the active theme by default. Added a 32px draggable strip at the top of the main content pane in `agents-layout.tsx` so window dragging works from any view (previously only the sidebar top was draggable). Added `-webkit-app-region: no-drag` carve-outs to interactive elements that sit inside the strip — AgentsHeaderControls hamburger, NewChatForm mobile fallback, SubChatSelector tabs and Plus button, AutomationsView / InboxView / AutomationsDetailView headers — so buttons remain clickable while empty gaps stay draggable. Co-authored-by: Claude Opus 4.7 (1M context) --- src/renderer/components/ui/context-menu.tsx | 22 +++++++++++----- src/renderer/components/ui/dropdown-menu.tsx | 26 ++++++++++++++----- src/renderer/components/ui/popover.tsx | 2 +- src/renderer/components/ui/select.tsx | 9 ++++--- .../features/agents/main/new-chat-form.tsx | 4 +++ .../agents/ui/agents-header-controls.tsx | 4 +++ .../features/agents/ui/archive-popover.tsx | 1 - .../features/agents/ui/sub-chat-selector.tsx | 16 ++++++++---- .../automations/automations-detail-view.tsx | 12 ++++++++- .../features/automations/automations-view.tsx | 8 ++++++ .../features/automations/inbox-view.tsx | 16 ++++++++++-- .../features/layout/agents-layout.tsx | 12 ++++++++- 12 files changed, 106 insertions(+), 26 deletions(-) diff --git a/src/renderer/components/ui/context-menu.tsx b/src/renderer/components/ui/context-menu.tsx index 9d5271d33..2c806d6f5 100644 --- a/src/renderer/components/ui/context-menu.tsx +++ b/src/renderer/components/ui/context-menu.tsx @@ -48,11 +48,18 @@ ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName const ContextMenuSubContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + forceDark?: boolean + } +>(({ className, forceDark = false, ...props }, ref) => ( )) @@ -60,14 +67,17 @@ ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName const ContextMenuContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + forceDark?: boolean + } +>(({ className, forceDark = false, ...props }, ref) => ( , - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + forceDark?: boolean + } +>(({ className, forceDark = false, ...props }, ref) => ( )) @@ -60,13 +67,20 @@ DropdownMenuSubContent.displayName = const DropdownMenuContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + forceDark?: boolean + } +>(({ className, sideOffset = 4, forceDark = false, ...props }, ref) => ( diff --git a/src/renderer/components/ui/popover.tsx b/src/renderer/components/ui/popover.tsx index c9fdddbab..b86ccb3c4 100644 --- a/src/renderer/components/ui/popover.tsx +++ b/src/renderer/components/ui/popover.tsx @@ -22,7 +22,7 @@ const PopoverContent = React.forwardRef< } >( ( - { className, align = "center", sideOffset = 4, forceDark = true, ...props }, + { className, align = "center", sideOffset = 4, forceDark = false, ...props }, ref, ) => ( diff --git a/src/renderer/components/ui/select.tsx b/src/renderer/components/ui/select.tsx index a9e531230..7b3287b75 100644 --- a/src/renderer/components/ui/select.tsx +++ b/src/renderer/components/ui/select.tsx @@ -98,8 +98,10 @@ SelectScrollDownButton.displayName = const SelectContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, position = "popper", ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + forceDark?: boolean + } +>(({ className, children, position = "popper", forceDark = false, ...props }, ref) => ( diff --git a/src/renderer/features/agents/ui/agents-header-controls.tsx b/src/renderer/features/agents/ui/agents-header-controls.tsx index 9e4858673..82b141a86 100644 --- a/src/renderer/features/agents/ui/agents-header-controls.tsx +++ b/src/renderer/features/agents/ui/agents-header-controls.tsx @@ -39,6 +39,10 @@ export function AgentsHeaderControls({ onClick={onToggleSidebar} className="h-6 w-6 p-0 hover:bg-foreground/10 transition-[background-color,transform] duration-150 ease-out active:scale-[0.97] text-foreground flex-shrink-0 rounded-md relative" aria-label="Open sidebar" + style={{ + // @ts-expect-error - WebKit-specific property + WebkitAppRegion: "no-drag", + }} > {/* Unseen changes indicator */} diff --git a/src/renderer/features/agents/ui/archive-popover.tsx b/src/renderer/features/agents/ui/archive-popover.tsx index a8af3d2f5..1842a293a 100644 --- a/src/renderer/features/agents/ui/archive-popover.tsx +++ b/src/renderer/features/agents/ui/archive-popover.tsx @@ -508,7 +508,6 @@ export const ArchivePopover = memo(function ArchivePopover({ trigger }: ArchiveP side="right" align="end" sideOffset={8} - forceDark={false} className="w-[250px] h-[400px] p-0 flex flex-col overflow-hidden" onKeyDown={handleKeyDown} tabIndex={-1} diff --git a/src/renderer/features/agents/ui/sub-chat-selector.tsx b/src/renderer/features/agents/ui/sub-chat-selector.tsx index b890510da..d17cb3875 100644 --- a/src/renderer/features/agents/ui/sub-chat-selector.tsx +++ b/src/renderer/features/agents/ui/sub-chat-selector.tsx @@ -679,10 +679,6 @@ export function SubChatSelector({
{/* Left gradient - visibility controlled via ref */}
{/* Icon: question icon (priority) OR loading spinner OR mode icon with badge (hide when editing) */} {editingSubChatId !== subChat.id && ( @@ -914,7 +914,13 @@ export function SubChatSelector({ {/* Plus button - absolute positioned on right with gradient cover */} {(isMobile || (!isMobile && subChatsSidebarMode === "tabs")) && ( -
+
{/* Gradient to cover content peeking from the left */}
diff --git a/src/renderer/features/automations/automations-detail-view.tsx b/src/renderer/features/automations/automations-detail-view.tsx index 71ecb1a98..684a232dc 100644 --- a/src/renderer/features/automations/automations-detail-view.tsx +++ b/src/renderer/features/automations/automations-detail-view.tsx @@ -411,11 +411,21 @@ export function AutomationsDetailView() { -
+
{!isCreateMode && ( <> diff --git a/src/renderer/features/automations/automations-view.tsx b/src/renderer/features/automations/automations-view.tsx index ec3d31418..6474d6862 100644 --- a/src/renderer/features/automations/automations-view.tsx +++ b/src/renderer/features/automations/automations-view.tsx @@ -136,6 +136,10 @@ export function AutomationsView() { onClick={handleSidebarToggle} className="h-7 w-7 p-0 flex items-center justify-center hover:bg-foreground/10 transition-[background-color,transform] duration-150 ease-out active:scale-[0.97] flex-shrink-0 rounded-md text-muted-foreground hover:text-foreground" aria-label={isMobile ? "Back to chats" : "Open sidebar"} + style={{ + // @ts-expect-error - WebKit-specific property + WebkitAppRegion: "no-drag", + }} > @@ -150,6 +154,10 @@ export function AutomationsView() {
-
+
+ + Usage + + ) +}) + // Custom SVG icons matching web's icons.tsx function SidebarInboxIcon(props: React.SVGProps) { return ( @@ -3461,6 +3491,9 @@ export function AgentsSidebar({ {/* Archive Button - isolated component to prevent sidebar re-renders */} + + {/* Usage Button - opens the Usage statistics page */} +
diff --git a/src/renderer/features/usage/components/activity-heatmap.tsx b/src/renderer/features/usage/components/activity-heatmap.tsx new file mode 100644 index 000000000..2d9c48f79 --- /dev/null +++ b/src/renderer/features/usage/components/activity-heatmap.tsx @@ -0,0 +1,100 @@ +import { useMemo } from "react" +import { cn } from "../../../lib/utils" +import { formatCompact, formatShortDate } from "../lib/format" + +export type HeatmapCell = { + date: string + dayOfWeek: number + weekIndex: number + totalTokens: number +} + +type Props = { + cells: HeatmapCell[] + className?: string +} + +const DAY_LABELS = ["Mon", "", "Wed", "", "Fri", "", ""] +const CELL = 12 +const GAP = 3 + +function bucketFor(value: number, thresholds: number[]): number { + if (value <= 0) return 0 + for (let i = 0; i < thresholds.length; i += 1) { + if (value <= thresholds[i]!) return i + 1 + } + return thresholds.length +} + +export function ActivityHeatmap({ cells, className }: Props) { + const { weekCount, thresholds } = useMemo(() => { + const maxWeek = cells.reduce((m, c) => Math.max(m, c.weekIndex), 0) + const values = cells.map((c) => c.totalTokens).filter((v) => v > 0).sort((a, b) => a - b) + // 4 thresholds ≈ quartiles, so we end up with 5 intensity levels including 0. + const t: number[] = [] + if (values.length > 0) { + for (const q of [0.25, 0.5, 0.75, 0.95]) { + const idx = Math.min(values.length - 1, Math.floor(values.length * q)) + t.push(values[idx]!) + } + } + return { weekCount: maxWeek + 1, thresholds: t } + }, [cells]) + + const width = weekCount * (CELL + GAP) + 24 + const height = 7 * (CELL + GAP) + 20 + + return ( +
+
Activity
+
+ + {DAY_LABELS.map((label, i) => ( + + {label} + + ))} + {cells.map((c) => { + const level = bucketFor(c.totalTokens, thresholds) + const x = 24 + c.weekIndex * (CELL + GAP) + const y = c.dayOfWeek * (CELL + GAP) + const opacity = level === 0 ? 0.08 : 0.2 + level * 0.2 + return ( + + + {formatShortDate(c.date)} — {formatCompact(c.totalTokens)} tokens + + + ) + })} + +
+
+ Less + {[0.08, 0.3, 0.5, 0.7, 0.9].map((o, i) => ( + + ))} + More +
+
+ ) +} diff --git a/src/renderer/features/usage/components/daily-cost-chart.tsx b/src/renderer/features/usage/components/daily-cost-chart.tsx new file mode 100644 index 000000000..173397352 --- /dev/null +++ b/src/renderer/features/usage/components/daily-cost-chart.tsx @@ -0,0 +1,91 @@ +import { useMemo } from "react" +import { cn } from "../../../lib/utils" +import { formatShortDate, formatUSD } from "../lib/format" + +export type DailyBucket = { + date: string + costUSD: number + totalTokens: number +} + +type Props = { + daily: DailyBucket[] + className?: string +} + +const HEIGHT = 160 +const BAR_GAP = 2 +const BAR_MIN = 4 +const LEFT_PAD = 4 +const RIGHT_PAD = 4 + +export function DailyCostChart({ daily, className }: Props) { + const { max, tickIndices } = useMemo(() => { + let m = 0 + for (const d of daily) m = Math.max(m, d.costUSD) + // Tick positions: first, last, and every ~7th in between. + const ticks: number[] = [] + if (daily.length > 0) { + ticks.push(0) + for (let i = 7; i < daily.length - 1; i += 7) ticks.push(i) + if (daily.length > 1) ticks.push(daily.length - 1) + } + return { max: m, tickIndices: ticks } + }, [daily]) + + const barWidth = 10 + const innerWidth = LEFT_PAD + RIGHT_PAD + daily.length * (barWidth + BAR_GAP) + + return ( +
+
Daily Cost
+
+ + {daily.map((d, i) => { + const h = max > 0 ? Math.max(BAR_MIN, (d.costUSD / max) * HEIGHT) : 0 + const x = LEFT_PAD + i * (barWidth + BAR_GAP) + const y = HEIGHT - h + return ( + 0 ? 0.9 : 0.1} + > + + {formatShortDate(d.date)} — {formatUSD(d.costUSD)} + + + ) + })} + {tickIndices.map((i) => { + const d = daily[i] + if (!d) return null + const x = LEFT_PAD + i * (barWidth + BAR_GAP) + barWidth / 2 + return ( + + {formatShortDate(d.date)} + + ) + })} + +
+
+ ) +} diff --git a/src/renderer/features/usage/components/model-breakdown.tsx b/src/renderer/features/usage/components/model-breakdown.tsx new file mode 100644 index 000000000..5ed981892 --- /dev/null +++ b/src/renderer/features/usage/components/model-breakdown.tsx @@ -0,0 +1,79 @@ +import { useMemo } from "react" +import { cn } from "../../../lib/utils" +import { formatCompact, formatUSD } from "../lib/format" + +export type ModelRow = { + model: string + displayName: string + provider: "claude" | "codex" | "unknown" + totalTokens: number + costUSD: number + priced: boolean +} + +type Props = { + rows: ModelRow[] + className?: string +} + +const PROVIDER_DOT: Record = { + claude: "bg-[#d97757]", + codex: "bg-emerald-500", + unknown: "bg-muted-foreground", +} + +export function ModelBreakdown({ rows, className }: Props) { + const maxTokens = useMemo( + () => rows.reduce((m, r) => Math.max(m, r.totalTokens), 0), + [rows], + ) + + if (rows.length === 0) { + return ( +
+ No model usage recorded in this range. +
+ ) + } + + return ( +
+
+
Model
+
Tokens
+
Cost
+
Usage
+
+ {rows.map((row) => { + const pct = maxTokens > 0 ? (row.totalTokens / maxTokens) * 100 : 0 + return ( +
+
+ + + {row.displayName} + +
+
+ {formatCompact(row.totalTokens)} +
+
+ {row.priced ? formatUSD(row.costUSD) : } +
+
+
+
+
+ ) + })} +
+ ) +} diff --git a/src/renderer/features/usage/components/segmented-toggle.tsx b/src/renderer/features/usage/components/segmented-toggle.tsx new file mode 100644 index 000000000..2bb1b7641 --- /dev/null +++ b/src/renderer/features/usage/components/segmented-toggle.tsx @@ -0,0 +1,51 @@ +import { cn } from "../../../lib/utils" + +export type SegmentedOption = { + value: T + label: string +} + +type Props = { + value: T + onChange: (next: T) => void + options: SegmentedOption[] + size?: "sm" | "md" + className?: string +} + +export function SegmentedToggle({ + value, + onChange, + options, + size = "md", + className, +}: Props) { + return ( +
+ {options.map((opt) => { + const active = opt.value === value + return ( + + ) + })} +
+ ) +} diff --git a/src/renderer/features/usage/components/stat-card.tsx b/src/renderer/features/usage/components/stat-card.tsx new file mode 100644 index 000000000..056b4d6f0 --- /dev/null +++ b/src/renderer/features/usage/components/stat-card.tsx @@ -0,0 +1,32 @@ +import { cn } from "../../../lib/utils" +import { formatCompact, formatFull } from "../lib/format" + +type StatCardProps = { + label: string + value: number + /** When true, renders a $ prefix and uses two decimals. */ + currency?: boolean + /** Override the auto-formatted value with a pre-formatted string. */ + valueOverride?: string + className?: string +} + +export function StatCard({ label, value, currency, valueOverride, className }: StatCardProps) { + const display = + valueOverride ?? + (currency ? `$${value.toFixed(2)}` : formatCompact(value)) + const title = currency ? `$${value.toFixed(2)}` : formatFull(value) + return ( +
+
{label}
+
+ {display} +
+
+ ) +} diff --git a/src/renderer/features/usage/lib/format.ts b/src/renderer/features/usage/lib/format.ts new file mode 100644 index 000000000..8697a6140 --- /dev/null +++ b/src/renderer/features/usage/lib/format.ts @@ -0,0 +1,30 @@ +const compactFormatter = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 1, +}) + +const fullFormatter = new Intl.NumberFormat("en-US") + +export function formatCompact(n: number): string { + if (!Number.isFinite(n)) return "0" + if (n === 0) return "0" + return compactFormatter.format(n) +} + +export function formatFull(n: number): string { + return fullFormatter.format(Math.round(n)) +} + +export function formatUSD(n: number, opts: { compact?: boolean } = {}): string { + if (!Number.isFinite(n)) return "$0.00" + if (opts.compact && Math.abs(n) >= 1000) { + return `$${compactFormatter.format(n)}` + } + return `$${n.toFixed(2)}` +} + +/** "Apr 17" style short label for axis ticks. */ +export function formatShortDate(iso: string): string { + const d = new Date(`${iso}T00:00:00`) + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }) +} diff --git a/src/renderer/features/usage/usage-content.tsx b/src/renderer/features/usage/usage-content.tsx new file mode 100644 index 000000000..dd2b98bd7 --- /dev/null +++ b/src/renderer/features/usage/usage-content.tsx @@ -0,0 +1,187 @@ +import { useAtom, useSetAtom } from "jotai" +import { useEffect } from "react" +import { + desktopViewAtom, + usagePeriodAtom, + usageSourceAtom, + type UsagePeriod, + type UsageSourceFilter, +} from "../agents/atoms" +import { trpc } from "../../lib/trpc" +import { StatCard } from "./components/stat-card" +import { SegmentedToggle } from "./components/segmented-toggle" +import { ActivityHeatmap } from "./components/activity-heatmap" +import { DailyCostChart } from "./components/daily-cost-chart" +import { ModelBreakdown } from "./components/model-breakdown" +import { formatCompact, formatUSD } from "./lib/format" +import { RefreshCw } from "lucide-react" + +const PERIOD_OPTIONS: { value: UsagePeriod; label: string }[] = [ + { value: "7d", label: "7d" }, + { value: "30d", label: "30d" }, + { value: "90d", label: "90d" }, + { value: "all", label: "All" }, +] + +const SOURCE_OPTIONS: { value: UsageSourceFilter; label: string }[] = [ + { value: "all", label: "All" }, + { value: "claude", label: "Claude" }, + { value: "codex", label: "Codex" }, +] + +export function UsageContent() { + const [period, setPeriod] = useAtom(usagePeriodAtom) + const [source, setSource] = useAtom(usageSourceAtom) + const setDesktopView = useSetAtom(desktopViewAtom) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault() + setDesktopView(null) + } + } + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [setDesktopView]) + + const { + data, + isLoading, + isError, + error, + refetch, + isFetching, + } = trpc.usage.getOverview.useQuery( + { period, source }, + { staleTime: 15_000, refetchOnWindowFocus: false }, + ) + + const refreshMutation = trpc.usage.refresh.useMutation({ + onSuccess: () => { + refetch() + }, + }) + + return ( +
+
+
+
+

Usage

+

+ Aggregated from local Claude Code and Codex CLI session logs. +

+
+
+ + + +
+
+ + {isError ? ( +
+ Failed to load usage: {error?.message ?? "unknown error"} +
+ ) : null} + + {isLoading || !data ? ( + + ) : ( + <> +
+ + + + +
+ +
+ + + + +
+ +
+
+ +
+
+ +
+
+ +
+
Models
+ +
+ + {data.totals.unpricedModels.length > 0 ? ( +
+ Unpriced models (tokens shown, cost omitted):{" "} + {data.totals.unpricedModels.join(", ")} +
+ ) : null} + + )} +
+
+ ) +} + +function LoadingSkeleton() { + return ( +
+
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+
+
+
+
+
+
+ ) +} From b643d9ec9951c884183a3acdded916b599228209 Mon Sep 17 00:00:00 2001 From: Alejandro Tamayo Castillo Date: Sat, 18 Apr 2026 11:53:56 +0200 Subject: [PATCH 12/33] chore(claude): bump SDK/CLI to 2.1.114 + refresh slash commands (#15) - @anthropic-ai/claude-agent-sdk: 0.2.112 -> 0.2.114 - @modelcontextprotocol/sdk: ^1.25.3 -> ^1.29.0 (match SDK peer dep) - claude:download CLI binary: 2.1.112 -> 2.1.114 - Remove /pr-comments (deprecated in Claude Code v2.1.91) - Add /init, /simplify, /help builtin commands Co-authored-by: Claude Opus 4.7 (1M context) --- bun.lock | 66 +++++++------------ package.json | 8 +-- .../agents/commands/builtin-commands.ts | 36 +++++++--- .../features/agents/commands/types.ts | 4 +- .../features/agents/main/chat-input-area.tsx | 12 +++- .../features/agents/main/new-chat-form.tsx | 10 +++ 6 files changed, 79 insertions(+), 57 deletions(-) diff --git a/bun.lock b/bun.lock index fee91b2e0..3702d91fd 100644 --- a/bun.lock +++ b/bun.lock @@ -6,11 +6,11 @@ "name": "21st-desktop", "dependencies": { "@ai-sdk/react": "^3.0.14", - "@anthropic-ai/claude-agent-sdk": "0.2.112", + "@anthropic-ai/claude-agent-sdk": "0.2.114", "@git-diff-view/react": "^0.0.35", "@git-diff-view/shiki": "^0.0.36", "@mcpc-tech/acp-ai-provider": "^0.2.4", - "@modelcontextprotocol/sdk": "^1.25.3", + "@modelcontextprotocol/sdk": "^1.29.0", "@monaco-editor/react": "^4.7.0", "@pierre/diffs": "^1.0.10", "@radix-ui/react-accordion": "^1.2.12", @@ -128,7 +128,23 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.112", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-vMFoiDKlOive8p3tphpV1gQaaytOipwGJ+uw9mvvaLQUODSC2+fCdRDAY25i2Tsv+lOtxzXBKctmaDuWqZY7ig=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.114", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.114", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.114", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.114", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.114" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-plJ+j17jew9tDMHir/90hXrwoB8cZ9GrIyG19zIJcFyQ8pVhRXjZRJCtF2ElfPoiwkxMmNu1Klqyui4xP4shPg=="], + + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.114", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0/6LWrNilWpmiX6Xrj5plsBmCrCdKGERgAlKUZQEJZplnfuweFAJu7WXZB4KBaUpGlPO91zB/yqDh6kp5aZFbA=="], + + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.114", "", { "os": "darwin", "cpu": "x64" }, "sha512-sOHxq1rEO/KZg2iEZILTPn62lMRRMPqtxKx41uGLi3xjVDrAej6Ury9dDZjYBKkK9n4kBylXV0Oom2CZ14dDYw=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.114", "", { "os": "linux", "cpu": "arm64" }, "sha512-j/SfEoN6+fyEsp8EuPe+xKcGfsZtaBmdUUH+YSRk5H/lYgy38yNsDhdt+AJMQcdMKfHsiwZ3Y9Ajoe9G9wNwHQ=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.114", "", { "os": "linux", "cpu": "arm64" }, "sha512-Mhd7bumTwWvkgjSJnYvCgyt8DfmLiUoK92mfvAKxHX7i5YSw+h5Kprqh2Cap+2SBbpwZvnwIoEYGCxhGwE5ddg=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.114", "", { "os": "linux", "cpu": "x64" }, "sha512-wbaExKDleLlm2zHEhb74GKMLVhtO0IUmFhdimQcdL6CdTkmDE8ZJi53tYWE9+jq+XWNRXoM2yEmKPzXoUmsJng=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.114", "", { "os": "linux", "cpu": "x64" }, "sha512-c1URsameGHAcghen+mY6jvr2oypiAPHXJIdP4huxR25zPdXWv2x+BCy+vcRVeajsq4VmFzAyQJwaM+BXkmXjAw=="], + + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.114", "", { "os": "win32", "cpu": "arm64" }, "sha512-qeWdUpQymcKCA92osPmffG4QogrOSvuffPvm6c2OlMDjCPYs8vKG7bSe1Vq5tP9tfBszKPVJWBDh+2ANkNissQ=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.114", "", { "os": "win32", "cpu": "x64" }, "sha512-nVr43WwsKvWA6rojw15qBS/f31srukdLxy1KwKzpftlpmkzQ9Lh8uhIafOmoIPzz67f8VJ8JqHE0caA5YrhX9A=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="], @@ -290,38 +306,6 @@ "@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], - - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], - - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], - - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], - - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], - - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], - - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], - - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], - - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], - - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], - - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], - - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], - - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], - - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], - - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], - - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], - "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="], @@ -352,7 +336,7 @@ "@mermaid-js/parser": ["@mermaid-js/parser@0.6.3", "", { "dependencies": { "langium": "3.3.1" } }, "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="], @@ -1272,7 +1256,7 @@ "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - "express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], + "express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="], "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], @@ -2284,8 +2268,6 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@anthropic-ai/claude-agent-sdk/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], - "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -2326,6 +2308,8 @@ "@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=="], + "@mcpc-tech/acp-ai-provider/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="], + "@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], @@ -2490,8 +2474,6 @@ "zip-stream/archiver-utils": ["archiver-utils@3.0.4", "", { "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw=="], - "@anthropic-ai/claude-agent-sdk/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="], - "@develar/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], @@ -2558,6 +2540,8 @@ "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "@mcpc-tech/acp-ai-provider/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], + "@pierre/diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-OYknTCct6qiwpQDqDdf3iedRdzj6hFlOPv5hMvI+hkWfCKs5mlJ4TXziBG9nyabLwGulrUjHiCq3xCspSzErYQ=="], "@pierre/diffs/shiki/@shikijs/langs": ["@shikijs/langs@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0" } }, "sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA=="], diff --git a/package.json b/package.json index 49484f82f..18302342f 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,8 @@ "dist": "electron-builder", "dist:manifest": "node scripts/generate-update-manifest.mjs", "dist:upload": "node scripts/upload-release.mjs", - "claude:download": "node scripts/download-claude-binary.mjs --version=2.1.112", - "claude:download:all": "node scripts/download-claude-binary.mjs --version=2.1.112 --all", + "claude:download": "node scripts/download-claude-binary.mjs --version=2.1.114", + "claude:download:all": "node scripts/download-claude-binary.mjs --version=2.1.114 --all", "codex:download": "node scripts/download-codex-binary.mjs --version=0.98.0", "codex:download:all": "node scripts/download-codex-binary.mjs --version=0.98.0 --all", "release": "rm -rf release && bun i && bun run claude:download && bun run codex:download && bun run build && bun run package:mac && bun run dist:manifest && ./scripts/upload-release-wrangler.sh", @@ -36,11 +36,11 @@ }, "dependencies": { "@ai-sdk/react": "^3.0.14", - "@anthropic-ai/claude-agent-sdk": "0.2.112", + "@anthropic-ai/claude-agent-sdk": "0.2.114", "@git-diff-view/react": "^0.0.35", "@git-diff-view/shiki": "^0.0.36", "@mcpc-tech/acp-ai-provider": "^0.2.4", - "@modelcontextprotocol/sdk": "^1.25.3", + "@modelcontextprotocol/sdk": "^1.29.0", "@monaco-editor/react": "^4.7.0", "@pierre/diffs": "^1.0.10", "@radix-ui/react-accordion": "^1.2.12", diff --git a/src/renderer/features/agents/commands/builtin-commands.ts b/src/renderer/features/agents/commands/builtin-commands.ts index 6a29bc62f..4006c7811 100644 --- a/src/renderer/features/agents/commands/builtin-commands.ts +++ b/src/renderer/features/agents/commands/builtin-commands.ts @@ -8,14 +8,16 @@ export const COMMAND_PROMPTS: Partial< > = { review: "Please review the code in the current context and provide feedback on code quality, potential bugs, and improvements.", - "pr-comments": - "Generate detailed PR review comments for the changes in the current context.", "release-notes": "Generate release notes summarizing the changes in this codebase.", "security-review": "Perform a security audit of the code in the current context. Identify vulnerabilities, security risks, and suggest fixes.", commit: "Закоммить это аккуратно, не трогая больше ничего. Сделай коммит только для staged изменений, не добавляй никакие другие файлы и не вноси дополнительных изменений.", + init: + "Initialize this project by creating a CLAUDE.md file that documents the codebase architecture, key commands, and conventions for AI assistants. Analyze the repo structure and existing config files first.", + simplify: + "Review the code in the current context for reuse, quality, and efficiency. Look for duplicated logic, unnecessary abstractions, dead code, and premature complexity. Propose concrete simplifications and apply them.", "worktree-setup": `Create a worktree setup script for this project. Your task: @@ -48,7 +50,7 @@ Now analyze this project and create .1code/worktree.json with the appropriate se */ export function isPromptCommand( type: BuiltinCommandAction["type"], -): type is "review" | "pr-comments" | "release-notes" | "security-review" | "commit" | "worktree-setup" { +): type is "review" | "release-notes" | "security-review" | "commit" | "worktree-setup" | "init" | "simplify" { return type in COMMAND_PROMPTS } @@ -84,6 +86,13 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandOption[] = [ description: "Compact conversation context to reduce token usage", category: "builtin", }, + { + id: "builtin:help", + name: "help", + command: "/help", + description: "List all available slash commands", + category: "builtin", + }, // Prompt-based commands { id: "builtin:review", @@ -92,13 +101,6 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandOption[] = [ description: "Ask agent to review your code", category: "builtin", }, - { - id: "builtin:pr-comments", - name: "pr-comments", - command: "/pr-comments", - description: "Ask agent to generate PR review comments", - category: "builtin", - }, { id: "builtin:release-notes", name: "release-notes", @@ -127,6 +129,20 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandOption[] = [ description: "Generate worktree setup config with AI", category: "builtin", }, + { + id: "builtin:init", + name: "init", + command: "/init", + description: "Initialize a CLAUDE.md project guide", + category: "builtin", + }, + { + id: "builtin:simplify", + name: "simplify", + command: "/simplify", + description: "Review code for reuse, quality, and efficiency", + category: "builtin", + }, ] /** diff --git a/src/renderer/features/agents/commands/types.ts b/src/renderer/features/agents/commands/types.ts index 8c2d2d866..12e5c109f 100644 --- a/src/renderer/features/agents/commands/types.ts +++ b/src/renderer/features/agents/commands/types.ts @@ -36,13 +36,15 @@ export type BuiltinCommandAction = | { type: "plan" } | { type: "agent" } | { type: "compact" } + | { type: "help" } // Prompt-based commands (send to agent) | { type: "review" } - | { type: "pr-comments" } | { type: "release-notes" } | { type: "security-review" } | { type: "commit" } | { type: "worktree-setup" } + | { type: "init" } + | { type: "simplify" } // Result of selecting a slash command export type SlashCommandSelection = diff --git a/src/renderer/features/agents/main/chat-input-area.tsx b/src/renderer/features/agents/main/chat-input-area.tsx index 288765fcb..dc0185228 100644 --- a/src/renderer/features/agents/main/chat-input-area.tsx +++ b/src/renderer/features/agents/main/chat-input-area.tsx @@ -68,7 +68,7 @@ import { type SubChatFileChange, } from "../atoms" import { useAgentSubChatStore } from "../stores/sub-chat-store" -import { AgentsSlashCommand, type SlashCommandOption } from "../commands" +import { AgentsSlashCommand, BUILTIN_SLASH_COMMANDS, type SlashCommandOption } from "../commands" import { AgentModelSelector } from "../components/agent-model-selector" import { AgentSendButton } from "../components/agent-send-button" import type { UploadedFile, UploadedImage } from "../hooks/use-agents-file-upload" @@ -1115,6 +1115,16 @@ export const ChatInputArea = memo(function ChatInputArea({ // Trigger context compaction onCompact() return + case "help": { + const lines = BUILTIN_SLASH_COMMANDS.map( + (c) => `${c.command} — ${c.description}`, + ).join("\n") + toast.message("Available slash commands", { + description: lines, + duration: 8000, + }) + return + } } } diff --git a/src/renderer/features/agents/main/new-chat-form.tsx b/src/renderer/features/agents/main/new-chat-form.tsx index 63de62c72..61469ac15 100644 --- a/src/renderer/features/agents/main/new-chat-form.tsx +++ b/src/renderer/features/agents/main/new-chat-form.tsx @@ -1461,6 +1461,16 @@ export function NewChatForm({ setAgentMode("agent") } return + case "help": { + const lines = BUILTIN_SLASH_COMMANDS.map( + (c) => `${c.command} — ${c.description}`, + ).join("\n") + toast.message("Available slash commands", { + description: lines, + duration: 8000, + }) + return + } } } From e1484646213a35bb1850c0b05075aad061a6c912 Mon Sep 17 00:00:00 2001 From: Alejandro Tamayo Castillo Date: Sat, 18 Apr 2026 11:58:36 +0200 Subject: [PATCH 13/33] feat(agents): per-mode thinking effort, wider columns, drag-region fixes (#16) - Per-mode Thinking Effort: new defaultPlan/Agent/ReviewModeThinkingAtom applied alongside the model default via applyModeDefaultModel, so switching mode (or /review) also swaps thinking effort. Removes the global Thinking Effort row from Settings; keeps per-subChat overrides intact. - Settings Preferences redesign: drop "Mode" from model labels; collapse the six model/thinking rows into three "Default Plan/Agent/Review" rows with the model and thinking selectors side-by-side on the right. Widen the settings container from max-w-2xl to max-w-5xl. - Chat column: bump max-w-4xl -> max-w-5xl on messages, user-questions, queue/status cards, chat input, disabled input fallback, and the new-chat form so every view uses the same wider column. - Drag-region fixes: add WebkitAppRegion: "no-drag" to interactive elements sitting under the 32px draggable top strip that were silently becoming drag regions. Affects right-side chat header buttons (Fork Locally, Open Preview, View Details, Open Terminal, Restore), terminal sidebar (Close + TerminalModeSwitcher), details sidebar, agent-plan sidebar, agent-preview desktop header, file-viewer sidebar (both headers), and expanded-widget sidebar. Co-authored-by: Claude Opus 4.7 (1M context) --- .../settings-tabs/agents-preferences-tab.tsx | 247 +++++++++++------- src/renderer/features/agents/atoms/index.ts | 21 ++ .../features/agents/lib/model-switching.ts | 23 +- .../features/agents/main/active-chat.tsx | 38 ++- .../features/agents/main/chat-input-area.tsx | 2 +- .../features/agents/main/new-chat-form.tsx | 9 +- .../features/agents/ui/agent-plan-sidebar.tsx | 8 +- .../features/agents/ui/agent-preview.tsx | 8 +- .../details-sidebar/details-sidebar.tsx | 8 +- .../expanded-widget-sidebar.tsx | 8 +- .../components/file-viewer-sidebar.tsx | 16 +- .../features/settings/settings-content.tsx | 2 +- .../features/terminal/terminal-sidebar.tsx | 8 + 13 files changed, 280 insertions(+), 118 deletions(-) diff --git a/src/renderer/components/dialogs/settings-tabs/agents-preferences-tab.tsx b/src/renderer/components/dialogs/settings-tabs/agents-preferences-tab.tsx index 44a820eee..f66f386b0 100644 --- a/src/renderer/components/dialogs/settings-tabs/agents-preferences-tab.tsx +++ b/src/renderer/components/dialogs/settings-tabs/agents-preferences-tab.tsx @@ -16,9 +16,11 @@ import { } from "../../../lib/atoms" import { defaultAgentModeModelAtom, + defaultAgentModeThinkingAtom, defaultPlanModeModelAtom, + defaultPlanModeThinkingAtom, defaultReviewModeModelAtom, - lastSelectedClaudeThinkingAtom, + defaultReviewModeThinkingAtom, } from "../../../features/agents/atoms" import { CLAUDE_MODELS, @@ -179,9 +181,6 @@ function formatModelLabel(modelId: string, options: ModelOption[]): string { } export function AgentsPreferencesTab() { - const [claudeThinking, setClaudeThinking] = useAtom( - lastSelectedClaudeThinkingAtom, - ) const [soundEnabled, setSoundEnabled] = useAtom(soundNotificationsEnabledAtom) const [desktopNotificationsEnabled, setDesktopNotificationsEnabled] = useAtom(desktopNotificationsEnabledAtom) const [notifyWhenFocused, setNotifyWhenFocused] = useAtom(notifyWhenFocusedAtom) @@ -196,6 +195,15 @@ export function AgentsPreferencesTab() { const [defaultReviewModel, setDefaultReviewModel] = useAtom( defaultReviewModeModelAtom, ) + const [defaultPlanThinking, setDefaultPlanThinking] = useAtom( + defaultPlanModeThinkingAtom, + ) + const [defaultAgentThinking, setDefaultAgentThinking] = useAtom( + defaultAgentModeThinkingAtom, + ) + const [defaultReviewThinking, setDefaultReviewThinking] = useAtom( + defaultReviewModeThinkingAtom, + ) const hiddenModels = useAtomValue(hiddenModelsAtom) const modelOptions = useMemo( () => buildModelOptions(hiddenModels), @@ -244,38 +252,6 @@ export function AgentsPreferencesTab() { {/* Agent Behavior */}
-
- - Thinking Effort - - - Default effort level for Claude's reasoning. Higher levels think - longer and use more credits. - -
- -
-
Default Mode @@ -299,87 +275,158 @@ export function AgentsPreferencesTab() {
-
-
+
+
- Default Plan Mode Model + Default Plan - Model used when a chat starts or switches to Plan mode + Model and thinking effort applied when a chat starts or switches + to Plan mode
- +
+ + +
-
-
+
+
- Default Agent Mode Model + Default Agent - Model used when a chat starts or switches to Agent mode (e.g. after - approving a plan) + Model and thinking effort applied when a chat starts or switches + to Agent mode (e.g. after approving a plan)
- +
+ + +
-
-
+
+
- Default Review Mode Model + Default Review - Model used when running /review or /security-review + Model and thinking effort applied when running /review or + /security-review
- +
+ + +
diff --git a/src/renderer/features/agents/atoms/index.ts b/src/renderer/features/agents/atoms/index.ts index 00a03cbee..ff40a1f5c 100644 --- a/src/renderer/features/agents/atoms/index.ts +++ b/src/renderer/features/agents/atoms/index.ts @@ -297,6 +297,27 @@ export const lastSelectedClaudeThinkingAtom = atomWithStorage( + "preferences:default-plan-mode-thinking", + "high", + undefined, + { getOnInit: true }, +) + +export const defaultAgentModeThinkingAtom = atomWithStorage( + "preferences:default-agent-mode-thinking", + "high", + undefined, + { getOnInit: true }, +) + +export const defaultReviewModeThinkingAtom = atomWithStorage( + "preferences:default-review-mode-thinking", + "high", + undefined, + { getOnInit: true }, +) + // Storage for per-subChat Claude model selection. // Falls back to lastSelectedModelIdAtom when sub-chat has no explicit selection yet. const subChatModelIdsStorageAtom = atomWithStorage>( diff --git a/src/renderer/features/agents/lib/model-switching.ts b/src/renderer/features/agents/lib/model-switching.ts index d8c08fb26..b8f012333 100644 --- a/src/renderer/features/agents/lib/model-switching.ts +++ b/src/renderer/features/agents/lib/model-switching.ts @@ -1,10 +1,14 @@ import { appStore } from "../../../lib/jotai-store" -import type { AgentMode } from "../atoms" +import type { AgentMode, ClaudeThinkingPreference } from "../atoms" import { defaultAgentModeModelAtom, + defaultAgentModeThinkingAtom, defaultPlanModeModelAtom, + defaultPlanModeThinkingAtom, defaultReviewModeModelAtom, + defaultReviewModeThinkingAtom, lastSelectedAgentIdAtom, + subChatClaudeThinkingAtomFamily, subChatCodexModelIdAtomFamily, subChatModelIdAtomFamily, subChatProviderOverrideAtomFamily, @@ -35,6 +39,19 @@ export function getDefaultModelForMode(mode: ModeContext): string { } } +export function getDefaultThinkingForMode( + mode: ModeContext, +): ClaudeThinkingPreference { + switch (mode) { + case "plan": + return appStore.get(defaultPlanModeThinkingAtom) + case "agent": + return appStore.get(defaultAgentModeThinkingAtom) + case "review": + return appStore.get(defaultReviewModeThinkingAtom) + } +} + export function getSubChatModel(subChatId: string): string { return appStore.get(subChatModelIdAtomFamily(subChatId)) } @@ -70,5 +87,9 @@ export function applyModeDefaultModel( ): { modelId: string; provider: Provider } { const modelId = getDefaultModelForMode(mode) const provider = setSubChatModel(subChatId, modelId) + appStore.set( + subChatClaudeThinkingAtomFamily(subChatId), + getDefaultThinkingForMode(mode), + ) return { modelId, provider } } diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index d6c3994e4..c2f9a40c4 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -4663,7 +4663,7 @@ const ChatViewInner = memo(function ChatViewInner({ /> {/* Workspace subtitle: repo • branch */} {(workspaceRepoName || workspaceBranch) && ( -
+
{[workspaceRepoName, workspaceBranch].filter(Boolean).join(" • ")} @@ -4708,7 +4708,7 @@ const ChatViewInner = memo(function ChatViewInner({ >
-
+
-
+
{/* Queue indicator card - top card */} {queue.length > 0 && ( {isImporting ? ( @@ -7569,6 +7573,10 @@ Make sure to preserve all functionality from both branches when resolving confli onClick={() => setIsPreviewSidebarOpen(true)} className="h-6 w-6 p-0 hover:bg-foreground/10 transition-colors text-foreground flex-shrink-0 rounded-md ml-2" aria-label="Open preview" + style={{ + // @ts-expect-error - WebKit-specific property + WebkitAppRegion: "no-drag", + }} > @@ -7577,7 +7585,13 @@ Make sure to preserve all functionality from both branches when resolving confli ) : ( - + @@ -7625,6 +7643,10 @@ Make sure to preserve all functionality from both branches when resolving confli onClick={() => setIsTerminalSidebarOpen(true)} className="h-6 w-6 p-0 hover:bg-foreground/10 transition-colors text-foreground flex-shrink-0 rounded-md ml-2" aria-label="Open terminal" + style={{ + // @ts-expect-error - WebKit-specific property + WebkitAppRegion: "no-drag", + }} > @@ -7647,6 +7669,10 @@ Make sure to preserve all functionality from both branches when resolving confli disabled={restoreWorkspaceMutation.isPending} className="h-6 px-2 gap-1.5 hover:bg-foreground/10 transition-colors text-foreground flex-shrink-0 rounded-md ml-2 flex items-center" aria-label="Restore workspace" + style={{ + // @ts-expect-error - WebKit-specific property + WebkitAppRegion: "no-drag", + }} > Restore @@ -7846,7 +7872,7 @@ Make sure to preserve all functionality from both branches when resolving confli {/* Disabled input while loading */}
-
+
-
+
{ const modeDefaultId = getDefaultModelForMode(agentMode) const provider = getProviderForModelId(modeDefaultId) @@ -384,6 +386,7 @@ export function NewChatForm({ if (model && model.id !== selectedModel.id) { setSelectedModel(model) } + setLastSelectedClaudeThinking(getDefaultThinkingForMode(agentMode)) // Only fire on mode change — manual picks via setSelectedModel should not // be overridden by this effect re-running. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -1727,7 +1730,7 @@ export function NewChatForm({
-
+
{/* Title - only show when project is selected */} {validatedProject && (
diff --git a/src/renderer/features/agents/ui/agent-plan-sidebar.tsx b/src/renderer/features/agents/ui/agent-plan-sidebar.tsx index 4d64b23e8..1025d05aa 100644 --- a/src/renderer/features/agents/ui/agent-plan-sidebar.tsx +++ b/src/renderer/features/agents/ui/agent-plan-sidebar.tsx @@ -62,7 +62,13 @@ export function AgentPlanSidebar({ return (
{/* Header */} -
+
@@ -546,6 +550,10 @@ export function TerminalSidebar({ onClick={closeSidebar} className="h-6 w-6 p-0 hover:bg-foreground/10 transition-[background-color,transform] duration-150 ease-out active:scale-[0.97] text-foreground flex-shrink-0 rounded-md" aria-label="Close terminal" + style={{ + // @ts-expect-error - WebKit-specific property + WebkitAppRegion: "no-drag", + }} > From 5a435fee2dc43890b7d5810955476a3b24239d46 Mon Sep 17 00:00:00 2001 From: Alejandro Tamayo Castillo Date: Sat, 18 Apr 2026 12:13:26 +0200 Subject: [PATCH 14/33] FIX: Disclaimer and hamburguer (#17) --- README.md | 16 ++++++++++ src/renderer/features/usage/usage-content.tsx | 32 +++++++++++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 082960e66..cfa08c524 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +> **⚠️ Fork Notice:** This is a personal fork of [21st-dev/1code](https://github.com/21st-dev/1code) maintained by [@aletc1](https://github.com/aletc1). It includes additional features and fixes not yet merged upstream. For the official release, visit the [original repository](https://github.com/21st-dev/1code). + # 1Code [1Code.dev](https://1code.dev) @@ -6,6 +8,20 @@ Open-source coding agent client. Run Claude Code, Codex, and more - locally or i By [21st.dev](https://21st.dev) team +## Fork Additions + +Enhancements added in this fork on top of upstream: + +- **Per-Mode Thinking Effort** - Set Claude's thinking budget independently for Plan and Agent modes +- **Usage Statistics** - Built-in page showing Claude + Codex token and cost tracking +- **Per-Mode Default Models** - Configure default model per mode with automatic switching on mode change +- **Latest Claude Models** - Opus 4.7 and updated model list including latest Claude releases +- **Wider Chat Column** - Expanded chat area (max-w-4xl) for better readability +- **Windows Git Path Fix** - POSIX-normalized git paths so the sidebar tree view works on Windows +- **Enter / Shift+Enter Swap** - Enter submits, Shift+Enter inserts a newline (matches common conventions) + +--- + ## Highlights - **Multi-Agent Support** - Claude Code and Codex in one app, switch instantly diff --git a/src/renderer/features/usage/usage-content.tsx b/src/renderer/features/usage/usage-content.tsx index dd2b98bd7..3637cfad8 100644 --- a/src/renderer/features/usage/usage-content.tsx +++ b/src/renderer/features/usage/usage-content.tsx @@ -4,6 +4,7 @@ import { desktopViewAtom, usagePeriodAtom, usageSourceAtom, + agentsSidebarOpenAtom, type UsagePeriod, type UsageSourceFilter, } from "../agents/atoms" @@ -14,7 +15,10 @@ import { ActivityHeatmap } from "./components/activity-heatmap" import { DailyCostChart } from "./components/daily-cost-chart" import { ModelBreakdown } from "./components/model-breakdown" import { formatCompact, formatUSD } from "./lib/format" -import { RefreshCw } from "lucide-react" +import { RefreshCw, AlignJustify } from "lucide-react" +import { useIsMobile } from "../../lib/hooks/use-mobile" +import { AgentsHeaderControls } from "../agents/ui/agents-header-controls" +import { Button } from "../../components/ui/button" const PERIOD_OPTIONS: { value: UsagePeriod; label: string }[] = [ { value: "7d", label: "7d" }, @@ -33,6 +37,8 @@ export function UsageContent() { const [period, setPeriod] = useAtom(usagePeriodAtom) const [source, setSource] = useAtom(usageSourceAtom) const setDesktopView = useSetAtom(desktopViewAtom) + const [sidebarOpen, setSidebarOpen] = useAtom(agentsSidebarOpenAtom) + const isMobile = useIsMobile() useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -64,7 +70,28 @@ export function UsageContent() { }) return ( -
+
+ {/* Header bar — mirrors kanban layout */} +
+ {isMobile ? ( + + ) : ( + setSidebarOpen((prev) => !prev)} + /> + )} +
+ +
@@ -165,6 +192,7 @@ export function UsageContent() { )}
+
) } From 30e04733fc94747f7d5886a2037eddda0f20699c Mon Sep 17 00:00:00 2001 From: Alejandro Tamayo Castillo Date: Sat, 18 Apr 2026 12:34:52 +0200 Subject: [PATCH 15/33] fix(stability): remaining crash/hang fixes from stability pass (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes out the 6 items still open from the 10-bug stability audit: - auto-create sub-chat row on send when the row is missing (prevents silent UPDATE no-op if renderer races createSubChat → send) - strip CLAUDE_CODE_ENTRYPOINT/CLAUDECODE from inherited env so the dev build launched from a `claude` CLI terminal no longer fails with "Claude Code cannot be launched inside another session" - 90s first-chunk wedge timeout on the SDK stream — aborts and surfaces a STREAM_WEDGE error instead of hanging the UI indefinitely - clearer ENOENT message when the bundled Claude binary is missing, pointing the user at `bun run claude:download` - root AppErrorBoundary + preload try/catch around exposeElectronTRPC, with one-shot auto-reload (10s debounce via sessionStorage) so IPC race crashes recover instead of leaving a black screen - Opus 1M context: map `opus[1m]` to the `opus` CLI shortcut and set ANTHROPIC_BETAS=context-1m-2025-08-07 on the child env so the 1M window actually engages Co-authored-by: Claude Opus 4.7 (1M context) --- src/main/lib/claude/env.ts | 7 ++ src/main/lib/trpc/routers/claude.ts | 94 +++++++++++++++++-- src/preload/index.ts | 19 +++- src/renderer/App.tsx | 41 ++++---- src/renderer/components/ui/error-boundary.tsx | 94 ++++++++++++++++++- 5 files changed, 226 insertions(+), 29 deletions(-) diff --git a/src/main/lib/claude/env.ts b/src/main/lib/claude/env.ts index 0ea2ab0cf..44d4679aa 100644 --- a/src/main/lib/claude/env.ts +++ b/src/main/lib/claude/env.ts @@ -24,6 +24,13 @@ const STRIPPED_ENV_KEYS_BASE = [ "OPENAI_API_KEY", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", + // Prevent "Claude Code cannot be launched inside another session" when the + // dev build is spawned from a `claude` CLI terminal — the CLI sets these + // markers on its environment and they propagate into Electron's process.env. + // We unconditionally strip them here and then re-set CLAUDE_CODE_ENTRYPOINT + // to our own value below (order matters: strip → set). + "CLAUDE_CODE_ENTRYPOINT", + "CLAUDECODE", ] // In dev mode, also strip ANTHROPIC_API_KEY so OAuth token is used instead diff --git a/src/main/lib/trpc/routers/claude.ts b/src/main/lib/trpc/routers/claude.ts index b8c7b00f5..077183c6c 100644 --- a/src/main/lib/trpc/routers/claude.ts +++ b/src/main/lib/trpc/routers/claude.ts @@ -2,6 +2,7 @@ import { observable } from "@trpc/server/observable" import { eq } from "drizzle-orm" import { app, BrowserWindow, safeStorage } from "electron" import * as fs from "fs/promises" +import { existsSync } from "node:fs" import * as os from "os" import path from "path" import { z } from "zod" @@ -897,11 +898,34 @@ export const claudeRouter = router({ const db = getDatabase() // 1. Get existing messages from DB - const existing = db + let existing = db .select() .from(subChats) .where(eq(subChats.id, input.subChatId)) .get() + + // Safety net: if the sub-chat row doesn't exist yet (renderer sent + // `send` before `createSubChat` round-tripped), backfill it instead + // of silently no-op'ing the UPDATE below. + if (!existing) { + console.warn( + `[claude] sub-chat ${input.subChatId} missing on send — auto-creating`, + ) + db.insert(subChats) + .values({ + id: input.subChatId, + chatId: input.chatId, + mode: input.mode, + messages: "[]", + }) + .run() + existing = db + .select() + .from(subChats) + .where(eq(subChats.id, input.subChatId)) + .get() + } + const existingMessages = JSON.parse(existing?.messages || "[]") const existingSessionId = existing?.sessionId || null @@ -1494,7 +1518,33 @@ export const claudeRouter = router({ } } - const resolvedModel = finalCustomConfig?.model || input.model + const rawResolvedModel = finalCustomConfig?.model || input.model + // Opus 1M: the UI exposes `opus[1m]` as a distinct model, but the + // Claude CLI only understands the `opus` shortcut. To opt into the + // 1M context window we strip the `[1m]` suffix for the model field + // and enable the matching beta via ANTHROPIC_BETAS on the child env. + const isOpus1M = rawResolvedModel === "opus[1m]" + const resolvedModel = isOpus1M ? "opus" : rawResolvedModel + if (isOpus1M) { + const envAsRecord = finalEnv as Record + const existingBetas = envAsRecord.ANTHROPIC_BETAS + const betaSlug = "context-1m-2025-08-07" + const merged = existingBetas + ? Array.from( + new Set([ + ...existingBetas + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + betaSlug, + ]), + ).join(",") + : betaSlug + envAsRecord.ANTHROPIC_BETAS = merged + console.log( + `[claude] Opus 1M context enabled — ANTHROPIC_BETAS=${merged}`, + ) + } // DEBUG: If using Ollama, test if it's actually responding if (isUsingOllama && finalCustomConfig) { @@ -2038,6 +2088,21 @@ ${prompt} // Plan mode: track ExitPlanMode to stop after plan is complete let exitPlanModeToolCallId: string | null = null + // Stream wedge recovery: abort if no first chunk arrives within 90s. + // SDK hangs have been observed with no crash/no error — detect them + // instead of letting the UI sit on a pending stream forever. + let streamWedged = false + const WEDGE_TIMEOUT_MS = 90_000 + const wedgeTimer = setTimeout(() => { + if (!firstMessageReceived) { + streamWedged = true + console.error( + `[claude] Stream wedged — no data in ${WEDGE_TIMEOUT_MS / 1000}s, aborting`, + ) + abortController.abort() + } + }, WEDGE_TIMEOUT_MS) + if (isUsingOllama) { console.log(`[Ollama] ===== STARTING STREAM ITERATION =====`) console.log(`[Ollama] Model: ${finalCustomConfig?.model}`) @@ -2094,6 +2159,7 @@ ${prompt} // Warn if SDK initialization is slow (MCP delay) if (!firstMessageReceived) { firstMessageReceived = true + clearTimeout(wedgeTimer) const timeToFirstMessage = Date.now() - streamIterationStart if (isUsingOllama) { console.log( @@ -2423,6 +2489,8 @@ ${prompt} } } + clearTimeout(wedgeTimer) + // Warn if stream yielded no messages (offline mode issue) const streamDuration = Date.now() - streamIterationStart if (isUsingOllama) { @@ -2469,6 +2537,7 @@ ${prompt} } } catch (streamError) { // This catches errors during streaming (like process exit) + clearTimeout(wedgeTimer) const err = streamError as Error const stderrOutput = stderrLines.join("\n") @@ -2496,7 +2565,10 @@ ${prompt} "No conversation found with session ID", ) - if (isSessionNotFound) { + if (streamWedged) { + errorContext = `Claude stream wedged — no data received in ${WEDGE_TIMEOUT_MS / 1000}s. Try again.` + errorCategory = "STREAM_WEDGE" + } else if (isSessionNotFound) { // Clear the invalid session ID from database so next attempt starts fresh console.log( `[claude] Session not found - clearing invalid sessionId from database`, @@ -2512,8 +2584,15 @@ ${prompt} errorContext = "Claude Code process crashed" errorCategory = "PROCESS_CRASH" } else if (err.message?.includes("ENOENT")) { - errorContext = "Required executable not found in PATH" - errorCategory = "EXECUTABLE_NOT_FOUND" + // If the bundled Claude binary is missing, surface a clear + // recovery path instead of a generic PATH error. + if (!existsSync(claudeBinaryPath)) { + errorContext = `Claude binary not found at ${claudeBinaryPath}. Run \`bun run claude:download\` and restart the app.` + errorCategory = "CLAUDE_BINARY_MISSING" + } else { + errorContext = "Required executable not found in PATH" + errorCategory = "EXECUTABLE_NOT_FOUND" + } } else if ( err.message?.includes("authentication") || err.message?.includes("401") @@ -2564,8 +2643,9 @@ ${prompt} } } - // Send error with stderr output to frontend (only if not aborted by user) - if (!abortController.signal.aborted) { + // Send error with stderr output to frontend (only if not aborted by user). + // Wedge-triggered aborts are NOT user aborts — surface them. + if (!abortController.signal.aborted || streamWedged) { safeEmit({ type: "error", errorText: stderrOutput diff --git a/src/preload/index.ts b/src/preload/index.ts index 6744f93bc..0787765f1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -8,8 +8,23 @@ if (process.env.NODE_ENV === "production") { }) } -// Expose tRPC IPC bridge for type-safe communication -exposeElectronTRPC() +// Expose tRPC IPC bridge for type-safe communication. +// Guard against a race where exposeElectronTRPC throws during preload boot +// (observed as a black screen crash). Surface the error to the renderer via +// a flag the AppErrorBoundary reads on mount, so we can recover instead of +// rendering a blank window. +try { + exposeElectronTRPC() +} catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error("[preload] exposeElectronTRPC failed:", err) + try { + contextBridge.exposeInMainWorld("__ipcBootError", message) + } catch { + // If even the contextBridge call fails, nothing more we can do here — + // the renderer will still show its error boundary on the first React crash. + } +} // Expose webUtils for file path access in drag and drop contextBridge.exposeInMainWorld("webUtils", { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 72fa8d406..b4b29bcf9 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2,6 +2,7 @@ import { Provider as JotaiProvider, useAtomValue, useSetAtom } from "jotai" import { ThemeProvider, useTheme } from "next-themes" import { useEffect, useMemo } from "react" import { Toaster } from "sonner" +import { AppErrorBoundary } from "./components/ui/error-boundary" import { TooltipProvider } from "./components/ui/tooltip" import { TRPCProvider } from "./contexts/TRPCProvider" import { WindowProvider, getInitialWindowParams } from "./contexts/WindowContext" @@ -202,24 +203,26 @@ export function App() { }, []) return ( - - - - - - -
- -
- -
-
-
-
-
-
+ + + + + + + +
+ +
+ +
+
+
+
+
+
+
) } diff --git a/src/renderer/components/ui/error-boundary.tsx b/src/renderer/components/ui/error-boundary.tsx index e55945877..cb1049ce9 100644 --- a/src/renderer/components/ui/error-boundary.tsx +++ b/src/renderer/components/ui/error-boundary.tsx @@ -1,4 +1,4 @@ -import { Component, type ReactNode } from "react" +import { Component, type ErrorInfo, type ReactNode } from "react" import { AlertCircle } from "lucide-react" import { Button } from "./button" @@ -60,3 +60,95 @@ export class ViewerErrorBoundary extends Component< return this.props.children } } + +interface AppErrorBoundaryState { + hasError: boolean + error: Error | null + ipcBootError: string | null +} + +// Key used to remember a recent auto-reload so a crash loop doesn't infinite-reload. +const AUTO_RELOAD_FLAG = "app:error-boundary:auto-reloaded-at" +const AUTO_RELOAD_WINDOW_MS = 10_000 + +// Root-level error boundary. Catches renderer crashes that would otherwise +// leave the user on a black screen (e.g. from a throwing top-level component +// or a missing IPC bridge during preload boot). Auto-reloads once within a +// short window; subsequent crashes show a manual Reload button instead of +// looping. +export class AppErrorBoundary extends Component< + { children: ReactNode }, + AppErrorBoundaryState +> { + constructor(props: { children: ReactNode }) { + super(props) + const ipcBootError = + typeof window !== "undefined" + ? ((window as unknown as { __ipcBootError?: string }).__ipcBootError ?? + null) + : null + this.state = { + hasError: Boolean(ipcBootError), + error: ipcBootError ? new Error(ipcBootError) : null, + ipcBootError, + } + } + + static getDerivedStateFromError(error: Error): Partial { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("[AppErrorBoundary] Root crash:", error, errorInfo) + this.maybeAutoReload() + } + + componentDidMount() { + if (this.state.ipcBootError) { + console.error( + "[AppErrorBoundary] IPC bridge failed to boot:", + this.state.ipcBootError, + ) + this.maybeAutoReload() + } + } + + private maybeAutoReload() { + try { + const last = Number(sessionStorage.getItem(AUTO_RELOAD_FLAG) || 0) + if (!last || Date.now() - last > AUTO_RELOAD_WINDOW_MS) { + sessionStorage.setItem(AUTO_RELOAD_FLAG, String(Date.now())) + console.warn("[AppErrorBoundary] Auto-reloading once to recover") + window.location.reload() + } + } catch { + // sessionStorage unavailable (e.g. in a sandbox) — skip auto-reload + } + } + + handleReload = () => { + window.location.reload() + } + + render() { + if (!this.state.hasError) return this.props.children + + const message = + this.state.ipcBootError || + this.state.error?.message || + "An unexpected error occurred." + + return ( +
+ +

Something went wrong

+

+ {message} +

+ +
+ ) + } +} From 39e1869787fb527658cba20b3d1f38afdf58292a Mon Sep 17 00:00:00 2001 From: Alejandro Tamayo Castillo Date: Sat, 18 Apr 2026 13:34:23 +0200 Subject: [PATCH 16/33] fix: listener leaks, abort sessions on delete, bump diff viewer (#19) - Add { once: true } to abort listeners in ipc/acp/remote chat transports to prevent AbortSignal listener accumulation. - Add abortClaudeSessionsForSubChats() helper and call it from projects.delete, chats.delete, and chats.deleteSubChat so in-flight Claude sessions are aborted before their workspace is removed. - Bump @pierre/diffs to 1.1.0-beta.18. Co-authored-by: Claude Opus 4.7 (1M context) --- bun.lock | 50 +++++++++------- package.json | 2 +- src/main/lib/trpc/routers/chats.ts | 13 +++++ src/main/lib/trpc/routers/claude.ts | 12 ++++ src/main/lib/trpc/routers/projects.ts | 26 ++++++++- .../features/agents/lib/acp-chat-transport.ts | 58 ++++++++++--------- .../features/agents/lib/ipc-chat-transport.ts | 24 ++++---- .../agents/lib/remote-chat-transport.ts | 14 +++-- 8 files changed, 133 insertions(+), 66 deletions(-) diff --git a/bun.lock b/bun.lock index 3702d91fd..1a2becaba 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,7 @@ "@mcpc-tech/acp-ai-provider": "^0.2.4", "@modelcontextprotocol/sdk": "^1.29.0", "@monaco-editor/react": "^4.7.0", - "@pierre/diffs": "^1.0.10", + "@pierre/diffs": "1.1.0-beta.18", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", @@ -428,7 +428,9 @@ "@opentelemetry/sql-common": ["@opentelemetry/sql-common@0.41.2", "", { "dependencies": { "@opentelemetry/core": "^2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0" } }, "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ=="], - "@pierre/diffs": ["@pierre/diffs@1.0.10", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "^3.0.0", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-ahkpfS30NfaB+PBxnf0/Mc20ySBRTQmM28a7Ojpd0UZixmTyhGhJfBFjvmhX8dSzR22lB3h3OIMMxpB4yYTIOQ=="], + "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.18", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-7ZF3YD9fxdbYsPnltz5cUqHacN7ztp8RX/fJLxwv8wIEORpP4+7dHz1h/qx3o4EW2xUrIhmbM8ImywLasB787Q=="], + + "@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], @@ -618,9 +620,9 @@ "@sentry/opentelemetry": ["@sentry/opentelemetry@10.34.0", "", { "dependencies": { "@sentry/core": "10.34.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0 || ^2.2.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0 || ^2.2.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0 || ^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0" } }, "sha512-uKuULBOmdVu3bYdD8doMLqKgN0PP3WWtI7Shu11P9PVrhSNT4U9yM9Z6v1aFlQcbrgyg3LynZuXs8lyjt90UbA=="], - "@shikijs/core": ["@shikijs/core@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA=="], + "@shikijs/core": ["@shikijs/core@1.29.2", "", { "dependencies": { "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ=="], - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-ATwv86xlbmfD9n9gKRiwuPpWgPENAWCLwYCGz9ugTJlsO2kOzhOkvoyV/UD+tJ0uT7YRyD530x6ugNSffmvIiQ=="], + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "oniguruma-to-es": "^2.2.0" } }, "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A=="], "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1" } }, "sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA=="], @@ -1766,7 +1768,7 @@ "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], - "oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="], + "oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], "ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], @@ -1924,9 +1926,9 @@ "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], - "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + "regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="], - "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + "regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="], "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], @@ -2358,10 +2360,6 @@ "@sentry/node/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "@shikijs/core/@shikijs/types": ["@shikijs/types@3.21.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA=="], - - "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.21.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA=="], - "@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA=="], "@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.22.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg=="], @@ -2460,10 +2458,6 @@ "roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], - "shiki/@shikijs/core": ["@shikijs/core@1.29.2", "", { "dependencies": { "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ=="], - - "shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "oniguruma-to-es": "^2.2.0" } }, "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A=="], - "streamdown/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], "streamdown/tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], @@ -2526,6 +2520,10 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@git-diff-view/shiki/shiki/@shikijs/core": ["@shikijs/core@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA=="], + + "@git-diff-view/shiki/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-ATwv86xlbmfD9n9gKRiwuPpWgPENAWCLwYCGz9ugTJlsO2kOzhOkvoyV/UD+tJ0uT7YRyD530x6ugNSffmvIiQ=="], + "@git-diff-view/shiki/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-OYknTCct6qiwpQDqDdf3iedRdzj6hFlOPv5hMvI+hkWfCKs5mlJ4TXziBG9nyabLwGulrUjHiCq3xCspSzErYQ=="], "@git-diff-view/shiki/shiki/@shikijs/langs": ["@shikijs/langs@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0" } }, "sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA=="], @@ -2542,6 +2540,10 @@ "@mcpc-tech/acp-ai-provider/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], + "@pierre/diffs/shiki/@shikijs/core": ["@shikijs/core@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA=="], + + "@pierre/diffs/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-ATwv86xlbmfD9n9gKRiwuPpWgPENAWCLwYCGz9ugTJlsO2kOzhOkvoyV/UD+tJ0uT7YRyD530x6ugNSffmvIiQ=="], + "@pierre/diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-OYknTCct6qiwpQDqDdf3iedRdzj6hFlOPv5hMvI+hkWfCKs5mlJ4TXziBG9nyabLwGulrUjHiCq3xCspSzErYQ=="], "@pierre/diffs/shiki/@shikijs/langs": ["@shikijs/langs@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0" } }, "sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA=="], @@ -2600,14 +2602,16 @@ "rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], - "shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], - "tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "zip-stream/archiver-utils/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "@git-diff-view/shiki/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="], + + "@pierre/diffs/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="], + "app-builder-lib/@electron/rebuild/node-gyp/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen": ["make-fetch-happen@10.2.1", "", { "dependencies": { "agentkeepalive": "^4.2.1", "cacache": "^16.1.0", "http-cache-semantics": "^4.1.0", "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", "is-lambda": "^1.0.1", "lru-cache": "^7.7.1", "minipass": "^3.1.6", "minipass-collect": "^1.0.2", "minipass-fetch": "^2.0.3", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^0.6.3", "promise-retry": "^2.0.1", "socks-proxy-agent": "^7.0.0", "ssri": "^9.0.0" } }, "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w=="], @@ -2622,14 +2626,18 @@ "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - "shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="], - - "shiki/@shikijs/engine-javascript/oniguruma-to-es/regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="], - "tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "zip-stream/archiver-utils/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "@git-diff-view/shiki/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "@git-diff-view/shiki/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "@pierre/diffs/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "@pierre/diffs/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + "app-builder-lib/@electron/rebuild/node-gyp/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen/cacache": ["cacache@16.1.3", "", { "dependencies": { "@npmcli/fs": "^2.1.0", "@npmcli/move-file": "^2.0.0", "chownr": "^2.0.0", "fs-minipass": "^2.1.0", "glob": "^8.0.1", "infer-owner": "^1.0.4", "lru-cache": "^7.7.1", "minipass": "^3.1.6", "minipass-collect": "^1.0.2", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "mkdirp": "^1.0.4", "p-map": "^4.0.0", "promise-inflight": "^1.0.1", "rimraf": "^3.0.2", "ssri": "^9.0.0", "tar": "^6.1.11", "unique-filename": "^2.0.0" } }, "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ=="], diff --git a/package.json b/package.json index 18302342f..a1533fa4d 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "@mcpc-tech/acp-ai-provider": "^0.2.4", "@modelcontextprotocol/sdk": "^1.29.0", "@monaco-editor/react": "^4.7.0", - "@pierre/diffs": "^1.0.10", + "@pierre/diffs": "1.1.0-beta.18", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", diff --git a/src/main/lib/trpc/routers/chats.ts b/src/main/lib/trpc/routers/chats.ts index a699b445d..97fe1975d 100644 --- a/src/main/lib/trpc/routers/chats.ts +++ b/src/main/lib/trpc/routers/chats.ts @@ -27,6 +27,7 @@ import { applyRollbackStash } from "../../git/stash" import { checkInternetConnection, checkOllamaStatus } from "../../ollama" import { terminalManager } from "../../terminal/manager" import { publicProcedure, router } from "../index" +import { abortClaudeSessionsForSubChats } from "./claude" type WorktreeSetupFailurePayload = { kind: "create-failed" | "setup-failed" @@ -641,6 +642,17 @@ export const chatsRouter = router({ // Get chat before deletion const chat = db.select().from(chats).where(eq(chats.id, input.id)).get() + // Abort any active Claude sessions for this chat's sub-chats before cascade delete + const subChatIds = db + .select({ id: subChats.id }) + .from(subChats) + .where(eq(subChats.chatId, input.id)) + .all() + .map((row) => row.id) + if (subChatIds.length > 0) { + abortClaudeSessionsForSubChats(subChatIds) + } + // Cleanup worktree if it was created (has branch = was a real worktree, not just project path) if (chat?.worktreePath && chat?.branch) { const project = db @@ -1040,6 +1052,7 @@ export const chatsRouter = router({ .input(z.object({ id: z.string() })) .mutation(({ input }) => { const db = getDatabase() + abortClaudeSessionsForSubChats([input.id]) return db .delete(subChats) .where(eq(subChats.id, input.id)) diff --git a/src/main/lib/trpc/routers/claude.ts b/src/main/lib/trpc/routers/claude.ts index 077183c6c..56d1e30e8 100644 --- a/src/main/lib/trpc/routers/claude.ts +++ b/src/main/lib/trpc/routers/claude.ts @@ -277,6 +277,18 @@ export function abortAllClaudeSessions(): void { activeSessions.clear() } +/** Abort Claude sessions for a specific set of sub-chat ids */ +export function abortClaudeSessionsForSubChats(subChatIds: string[]): void { + for (const subChatId of subChatIds) { + const controller = activeSessions.get(subChatId) + if (controller) { + console.log(`[claude] Aborting session ${subChatId} (workspace removed)`) + controller.abort() + activeSessions.delete(subChatId) + } + } +} + // In-memory cache of working MCP server names (resets on app restart) // Key: "scope::serverName" where scope is "__global__" or projectPath // Value: true if working (has tools), false if failed diff --git a/src/main/lib/trpc/routers/projects.ts b/src/main/lib/trpc/routers/projects.ts index 106c6d367..6a95a595c 100644 --- a/src/main/lib/trpc/routers/projects.ts +++ b/src/main/lib/trpc/routers/projects.ts @@ -1,7 +1,7 @@ import { z } from "zod" import { router, publicProcedure } from "../index" -import { getDatabase, projects } from "../../db" -import { eq, desc } from "drizzle-orm" +import { getDatabase, projects, chats, subChats } from "../../db" +import { eq, desc, inArray } from "drizzle-orm" import { dialog, BrowserWindow, app } from "electron" import { basename, join } from "path" import { exec } from "node:child_process" @@ -12,6 +12,7 @@ import { extname } from "node:path" import { getGitRemoteInfo } from "../../git" import { trackProjectOpened } from "../../analytics" import { getLaunchDirectory } from "../../cli" +import { abortClaudeSessionsForSubChats } from "./claude" const execAsync = promisify(exec) @@ -192,6 +193,27 @@ export const projectsRouter = router({ .input(z.object({ id: z.string() })) .mutation(({ input }) => { const db = getDatabase() + + const chatIds = db + .select({ id: chats.id }) + .from(chats) + .where(eq(chats.projectId, input.id)) + .all() + .map((row) => row.id) + + if (chatIds.length > 0) { + const subChatIds = db + .select({ id: subChats.id }) + .from(subChats) + .where(inArray(subChats.chatId, chatIds)) + .all() + .map((row) => row.id) + + if (subChatIds.length > 0) { + abortClaudeSessionsForSubChats(subChatIds) + } + } + return db .delete(projects) .where(eq(projects.id, input.id)) diff --git a/src/renderer/features/agents/lib/acp-chat-transport.ts b/src/renderer/features/agents/lib/acp-chat-transport.ts index 9e42e91f4..bc4c13d9e 100644 --- a/src/renderer/features/agents/lib/acp-chat-transport.ts +++ b/src/renderer/features/agents/lib/acp-chat-transport.ts @@ -268,35 +268,39 @@ export class ACPChatTransport implements ChatTransport { }, ) - options.abortSignal?.addEventListener("abort", () => { - // Start server-side cancellation first so the router still has - // active run ownership when processing cancel(runId). - const cancelPromise = trpcClient.codex.cancel - .mutate({ subChatId: this.config.subChatId, runId }) - .catch(() => { - // No-op - }) - - // Keep stop UX immediate in the client. - try { - controller.close() - } catch { - // Stream already closed - } - - // Keep subscription alive briefly so server-side onFinish can persist - // interrupted response state before cleanup unsubscribe runs. - void (async () => { + options.abortSignal?.addEventListener( + "abort", + () => { + // Start server-side cancellation first so the router still has + // active run ownership when processing cancel(runId). + const cancelPromise = trpcClient.codex.cancel + .mutate({ subChatId: this.config.subChatId, runId }) + .catch(() => { + // No-op + }) + + // Keep stop UX immediate in the client. try { - await cancelPromise - } finally { - clearForcedUnsubscribeTimer() - forcedUnsubscribeTimer = setTimeout(() => { - safeUnsubscribe() - }, 10000) + controller.close() + } catch { + // Stream already closed } - })() - }) + + // Keep subscription alive briefly so server-side onFinish can persist + // interrupted response state before cleanup unsubscribe runs. + void (async () => { + try { + await cancelPromise + } finally { + clearForcedUnsubscribeTimer() + forcedUnsubscribeTimer = setTimeout(() => { + safeUnsubscribe() + }, 10000) + } + })() + }, + { once: true }, + ) }, }) } diff --git a/src/renderer/features/agents/lib/ipc-chat-transport.ts b/src/renderer/features/agents/lib/ipc-chat-transport.ts index 216147f2b..20f004295 100644 --- a/src/renderer/features/agents/lib/ipc-chat-transport.ts +++ b/src/renderer/features/agents/lib/ipc-chat-transport.ts @@ -486,16 +486,20 @@ export class IPCChatTransport implements ChatTransport { ) // Handle abort - options.abortSignal?.addEventListener("abort", () => { - console.log(`[SD] R:ABORT sub=${subId} n=${chunkCount} last=${lastChunkType}`) - sub.unsubscribe() - // trpcClient.claude.cancel.mutate({ subChatId: this.config.subChatId }) - try { - controller.close() - } catch { - // Already closed - } - }) + options.abortSignal?.addEventListener( + "abort", + () => { + console.log(`[SD] R:ABORT sub=${subId} n=${chunkCount} last=${lastChunkType}`) + sub.unsubscribe() + // trpcClient.claude.cancel.mutate({ subChatId: this.config.subChatId }) + try { + controller.close() + } catch { + // Already closed + } + }, + { once: true }, + ) }, }) } diff --git a/src/renderer/features/agents/lib/remote-chat-transport.ts b/src/renderer/features/agents/lib/remote-chat-transport.ts index 3e21945c4..920345a49 100644 --- a/src/renderer/features/agents/lib/remote-chat-transport.ts +++ b/src/renderer/features/agents/lib/remote-chat-transport.ts @@ -213,11 +213,15 @@ export class RemoteChatTransport implements ChatTransport { // Handle abort if (abortSignal) { - abortSignal.addEventListener("abort", () => { - console.log(`[RemoteTransport] ABORT sub=${subId} chunks=${chunkCount}`) - streamDone = true - cleanup() - }) + abortSignal.addEventListener( + "abort", + () => { + console.log(`[RemoteTransport] ABORT sub=${subId} chunks=${chunkCount}`) + streamDone = true + cleanup() + }, + { once: true }, + ) } const cleanup = () => { From 822cbf44eb5556cc5138d8a484c2c17dcb646e40 Mon Sep 17 00:00:00 2001 From: Alejandro Tamayo Castillo Date: Sat, 18 Apr 2026 14:01:18 +0200 Subject: [PATCH 17/33] fix: remote-ahead push dialog, PR cache invalidation, tool registry, archive popover (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Addresses four bugs from the "happy hippo" stability pass that weren't yet applied on `dev`. Two of the six originally reported fixes (changelog URL, PostHog SDK) were already in the codebase; this PR ships the remaining four. ### 1. Pull & Push dialog for remote-ahead errors When `git push` fails because the remote branch has new commits, the user now sees a dialog offering a one-click **Pull & Push** (auto-stash + rebase, then retry push) instead of a raw `[rejected] non-fast-forward` toast. - Backend detects the error via a new `isNonFastForwardPushError` helper and re-throws it with a `REMOTE_AHEAD:` prefix so the renderer can distinguish it from generic push failures. - `usePushAction` routes `REMOTE_AHEAD:` errors to a new `PullPushDialog` and keeps the toast path for every other error. - Dialog is rendered in both push consumers: `DiffSidebarHeader` and `ActiveChat`. ### 2. PR status cache invalidation on branch switch Switching branches via the branch selector in `ChangesPanelHeader` now invalidates both `changes.getGitHubStatus` and `chats.getPrStatus`, so the PR chip reflects the new branch immediately instead of waiting for the 30s polling interval. ### 3. Missing tool-type registrations Added 14 tool types to `AgentToolRegistry` that were falling through to the plain-text fallback: `Skill`, `ScheduleWakeup`, `EnterPlanMode`, `CronCreate`/`CronDelete`/`CronList`, `Monitor`, `PushNotification`, `TaskOutput`/`TaskStop`, `EnterWorktree`/`ExitWorktree`, `RemoteTrigger`, `ToolSearch`. Each renders with a proper icon, pending/completed title, and a compact subtitle derived from the tool input. `AskUserQuestion` is intentionally left out since it still uses its dedicated rich renderer. ### 4. Archive popover eager fetches `archive-popover.tsx` no longer fires `trpc.projects.list` or `useRemoteArchivedChats` until the popover opens. `useRemoteArchivedChats` accepts a new `enabled` argument (defaults to `true` to keep existing callers unchanged). ## Test plan - [ ] Create a diverging commit on `origin/`, commit locally, press **Push** → dialog appears; **Pull & Push** completes in one click, auto-stashes/restores any local changes. - [ ] Disconnect network and push → plain toast (no dialog regression for unrelated errors). - [ ] Open a chat with an open PR, switch branches via the branch dropdown → PR chip refreshes immediately. - [ ] Trigger a Claude session that uses a Skill and/or a ScheduleWakeup → both render with icons and labels instead of plain text. - [ ] Cold app start → confirm neither `projects.list` nor the remote archived-chats query fires until the archive popover is opened. - [ ] Verify `bun run build` still passes (no new type errors in touched files). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- src/main/lib/git/git-operations.ts | 31 ++- src/main/lib/git/git-utils.ts | 11 + .../features/agents/main/active-chat.tsx | 3 +- .../agents/ui/agent-tool-registry.tsx | 198 ++++++++++++++++++ .../features/agents/ui/archive-popover.tsx | 11 +- .../changes-panel-header.tsx | 4 + .../diff-sidebar-header.tsx | 5 +- .../changes/components/pull-push-dialog.tsx | 82 ++++++++ .../features/changes/hooks/use-push-action.ts | 25 ++- src/renderer/lib/hooks/use-remote-chats.ts | 4 +- 10 files changed, 355 insertions(+), 19 deletions(-) create mode 100644 src/renderer/features/changes/components/pull-push-dialog.tsx diff --git a/src/main/lib/git/git-operations.ts b/src/main/lib/git/git-operations.ts index e027077a4..d961956dc 100644 --- a/src/main/lib/git/git-operations.ts +++ b/src/main/lib/git/git-operations.ts @@ -2,7 +2,11 @@ import { shell } from "electron"; import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../trpc"; -import { isUpstreamMissingError } from "./git-utils"; +import { + isUpstreamMissingError, + isNonFastForwardPushError, + REMOTE_AHEAD_ERROR_PREFIX, +} from "./git-utils"; import { assertRegisteredWorktree } from "./security"; import { fetchGitHubPRStatus } from "./github"; import { gitCache } from "./cache"; @@ -247,13 +251,24 @@ export const createGitOperationsRouter = () => { const git = createGitForNetwork(input.worktreePath); const hasUpstream = await hasUpstreamBranch(git); - if (input.setUpstream && !hasUpstream) { - const branch = await git.revparse(["--abbrev-ref", "HEAD"]); - await withLockRetry(input.worktreePath, () => - git.push(["--set-upstream", "origin", branch.trim()]) - ); - } else { - await withLockRetry(input.worktreePath, () => git.push()); + try { + if (input.setUpstream && !hasUpstream) { + const branch = await git.revparse(["--abbrev-ref", "HEAD"]); + await withLockRetry(input.worktreePath, () => + git.push(["--set-upstream", "origin", branch.trim()]) + ); + } else { + await withLockRetry(input.worktreePath, () => git.push()); + } + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + if (isNonFastForwardPushError(message)) { + throw new Error( + `${REMOTE_AHEAD_ERROR_PREFIX} Remote has new commits. Pull with rebase and retry.` + ); + } + throw error; } await git.fetch(); invalidateGitStateCaches(input.worktreePath); diff --git a/src/main/lib/git/git-utils.ts b/src/main/lib/git/git-utils.ts index 701c2e62f..14a0192a4 100644 --- a/src/main/lib/git/git-utils.ts +++ b/src/main/lib/git/git-utils.ts @@ -8,3 +8,14 @@ export function isUpstreamMissingError(message: string): boolean { message.includes("couldn't find remote ref") ); } + +export function isNonFastForwardPushError(message: string): boolean { + return ( + message.includes("[rejected]") || + message.includes("non-fast-forward") || + message.includes("fetch first") || + message.includes("Updates were rejected") + ); +} + +export const REMOTE_AHEAD_ERROR_PREFIX = "REMOTE_AHEAD:"; diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index c2f9a40c4..1c1cae5d4 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -6187,7 +6187,7 @@ Make sure to preserve all functionality from both branches when resolving confli onRefresh: handleCommitChangesRefresh, }) - const { push: pushBranch, isPending: isPushing } = usePushAction({ + const { push: pushBranch, isPending: isPushing, dialog: pushDialog } = usePushAction({ worktreePath, hasUpstream: gitStatus?.hasUpstream ?? true, onSuccess: handleCommitChangesRefresh, @@ -7450,6 +7450,7 @@ Make sure to preserve all functionality from both branches when resolving confli return ( + {pushDialog} {/* File Search Dialog (Cmd+P) */} {worktreePath && ( = { }, variant: "collapsible", }, + + "tool-Skill": { + icon: SparklesIcon, + title: (part) => { + const isPending = + part.state !== "output-available" && part.state !== "output-error" + const name = part.input?.skill || part.input?.name || "skill" + return isPending ? `Running ${name}` : `Ran ${name}` + }, + subtitle: (part) => { + const args = part.input?.args + if (!args) return "" + const text = typeof args === "string" ? args : JSON.stringify(args) + return text.length > 50 ? text.slice(0, 47) + "..." : text + }, + variant: "simple", + }, + + "tool-ScheduleWakeup": { + icon: Clock, + title: (part) => { + const isPending = + part.state !== "output-available" && part.state !== "output-error" + return isPending ? "Scheduling wake-up" : "Scheduled wake-up" + }, + subtitle: (part) => { + const seconds = part.input?.delaySeconds + if (typeof seconds !== "number") return "" + if (seconds < 60) return `${seconds}s` + if (seconds < 3600) return `${Math.round(seconds / 60)}m` + return `${Math.round(seconds / 360) / 10}h` + }, + variant: "simple", + }, + + "tool-EnterPlanMode": { + icon: PlanningIcon, + title: (part) => { + const { isPending } = getToolStatus(part) + return isPending ? "Entering plan mode" : "Planning" + }, + subtitle: () => "", + variant: "simple", + }, + + "tool-CronCreate": { + icon: Clock, + title: (part) => { + const isPending = + part.state !== "output-available" && part.state !== "output-error" + return isPending ? "Creating schedule" : "Created schedule" + }, + subtitle: (part) => { + const cron = part.input?.cron || part.input?.schedule || "" + return cron.length > 40 ? cron.slice(0, 37) + "..." : cron + }, + variant: "simple", + }, + + "tool-CronDelete": { + icon: Clock, + title: (part) => { + const isPending = + part.state !== "output-available" && part.state !== "output-error" + return isPending ? "Deleting schedule" : "Deleted schedule" + }, + subtitle: (part) => { + const id = part.input?.id + return id ? `#${id}` : "" + }, + variant: "simple", + }, + + "tool-CronList": { + icon: Clock, + title: (part) => { + const isPending = + part.state !== "output-available" && part.state !== "output-error" + return isPending ? "Listing schedules" : "Listed schedules" + }, + subtitle: () => "", + variant: "simple", + }, + + "tool-Monitor": { + icon: Activity, + title: (part) => { + const isPending = + part.state !== "output-available" && part.state !== "output-error" + return isPending ? "Monitoring" : "Monitored" + }, + subtitle: (part) => { + const cmd = part.input?.command || "" + return cmd.length > 40 ? cmd.slice(0, 37) + "..." : cmd + }, + variant: "simple", + }, + + "tool-PushNotification": { + icon: Bell, + title: (part) => { + const isPending = + part.state !== "output-available" && part.state !== "output-error" + return isPending ? "Sending notification" : "Sent notification" + }, + subtitle: (part) => { + const title = part.input?.title || part.input?.message || "" + return title.length > 40 ? title.slice(0, 37) + "..." : title + }, + variant: "simple", + }, + + "tool-TaskOutput": { + icon: FileText, + title: (part) => { + const isPending = + part.state !== "output-available" && part.state !== "output-error" + return isPending ? "Getting task output" : "Got task output" + }, + subtitle: (part) => { + const id = part.input?.taskId || part.input?.id + return id ? `#${id}` : "" + }, + variant: "simple", + }, + + "tool-TaskStop": { + icon: Square, + title: (part) => { + const isPending = + part.state !== "output-available" && part.state !== "output-error" + return isPending ? "Stopping task" : "Stopped task" + }, + subtitle: (part) => { + const id = part.input?.taskId || part.input?.id + return id ? `#${id}` : "" + }, + variant: "simple", + }, + + "tool-EnterWorktree": { + icon: GitBranch, + title: (part) => { + const isPending = + part.state !== "output-available" && part.state !== "output-error" + return isPending ? "Entering worktree" : "Entered worktree" + }, + subtitle: (part) => { + const branch = part.input?.branch || part.input?.path || "" + return branch.length > 40 ? branch.slice(0, 37) + "..." : branch + }, + variant: "simple", + }, + + "tool-ExitWorktree": { + icon: GitBranch, + title: (part) => { + const isPending = + part.state !== "output-available" && part.state !== "output-error" + return isPending ? "Leaving worktree" : "Left worktree" + }, + subtitle: () => "", + variant: "simple", + }, + + "tool-RemoteTrigger": { + icon: Zap, + title: (part) => { + const isPending = + part.state !== "output-available" && part.state !== "output-error" + return isPending ? "Triggering remote" : "Triggered remote" + }, + subtitle: (part) => { + const name = part.input?.name || part.input?.trigger || "" + return name.length > 40 ? name.slice(0, 37) + "..." : name + }, + variant: "simple", + }, + + "tool-ToolSearch": { + icon: SearchIcon, + title: (part) => { + const isPending = + part.state !== "output-available" && part.state !== "output-error" + return isPending ? "Finding tools" : "Found tools" + }, + subtitle: (part) => { + const query = part.input?.query || "" + return query.length > 40 ? query.slice(0, 37) + "..." : query + }, + variant: "simple", + }, } // ============================================================================ diff --git a/src/renderer/features/agents/ui/archive-popover.tsx b/src/renderer/features/agents/ui/archive-popover.tsx index 1842a293a..3aa5f345f 100644 --- a/src/renderer/features/agents/ui/archive-popover.tsx +++ b/src/renderer/features/agents/ui/archive-popover.tsx @@ -253,14 +253,17 @@ export const ArchivePopover = memo(function ArchivePopover({ trigger }: ArchiveP }, ) - // Remote archived chats (always fetch) - const { data: remoteArchivedChats, isLoading: isRemoteLoading } = useRemoteArchivedChats() + // Remote archived chats (lazy — only when popover is open) + const { data: remoteArchivedChats, isLoading: isRemoteLoading } = useRemoteArchivedChats(open) // Loading if either is loading const isLoading = isLocalLoading || isRemoteLoading - // Fetch all projects for git info (for local chats) - const { data: projects } = trpc.projects.list.useQuery(undefined) + // Fetch all projects for git info (for local chats) — lazy on open + const { data: projects } = trpc.projects.list.useQuery(undefined, { + enabled: open, + staleTime: 5 * 60 * 1000, + }) // Collect chat IDs for file stats query (only local chats) const archivedChatIds = useMemo(() => { diff --git a/src/renderer/features/changes/components/changes-panel-header/changes-panel-header.tsx b/src/renderer/features/changes/components/changes-panel-header/changes-panel-header.tsx index 743cb5115..e2be67e7c 100644 --- a/src/renderer/features/changes/components/changes-panel-header/changes-panel-header.tsx +++ b/src/renderer/features/changes/components/changes-panel-header/changes-panel-header.tsx @@ -44,6 +44,8 @@ export function ChangesPanelHeader({ const [displayTime, setDisplayTime] = useState(""); const timeoutRef = useRef(null); + const utils = trpc.useUtils(); + const { data: branchData, refetch: refetchBranches } = trpc.changes.getBranches.useQuery( { worktreePath }, { enabled: !!worktreePath }, @@ -59,6 +61,8 @@ export function ChangesPanelHeader({ const checkoutMutation = trpc.changes.checkout.useMutation({ onSuccess: () => { refetchBranches(); + utils.changes.getGitHubStatus.invalidate({ worktreePath }); + utils.chats.getPrStatus.invalidate(); }, }); diff --git a/src/renderer/features/changes/components/diff-sidebar-header/diff-sidebar-header.tsx b/src/renderer/features/changes/components/diff-sidebar-header/diff-sidebar-header.tsx index 994d26935..3d2dd6e66 100644 --- a/src/renderer/features/changes/components/diff-sidebar-header/diff-sidebar-header.tsx +++ b/src/renderer/features/changes/components/diff-sidebar-header/diff-sidebar-header.tsx @@ -183,7 +183,7 @@ export const DiffSidebarHeader = memo(function DiffSidebarHeader({ }, }); - const { push: pushBranch, isPending: isPushPending } = usePushAction({ + const { push: pushBranch, isPending: isPushPending, dialog: pushDialog } = usePushAction({ worktreePath, hasUpstream, onSuccess: onRefresh, @@ -430,6 +430,8 @@ export const DiffSidebarHeader = memo(function DiffSidebarHeader({ : primaryAction; return ( + <> + {pushDialog}
{/* Drag region for window dragging */} {isDesktop && !isFullscreen && ( @@ -961,5 +963,6 @@ export const DiffSidebarHeader = memo(function DiffSidebarHeader({
+ ); }) diff --git a/src/renderer/features/changes/components/pull-push-dialog.tsx b/src/renderer/features/changes/components/pull-push-dialog.tsx new file mode 100644 index 000000000..2bba88790 --- /dev/null +++ b/src/renderer/features/changes/components/pull-push-dialog.tsx @@ -0,0 +1,82 @@ +import { useState } from "react"; +import { toast } from "sonner"; +import { trpc } from "../../../lib/trpc"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogBody, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../../../components/ui/alert-dialog"; + +interface PullPushDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + worktreePath: string | null | undefined; + setUpstream: boolean; + onSuccess?: () => void; +} + +export function PullPushDialog({ + open, + onOpenChange, + worktreePath, + setUpstream, + onSuccess, +}: PullPushDialogProps) { + const [isWorking, setIsWorking] = useState(false); + const pullMutation = trpc.changes.pull.useMutation(); + const pushMutation = trpc.changes.push.useMutation(); + + const handlePullAndPush = async () => { + if (!worktreePath) return; + setIsWorking(true); + try { + await pullMutation.mutateAsync({ worktreePath, autoStash: true }); + await pushMutation.mutateAsync({ worktreePath, setUpstream }); + onSuccess?.(); + onOpenChange(false); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + toast.error(`Pull & push failed: ${message}`); + } finally { + setIsWorking(false); + } + }; + + return ( + + + + Remote has new commits + + Your push was rejected because the remote branch has commits you + don't have locally. + + + +

+ Pull with rebase and push in one step. Any uncommitted changes will + be auto-stashed and restored. +

+
+ + Cancel + { + e.preventDefault(); + void handlePullAndPush(); + }} + disabled={isWorking || !worktreePath} + > + {isWorking ? "Working…" : "Pull & Push"} + + +
+
+ ); +} diff --git a/src/renderer/features/changes/hooks/use-push-action.ts b/src/renderer/features/changes/hooks/use-push-action.ts index ccbe7ba3c..807f9041a 100644 --- a/src/renderer/features/changes/hooks/use-push-action.ts +++ b/src/renderer/features/changes/hooks/use-push-action.ts @@ -1,6 +1,9 @@ -import { useCallback } from "react"; +import { createElement, useCallback, useState, type ReactNode } from "react"; import { toast } from "sonner"; import { trpc } from "../../../lib/trpc"; +import { PullPushDialog } from "../components/pull-push-dialog"; + +const REMOTE_AHEAD_MARKER = "REMOTE_AHEAD:"; interface UsePushActionOptions { worktreePath?: string | null; @@ -13,11 +16,19 @@ export function usePushAction({ hasUpstream = true, onSuccess, }: UsePushActionOptions) { + const [dialogOpen, setDialogOpen] = useState(false); + const pushMutation = trpc.changes.push.useMutation({ onSuccess: () => { onSuccess?.(); }, - onError: (error) => toast.error(`Push failed: ${error.message}`), + onError: (error) => { + if (error.message.startsWith(REMOTE_AHEAD_MARKER)) { + setDialogOpen(true); + return; + } + toast.error(`Push failed: ${error.message}`); + }, }); const push = useCallback(() => { @@ -28,5 +39,13 @@ export function usePushAction({ pushMutation.mutate({ worktreePath, setUpstream: !hasUpstream }); }, [worktreePath, hasUpstream, pushMutation]); - return { push, isPending: pushMutation.isPending }; + const dialog: ReactNode = createElement(PullPushDialog, { + open: dialogOpen, + onOpenChange: setDialogOpen, + worktreePath, + setUpstream: !hasUpstream, + onSuccess, + }); + + return { push, isPending: pushMutation.isPending, dialog }; } diff --git a/src/renderer/lib/hooks/use-remote-chats.ts b/src/renderer/lib/hooks/use-remote-chats.ts index 008a2d500..76766fc20 100644 --- a/src/renderer/lib/hooks/use-remote-chats.ts +++ b/src/renderer/lib/hooks/use-remote-chats.ts @@ -106,13 +106,13 @@ export function usePrefetchRemoteChat() { /** * Fetch archived remote chats for the selected team */ -export function useRemoteArchivedChats() { +export function useRemoteArchivedChats(enabled: boolean = true) { const teamId = useAtomValue(selectedTeamIdAtom) return useQuery({ queryKey: ["remote-archived-chats", teamId], queryFn: () => remoteApi.getArchivedChats(teamId!), - enabled: !!teamId, + enabled: enabled && !!teamId, staleTime: 5 * 60 * 1000, gcTime: 30 * 60 * 1000, }) From d120bbc4be9546d56da7bdf0215d02ac6dca94b8 Mon Sep 17 00:00:00 2001 From: Alejandro Tamayo Castillo Date: Sat, 18 Apr 2026 14:16:36 +0200 Subject: [PATCH 18/33] chore: hide update + changelog + feedback UI (#21) Comment out all update-checking, download/install, changelog, and Feedback UI entry points so they are invisible to users in this build. Implementation is preserved verbatim behind UPDATES-DISABLED markers for easy reactivation (grep UPDATES-DISABLED to find all sites). Affected surfaces: - macOS app menu "Check for Updates..." item - Main-process auto-updater init, IPC handlers, focus/startup checks - Preload bridge update:* methods + event listeners - Update banner (bottom-left) + useUpdateChecker hook call - Settings -> Beta tab -> Updates section (Early Access + Check Now) - Help popover "What's new" section + changelog fetch - Sidebar Feedback button Co-authored-by: Claude Opus 4.7 (1M context) --- src/main/index.ts | 19 +++++++++++++++- src/main/lib/auto-updater.ts | 22 +++++++++++++++++++ src/preload/index.ts | 3 +++ .../dialogs/settings-tabs/agents-beta-tab.tsx | 10 +++++++-- src/renderer/components/update-banner.tsx | 7 ++++++ .../agents/components/agents-help-popover.tsx | 9 ++++++++ .../features/layout/agents-layout.tsx | 6 +++-- .../features/sidebar/agents-sidebar.tsx | 4 +++- 8 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index d4e008f4c..b1937bbaa 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -630,6 +630,8 @@ if (gotTheLock) { label: "About 1Code", click: () => app.showAboutPanel(), }, + // UPDATES-DISABLED: re-enable to restore "Check for Updates..." menu item + /* { label: updateAvailable ? `Update to v${availableVersion}...` @@ -648,6 +650,7 @@ if (gotTheLock) { } }, }, + */ { type: "separator" }, { label: "Settings...", @@ -867,9 +870,14 @@ if (gotTheLock) { // Set update state and rebuild menu const setUpdateAvailable = (available: boolean, version?: string) => { + // UPDATES-DISABLED: re-enable to restore update menu state updates + void available + void version + /* updateAvailable = available availableVersion = version || null buildMenu() + */ } // Unlock devtools and rebuild menu (called from renderer via IPC) @@ -881,8 +889,10 @@ if (gotTheLock) { } } + // UPDATES-DISABLED: re-enable to restore update state exposure // Expose setUpdateAvailable globally for auto-updater - ;(global as any).__setUpdateAvailable = setUpdateAvailable + // ;(global as any).__setUpdateAvailable = setUpdateAvailable + void setUpdateAvailable // Expose unlockDevTools globally for IPC handler ;(global as any).__unlockDevTools = unlockDevTools @@ -941,6 +951,8 @@ if (gotTheLock) { // Create main window createMainWindow() + // UPDATES-DISABLED: re-enable to restore auto-updater startup + /* // Initialize auto-updater (production only) if (app.isPackaged) { await initAutoUpdater(getAllWindows) @@ -951,6 +963,11 @@ if (gotTheLock) { checkForUpdates(true) }, 5000) } + */ + void initAutoUpdater + void setupFocusUpdateCheck + void checkForUpdates + void downloadUpdate // Warm up MCP cache 3 seconds after startup (background, non-blocking) // This populates the cache so all future sessions can use filtered MCP servers diff --git a/src/main/lib/auto-updater.ts b/src/main/lib/auto-updater.ts index 59c2c0cdf..e0003ed3e 100644 --- a/src/main/lib/auto-updater.ts +++ b/src/main/lib/auto-updater.ts @@ -87,6 +87,10 @@ function sendToAllRenderers(channel: string, data?: unknown) { * Initialize the auto-updater with event handlers and IPC */ export async function initAutoUpdater(getWindows: () => BrowserWindow[]) { + // UPDATES-DISABLED: re-enable to restore update functionality + void getWindows + return + /* getAllWindows = getWindows // Initialize config @@ -181,12 +185,16 @@ export async function initAutoUpdater(getWindows: () => BrowserWindow[]) { registerIpcHandlers() log.info("[AutoUpdater] Initialized with feed URL:", CDN_BASE) + */ } /** * Register IPC handlers for update operations */ function registerIpcHandlers() { + // UPDATES-DISABLED: re-enable to restore update IPC handlers + return + /* // Check for updates ipcMain.handle("update:check", async (_event, force?: boolean) => { if (!app.isPackaged) { @@ -272,6 +280,7 @@ function registerIpcHandlers() { ipcMain.handle("update:get-channel", () => { return getSavedChannel() }) + */ } /** @@ -279,6 +288,10 @@ function registerIpcHandlers() { * @param force - Skip the minimum interval check */ export async function checkForUpdates(force = false) { + // UPDATES-DISABLED: re-enable to restore update check + void force + return Promise.resolve(null) + /* if (!app.isPackaged) { log.info("[AutoUpdater] Skipping update check in dev mode") return Promise.resolve(null) @@ -295,12 +308,16 @@ export async function checkForUpdates(force = false) { lastCheckTime = now return autoUpdater.checkForUpdates() + */ } /** * Start downloading the update */ export async function downloadUpdate() { + // UPDATES-DISABLED: re-enable to restore update download + return false + /* if (!app.isPackaged) { log.info("[AutoUpdater] Skipping download in dev mode") return false @@ -314,6 +331,7 @@ export async function downloadUpdate() { log.error("[AutoUpdater] Download failed:", error) return false } + */ } /** @@ -321,11 +339,15 @@ export async function downloadUpdate() { * This is more natural than checking on an interval */ export function setupFocusUpdateCheck(_getWindows: () => BrowserWindow[]) { + // UPDATES-DISABLED: re-enable to restore focus-based update check + return + /* // Listen for window focus events app.on("browser-window-focus", () => { log.info("[AutoUpdater] Window focused - checking for updates") checkForUpdates() }) + */ } /** diff --git a/src/preload/index.ts b/src/preload/index.ts index 0787765f1..03552563d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -44,6 +44,8 @@ contextBridge.exposeInMainWorld("desktopApi", { getVersion: () => ipcRenderer.invoke("app:version"), isPackaged: () => ipcRenderer.invoke("app:isPackaged"), + // UPDATES-DISABLED: re-enable to restore update API in preload bridge + /* // Auto-update methods checkForUpdates: (force?: boolean) => ipcRenderer.invoke("update:check", force), downloadUpdate: () => ipcRenderer.invoke("update:download"), @@ -87,6 +89,7 @@ contextBridge.exposeInMainWorld("desktopApi", { ipcRenderer.on("update:manual-check", handler) return () => ipcRenderer.removeListener("update:manual-check", handler) }, + */ // Window controls windowMinimize: () => ipcRenderer.invoke("window:minimize"), diff --git a/src/renderer/components/dialogs/settings-tabs/agents-beta-tab.tsx b/src/renderer/components/dialogs/settings-tabs/agents-beta-tab.tsx index 2bdfcdf7e..644bf8664 100644 --- a/src/renderer/components/dialogs/settings-tabs/agents-beta-tab.tsx +++ b/src/renderer/components/dialogs/settings-tabs/agents-beta-tab.tsx @@ -51,7 +51,8 @@ export function AgentsBetaTab() { const [autoOffline, setAutoOffline] = useAtom(autoOfflineModeAtom) const [selectedOllamaModel, setSelectedOllamaModel] = useAtom(selectedOllamaModelAtom) const [automationsEnabled, setAutomationsEnabled] = useAtom(betaAutomationsEnabledAtom) - const [betaUpdatesEnabled, setBetaUpdatesEnabled] = useAtom(betaUpdatesEnabledAtom) + // UPDATES-DISABLED: re-enable to restore beta updates toggle + // const [betaUpdatesEnabled, setBetaUpdatesEnabled] = useAtom(betaUpdatesEnabledAtom) // Check subscription to gate automations behind paid plan const { data: subscription } = useQuery({ @@ -62,6 +63,8 @@ export function AgentsBetaTab() { const isDev = process.env.NODE_ENV === "development" const canEnableAutomations = isPaidPlan || isDev const [copied, setCopied] = useState(false) + // UPDATES-DISABLED: re-enable to restore update check state + handlers + /* const [updateStatus, setUpdateStatus] = useState<"idle" | "checking" | "available" | "not-available" | "error">("idle") const [updateVersion, setUpdateVersion] = useState(null) const [currentVersion, setCurrentVersion] = useState(null) @@ -99,6 +102,7 @@ export function AgentsBetaTab() { setUpdateStatus("error") } } + */ // Get Ollama status const { data: ollamaStatus } = trpc.ollama.getStatus.useQuery(undefined, { @@ -323,7 +327,8 @@ export function AgentsBetaTab() {
)} - {/* Updates Section */} + {/* UPDATES-DISABLED: re-enable to restore Updates settings section */} + {/*

Updates

@@ -378,6 +383,7 @@ export function AgentsBetaTab() {
+ */}
) } diff --git a/src/renderer/components/update-banner.tsx b/src/renderer/components/update-banner.tsx index d3c1e62f4..deeb9a1be 100644 --- a/src/renderer/components/update-banner.tsx +++ b/src/renderer/components/update-banner.tsx @@ -10,6 +10,12 @@ import { IconSpinner } from "../icons" const MOCK_STATE: "none" | "available" | "downloading" | "just-updated" = "none" export function UpdateBanner() { + // UPDATES-DISABLED: re-enable to restore update banner UI + return null +} + +/* UPDATES-DISABLED: original implementation preserved below for reactivation +function _UpdateBanner_original() { const { state: realState, downloadUpdate, @@ -245,3 +251,4 @@ export function UpdateBanner() {
) } +*/ diff --git a/src/renderer/features/agents/components/agents-help-popover.tsx b/src/renderer/features/agents/components/agents-help-popover.tsx index 05a959bcf..0cc9c4ff5 100644 --- a/src/renderer/features/agents/components/agents-help-popover.tsx +++ b/src/renderer/features/agents/components/agents-help-popover.tsx @@ -68,6 +68,8 @@ export function AgentsHelpPopover({ const open = controlledOpen ?? internalOpen const setOpen = controlledOnOpenChange ?? setInternalOpen + // UPDATES-DISABLED: re-enable to restore changelog fetch + handlers + /* useEffect(() => { let cancelled = false window.desktopApi @@ -92,11 +94,14 @@ export function AgentsHelpPopover({ cancelled = true } }, []) + */ const handleCommunityClick = () => { window.desktopApi.openExternal("https://discord.gg/8ektTZGnj4") } + // UPDATES-DISABLED: re-enable to restore changelog link handlers + /* const handleChangelogClick = () => { window.desktopApi.openExternal("https://1code.dev/agents/changelog") } @@ -106,6 +111,7 @@ export function AgentsHelpPopover({ `https://1code.dev/agents/changelog#${version}`, ) } + */ const handleKeyboardShortcutsClick = () => { setOpen(false) @@ -132,6 +138,8 @@ export function AgentsHelpPopover({ )} + {/* UPDATES-DISABLED: re-enable to restore "What's new" changelog section */} + {/* {highlights.length > 0 && ( <> @@ -165,6 +173,7 @@ export function AgentsHelpPopover({ )} + */} ) diff --git a/src/renderer/features/layout/agents-layout.tsx b/src/renderer/features/layout/agents-layout.tsx index 21de84ebf..6d4b9e098 100644 --- a/src/renderer/features/layout/agents-layout.tsx +++ b/src/renderer/features/layout/agents-layout.tsx @@ -88,8 +88,9 @@ export function AgentsLayout() { return unsubscribe }, [isDesktop, setIsFullscreen]) + // UPDATES-DISABLED: re-enable to restore update checking // Check for updates on mount and periodically - useUpdateChecker() + // useUpdateChecker() const [sidebarOpen, setSidebarOpen] = useAtom(agentsSidebarOpenAtom) const [sidebarWidth, setSidebarWidth] = useAtom(agentsSidebarWidthAtom) @@ -350,8 +351,9 @@ export function AgentsLayout() {
+ {/* UPDATES-DISABLED: re-enable to restore update banner */} {/* Update Banner */} - + {/* */}
) diff --git a/src/renderer/features/sidebar/agents-sidebar.tsx b/src/renderer/features/sidebar/agents-sidebar.tsx index 0f0f9e3d4..eb9ce8cc8 100644 --- a/src/renderer/features/sidebar/agents-sidebar.tsx +++ b/src/renderer/features/sidebar/agents-sidebar.tsx @@ -3499,7 +3499,8 @@ export function AgentsSidebar({
- {/* Feedback Button */} + {/* UPDATES-DISABLED: re-enable to restore Feedback button */} + {/* window.open(FEEDBACK_URL, "_blank")} variant="outline" @@ -3511,6 +3512,7 @@ export function AgentsSidebar({ > Feedback + */} )} From c62708ef619ba121c347978f5e34cc42cd877adb Mon Sep 17 00:00:00 2001 From: Alejandro Tamayo Castillo Date: Sat, 18 Apr 2026 14:33:20 +0200 Subject: [PATCH 19/33] feat(models): add Sonnet 1M and GPT-5.4 with usage warnings + recovery action (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Register Sonnet 4.6 1M (sonnet[1m]) alongside existing Opus 1M. - Generalize the [1m] beta-header path so the same context-1m-2025-08-07 slug is sent for both Opus and Sonnet 1M models. - Add an amber "1M · higher cost" badge in the model selector to surface the pricing tradeoff before users opt in. - On rate-limit / context errors against a [1m] model, swap the toast action to "Switch to " — one click moves the sub-chat back to the 200K variant instead of leaving the user stuck. - Add gpt-5.4 and gpt-5.4-mini to Codex models and switch both the backend default (gpt-5.4/high) and frontend last-selected default to gpt-5.4. 5.3-codex stays available; existing user prefs are preserved. Co-authored-by: Claude Opus 4.7 (1M context) --- src/main/lib/trpc/routers/claude.ts | 23 ++++++---- src/main/lib/trpc/routers/codex.ts | 2 +- src/renderer/features/agents/atoms/index.ts | 4 +- .../components/agent-model-selector.tsx | 10 +++++ .../features/agents/lib/ipc-chat-transport.ts | 44 +++++++++++++++---- src/renderer/features/agents/lib/models.ts | 16 +++++++ .../agents/ui/agent-context-indicator.tsx | 1 + 7 files changed, 82 insertions(+), 18 deletions(-) diff --git a/src/main/lib/trpc/routers/claude.ts b/src/main/lib/trpc/routers/claude.ts index 56d1e30e8..3d87349fb 100644 --- a/src/main/lib/trpc/routers/claude.ts +++ b/src/main/lib/trpc/routers/claude.ts @@ -1531,13 +1531,18 @@ export const claudeRouter = router({ } const rawResolvedModel = finalCustomConfig?.model || input.model - // Opus 1M: the UI exposes `opus[1m]` as a distinct model, but the - // Claude CLI only understands the `opus` shortcut. To opt into the - // 1M context window we strip the `[1m]` suffix for the model field - // and enable the matching beta via ANTHROPIC_BETAS on the child env. - const isOpus1M = rawResolvedModel === "opus[1m]" - const resolvedModel = isOpus1M ? "opus" : rawResolvedModel - if (isOpus1M) { + // 1M context: the UI exposes `opus[1m]` and `sonnet[1m]` as + // distinct models, but the Claude CLI only understands the base + // shortcuts (`opus`, `sonnet`). Strip the `[1m]` suffix for the + // model field and enable the shared beta via ANTHROPIC_BETAS on + // the child env. + const has1MSuffix = + typeof rawResolvedModel === "string" && + rawResolvedModel.endsWith("[1m]") + const resolvedModel = has1MSuffix + ? rawResolvedModel!.slice(0, -4) + : rawResolvedModel + if (has1MSuffix) { const envAsRecord = finalEnv as Record const existingBetas = envAsRecord.ANTHROPIC_BETAS const betaSlug = "context-1m-2025-08-07" @@ -1554,7 +1559,7 @@ export const claudeRouter = router({ : betaSlug envAsRecord.ANTHROPIC_BETAS = merged console.log( - `[claude] Opus 1M context enabled — ANTHROPIC_BETAS=${merged}`, + `[claude] 1M context enabled for ${resolvedModel} — ANTHROPIC_BETAS=${merged}`, ) } @@ -2327,6 +2332,7 @@ ${prompt} rawErrorCode, sessionId: msgAny.session_id, messageId: msgAny.message?.id, + model: rawResolvedModel, }, } as UIMessageChunk) } @@ -2669,6 +2675,7 @@ ${prompt} cwd: input.cwd, mode: input.mode, stderr: stderrOutput || "(no stderr captured)", + model: rawResolvedModel, }, } as UIMessageChunk) } diff --git a/src/main/lib/trpc/routers/codex.ts b/src/main/lib/trpc/routers/codex.ts index 0bc355eb9..36d10e344 100644 --- a/src/main/lib/trpc/routers/codex.ts +++ b/src/main/lib/trpc/routers/codex.ts @@ -136,7 +136,7 @@ const AUTH_HINTS = [ "401", "403", ] -const DEFAULT_CODEX_MODEL = "gpt-5.3-codex/high" +const DEFAULT_CODEX_MODEL = "gpt-5.4/high" const CODEX_MCP_TOOLS_FETCH_TIMEOUT_MS = 40_000 const CODEX_USAGE_POLL_ATTEMPTS = 3 const CODEX_USAGE_POLL_INTERVAL_MS = 200 diff --git a/src/renderer/features/agents/atoms/index.ts b/src/renderer/features/agents/atoms/index.ts index ff40a1f5c..9497423d4 100644 --- a/src/renderer/features/agents/atoms/index.ts +++ b/src/renderer/features/agents/atoms/index.ts @@ -223,6 +223,7 @@ const AVAILABLE_CLAUDE_MODEL_IDS = [ "opus", "opus[1m]", "sonnet", + "sonnet[1m]", "haiku", ] as const @@ -255,7 +256,7 @@ export const defaultReviewModeModelAtom = atomWithStorage( export const lastSelectedCodexModelIdAtom = atomWithStorage( "agents:lastSelectedCodexModelId", - "gpt-5.3-codex", + "gpt-5.4", undefined, { getOnInit: true }, ) @@ -463,6 +464,7 @@ export const MODEL_ID_MAP: Record = { opus: "opus", "opus[1m]": "opus[1m]", sonnet: "sonnet", + "sonnet[1m]": "sonnet[1m]", haiku: "haiku", } diff --git a/src/renderer/features/agents/components/agent-model-selector.tsx b/src/renderer/features/agents/components/agent-model-selector.tsx index 035cfd53c..369d03580 100644 --- a/src/renderer/features/agents/components/agent-model-selector.tsx +++ b/src/renderer/features/agents/components/agent-model-selector.tsx @@ -607,6 +607,8 @@ export function AgentModelSelector({ const selected = isItemSelected(item) const disabled = isItemDisabled(item) const crossProvider = isItemCrossProvider(item) + const is1M = + item.type === "claude" && item.model.id.endsWith("[1m]") return ( {getItemIcon(item)} {getItemLabel(item)} + {is1M && ( + + 1M · higher cost + + )} {crossProvider && ( New chat )} diff --git a/src/renderer/features/agents/lib/ipc-chat-transport.ts b/src/renderer/features/agents/lib/ipc-chat-transport.ts index 20f004295..23fefb3e3 100644 --- a/src/renderer/features/agents/lib/ipc-chat-transport.ts +++ b/src/renderer/features/agents/lib/ipc-chat-transport.ts @@ -26,6 +26,7 @@ import { subChatClaudeThinkingAtomFamily, subChatModelIdAtomFamily, } from "../atoms" +import { setSubChatModel } from "./model-switching" import { useAgentSubChatStore } from "../stores/sub-chat-store" import type { AgentMessageMetadata } from "../ui/agent-message-usage" @@ -424,16 +425,43 @@ export class IPCChatTransport implements ChatTransport { ? rawDescription.slice(0, 300) + "..." : rawDescription + // Surface a clearer fallback action when a 1M-context model + // hits a rate-limit / context error. Lets the user recover + // with one click instead of digging through model settings. + const erroredModel: string | undefined = chunk.debugInfo?.model + const is1MModel = + typeof erroredModel === "string" && erroredModel.endsWith("[1m]") + const isRateOrContextError = + category === "RATE_LIMIT" || category === "RATE_LIMIT_SDK" + const subChatId = this.config.subChatId + const offerFallback = + is1MModel && isRateOrContextError && Boolean(subChatId) + const fallbackModelId = erroredModel?.replace(/\[1m\]$/, "") + + const action = offerFallback && fallbackModelId + ? { + label: `Switch to ${fallbackModelId}`, + onClick: () => { + setSubChatModel(subChatId, fallbackModelId) + toast.success(`Switched to ${fallbackModelId}`) + }, + } + : { + label: "Copy Error", + onClick: () => { + navigator.clipboard.writeText(errorDetails) + toast.success("Error details copied to clipboard") + }, + } + + const finalDescription = offerFallback + ? `${description} 1M-context models share a tighter quota — try the standard 200K model.` + : description + toast.error(title, { - description, + description: finalDescription, duration: 12000, - action: { - label: "Copy Error", - onClick: () => { - navigator.clipboard.writeText(errorDetails) - toast.success("Error details copied to clipboard") - }, - }, + action, }) } diff --git a/src/renderer/features/agents/lib/models.ts b/src/renderer/features/agents/lib/models.ts index d76613419..71164b1f9 100644 --- a/src/renderer/features/agents/lib/models.ts +++ b/src/renderer/features/agents/lib/models.ts @@ -25,6 +25,12 @@ export const CLAUDE_MODELS = [ version: "4.6", thinkings: ["off", "low", "medium", "high"] as ClaudeThinkingLevel[], }, + { + id: "sonnet[1m]", + name: "Sonnet", + version: "4.6 1M", + thinkings: ["off", "low", "medium", "high"] as ClaudeThinkingLevel[], + }, { id: "haiku", name: "Haiku", @@ -43,6 +49,16 @@ export function formatClaudeThinkingLabel(thinking: ClaudeThinkingLevel): string export type CodexThinkingLevel = "low" | "medium" | "high" | "xhigh" export const CODEX_MODELS = [ + { + id: "gpt-5.4", + name: "GPT-5.4", + thinkings: ["low", "medium", "high", "xhigh"] as CodexThinkingLevel[], + }, + { + id: "gpt-5.4-mini", + name: "GPT-5.4 mini", + thinkings: ["low", "medium", "high"] as CodexThinkingLevel[], + }, { id: "gpt-5.3-codex", name: "Codex 5.3", diff --git a/src/renderer/features/agents/ui/agent-context-indicator.tsx b/src/renderer/features/agents/ui/agent-context-indicator.tsx index a3860306b..1880e5072 100644 --- a/src/renderer/features/agents/ui/agent-context-indicator.tsx +++ b/src/renderer/features/agents/ui/agent-context-indicator.tsx @@ -13,6 +13,7 @@ const CONTEXT_WINDOWS = { opus: 200_000, "opus[1m]": 1_000_000, sonnet: 200_000, + "sonnet[1m]": 1_000_000, haiku: 200_000, } as const From 22a56857e607e514f07d91b5b5bd40fb88d69dc1 Mon Sep 17 00:00:00 2001 From: Alejandro Tamayo Castillo Date: Sat, 18 Apr 2026 15:54:21 +0200 Subject: [PATCH 20/33] feat: copy popover, optimistic sub-chats, worktree cleanup, sortable sidebar, draggable tabs (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: copy popover, optimistic sub-chats, worktree + empty-chat cleanup, sortable sidebar, draggable tabs Bundles six related improvements (F1-F6): - F1: Copy button in text-selection popover - F2: Optimistic sub-chat creation with rollback on RPC failure - F3: Worktree directory cleanup (path-prefix guard, project-delete cascade, startup orphan scan) - F4: Native HTML5 DnD on the tab bar with insertion marker - F5: Sortable sidebar rows via @dnd-kit - F6: Cached file_stats columns + auto-delete empty sub-chats on tab close and app quit Co-Authored-By: Claude Opus 4.7 (1M context) * ui: add grab cursor + grip-handle hint for draggable rows and tabs Drag affordances were invisible — users couldn't tell which rows/tabs were draggable. Sidebar rows now show a hover-revealed GripVertical icon on the left edge plus cursor-grab on hover. Tab pills show cursor-grab when draggable. Split-pair items keep cursor-pointer (they're locked). Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- bun.lock | 11 + drizzle/0008_shiny_hydra.sql | 3 + drizzle/meta/0008_snapshot.json | 458 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + package.json | 3 + src/main/index.ts | 27 ++ src/main/lib/db/schema/index.ts | 4 + src/main/lib/file-stats.ts | 94 ++++ src/main/lib/git/worktree-cleanup.ts | 118 +++++ src/main/lib/git/worktree.ts | 50 +- src/main/lib/trpc/routers/chats.ts | 239 ++++----- src/main/lib/trpc/routers/claude.ts | 76 +-- src/main/lib/trpc/routers/codex.ts | 23 +- src/main/lib/trpc/routers/projects.ts | 64 ++- src/renderer/App.tsx | 18 +- .../features/agents/main/active-chat.tsx | 48 +- .../features/agents/stores/sub-chat-store.ts | 13 + .../features/agents/ui/sub-chat-selector.tsx | 84 +++- .../agents/ui/text-selection-popover.tsx | 38 +- .../sidebar/agents-subchats-sidebar.tsx | 181 +++++-- 20 files changed, 1305 insertions(+), 254 deletions(-) create mode 100644 drizzle/0008_shiny_hydra.sql create mode 100644 drizzle/meta/0008_snapshot.json create mode 100644 src/main/lib/file-stats.ts create mode 100644 src/main/lib/git/worktree-cleanup.ts diff --git a/bun.lock b/bun.lock index 1a2becaba..98cef2133 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,9 @@ "dependencies": { "@ai-sdk/react": "^3.0.14", "@anthropic-ai/claude-agent-sdk": "0.2.114", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@git-diff-view/react": "^0.0.35", "@git-diff-view/shiki": "^0.0.36", "@mcpc-tech/acp-ai-provider": "^0.2.4", @@ -208,6 +211,14 @@ "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@electron-toolkit/preload": ["@electron-toolkit/preload@3.0.2", "", { "peerDependencies": { "electron": ">=13.0.0" } }, "sha512-TWWPToXd8qPRfSXwzf5KVhpXMfONaUuRAZJHsKthKgZR/+LqX1dZVSSClQ8OTAEduvLGdecljCsoT2jSshfoUg=="], diff --git a/drizzle/0008_shiny_hydra.sql b/drizzle/0008_shiny_hydra.sql new file mode 100644 index 000000000..cc11421e5 --- /dev/null +++ b/drizzle/0008_shiny_hydra.sql @@ -0,0 +1,3 @@ +ALTER TABLE `sub_chats` ADD `file_stats_additions` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `sub_chats` ADD `file_stats_deletions` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `sub_chats` ADD `file_stats_file_count` integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 000000000..3b702ea92 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,458 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "703af73c-d5ad-4d1e-9405-252a3086df88", + "prevId": "b2d2d602-5de1-43b1-ada8-c9ed3edde22d", + "tables": { + "anthropic_accounts": { + "name": "anthropic_accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_token": { + "name": "oauth_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_at": { + "name": "connected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "desktop_user_id": { + "name": "desktop_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "anthropic_settings": { + "name": "anthropic_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": "'singleton'" + }, + "active_account_id": { + "name": "active_account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chats": { + "name": "chats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "chats_worktree_path_idx": { + "name": "chats_worktree_path_idx", + "columns": [ + "worktree_path" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chats_project_id_projects_id_fk": { + "name": "chats_project_id_projects_id_fk", + "tableFrom": "chats", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "claude_code_credentials": { + "name": "claude_code_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "oauth_token": { + "name": "oauth_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_at": { + "name": "connected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_remote_url": { + "name": "git_remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_provider": { + "name": "git_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_owner": { + "name": "git_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_repo": { + "name": "git_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_path": { + "name": "icon_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_path_unique": { + "name": "projects_path_unique", + "columns": [ + "path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sub_chats": { + "name": "sub_chats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "messages": { + "name": "messages", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "file_stats_additions": { + "name": "file_stats_additions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "file_stats_deletions": { + "name": "file_stats_deletions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "file_stats_file_count": { + "name": "file_stats_file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sub_chats_chat_id_chats_id_fk": { + "name": "sub_chats_chat_id_chats_id_fk", + "tableFrom": "sub_chats", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 88a3e0a60..57808ce6d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1769810815497, "tag": "0007_clammy_grim_reaper", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1776516765268, + "tag": "0008_shiny_hydra", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index a1533fa4d..778c5045d 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,9 @@ "dependencies": { "@ai-sdk/react": "^3.0.14", "@anthropic-ai/claude-agent-sdk": "0.2.114", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@git-diff-view/react": "^0.0.35", "@git-diff-view/shiki": "^0.0.36", "@mcpc-tech/acp-ai-provider": "^0.2.4", diff --git a/src/main/index.ts b/src/main/index.ts index b1937bbaa..5a90d0804 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -948,6 +948,14 @@ if (gotTheLock) { console.error("[App] Failed to initialize database:", error) } + // Scan for orphan worktree directories in the background (non-blocking). + // Runs after a short delay so it doesn't compete with main-window paint. + setTimeout(() => { + import("./lib/git/worktree-cleanup") + .then(({ scanWorktreeOrphans }) => scanWorktreeOrphans()) + .catch((err) => console.warn("[App] Worktree orphan scan failed to start:", err)) + }, 5_000) + // Create main window createMainWindow() @@ -1021,6 +1029,25 @@ if (gotTheLock) { cancelAllPendingOAuth() await cleanupGitWatchers() await shutdownAnalytics() + + // Auto-delete sub-chats that were never named and never used (messages = "[]"). + // Conservative: keeps anything the user invested effort in (named or messaged). + try { + const { getDatabase, subChats } = await import("./lib/db") + const { and, eq, isNull } = await import("drizzle-orm") + const db = getDatabase() + const result = db + .delete(subChats) + .where(and(eq(subChats.messages, "[]"), isNull(subChats.name))) + .returning() + .all() + if (result.length > 0) { + console.log(`[App] Cleaned up ${result.length} empty unnamed sub-chats`) + } + } catch (error) { + console.warn("[App] Empty sub-chat cleanup failed:", error) + } + await closeDatabase() }) diff --git a/src/main/lib/db/schema/index.ts b/src/main/lib/db/schema/index.ts index fe6aa3490..5fe6ee082 100644 --- a/src/main/lib/db/schema/index.ts +++ b/src/main/lib/db/schema/index.ts @@ -76,6 +76,10 @@ export const subChats = sqliteTable("sub_chats", { streamId: text("stream_id"), // Track in-progress streams mode: text("mode").notNull().default("agent"), // "plan" | "agent" messages: text("messages").notNull().default("[]"), // JSON array + // Cached file stats — kept in sync by writers, read by getFileStats to avoid JSON parse on every query + fileStatsAdditions: integer("file_stats_additions").notNull().default(0), + fileStatsDeletions: integer("file_stats_deletions").notNull().default(0), + fileStatsFileCount: integer("file_stats_file_count").notNull().default(0), createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn( () => new Date(), ), diff --git a/src/main/lib/file-stats.ts b/src/main/lib/file-stats.ts new file mode 100644 index 000000000..b229b18f3 --- /dev/null +++ b/src/main/lib/file-stats.ts @@ -0,0 +1,94 @@ +/** + * Aggregate +/- line counts and file count for a sub-chat's messages array. + * + * Mirrors the logic that `getFileStats` used to run on every read; called from + * every messages-write path so the cached columns on `sub_chats` stay in sync. + * + * Returns zeros for unparseable JSON or message arrays without Edit/Write tool calls. + */ +export interface SubChatFileStats { + fileStatsAdditions: number + fileStatsDeletions: number + fileStatsFileCount: number +} + +const ZERO: SubChatFileStats = { + fileStatsAdditions: 0, + fileStatsDeletions: 0, + fileStatsFileCount: 0, +} + +export function computeFileStatsFromMessages(messagesJson: string | null | undefined): SubChatFileStats { + if (!messagesJson) return ZERO + + // Cheap pre-filter: skip the JSON parse if there are no Edit/Write tool calls. + if (!messagesJson.includes("tool-Edit") && !messagesJson.includes("tool-Write")) { + return ZERO + } + + let messages: Array<{ + role: string + parts?: Array<{ + type: string + input?: { + file_path?: string + old_string?: string + new_string?: string + content?: string + } + }> + }> + try { + messages = JSON.parse(messagesJson) + } catch { + return ZERO + } + + const fileStates = new Map< + string, + { originalContent: string | null; currentContent: string } + >() + + for (const msg of messages) { + if (msg.role !== "assistant") continue + for (const part of msg.parts || []) { + if (part.type !== "tool-Edit" && part.type !== "tool-Write") continue + const filePath = part.input?.file_path + if (!filePath) continue + // Skip session/internal files + if (filePath.includes("claude-sessions") || filePath.includes("Application Support")) continue + + const oldString = part.input?.old_string || "" + const newString = part.input?.new_string || part.input?.content || "" + + const existing = fileStates.get(filePath) + if (existing) { + existing.currentContent = newString + } else { + fileStates.set(filePath, { + originalContent: part.type === "tool-Write" ? null : oldString, + currentContent: newString, + }) + } + } + } + + let additions = 0 + let deletions = 0 + let fileCount = 0 + for (const [, state] of fileStates) { + const original = state.originalContent || "" + if (original === state.currentContent) continue + const oldLines = original ? original.split("\n").length : 0 + const newLines = state.currentContent ? state.currentContent.split("\n").length : 0 + if (!original) { + additions += newLines + } else { + additions += newLines + deletions += oldLines + } + fileCount += 1 + } + + return { fileStatsAdditions: additions, fileStatsDeletions: deletions, fileStatsFileCount: fileCount } +} diff --git a/src/main/lib/git/worktree-cleanup.ts b/src/main/lib/git/worktree-cleanup.ts new file mode 100644 index 000000000..e0a863dc0 --- /dev/null +++ b/src/main/lib/git/worktree-cleanup.ts @@ -0,0 +1,118 @@ +/** + * Startup orphan-worktree scanner. + * + * Walks ~/.21st/worktrees/// two levels deep and removes any + * directory that has no matching `chats.worktreePath` row. This catches: + * - Worktrees left behind by previous app crashes + * - Worktrees from manually-deleted DB rows + * - Worktrees migrated between projects + * + * Defensive: refuses to operate on paths outside the worktree root, never + * blocks startup, swallows errors. + */ +import { readdir, rm, stat } from "node:fs/promises" +import { homedir } from "node:os" +import { join, resolve, sep } from "node:path" +import { eq } from "drizzle-orm" +import { chats, getDatabase } from "../db" +import { isPathInsideWorktreeRoot } from "./worktree" + +const SCAN_TIMEOUT_MS = 30_000 + +async function listSubdirs(dir: string): Promise { + try { + const entries = await readdir(dir, { withFileTypes: true }) + return entries.filter((e) => e.isDirectory()).map((e) => e.name) + } catch { + return [] + } +} + +async function isOlderThan(path: string, minAgeMs: number): Promise { + try { + const s = await stat(path) + return Date.now() - s.mtimeMs > minAgeMs + } catch { + return false + } +} + +async function scanOnce(): Promise<{ scanned: number; removed: number }> { + const root = join(homedir(), ".21st", "worktrees") + const projectSlugs = await listSubdirs(root) + if (projectSlugs.length === 0) return { scanned: 0, removed: 0 } + + const db = getDatabase() + let scanned = 0 + let removed = 0 + + for (const slug of projectSlugs) { + const slugPath = join(root, slug) + const worktreeFolders = await listSubdirs(slugPath) + for (const folder of worktreeFolders) { + scanned++ + const fullPath = join(slugPath, folder) + const resolved = resolve(fullPath) + + // Defense in depth: ensure path is still inside the worktree root after symlink resolution + const allowedRoot = resolve(root) + sep + if (!resolved.startsWith(allowedRoot)) continue + if (!isPathInsideWorktreeRoot(fullPath)) continue + + const matchingChat = db + .select({ id: chats.id }) + .from(chats) + .where(eq(chats.worktreePath, fullPath)) + .get() + + if (matchingChat) continue + + // Don't remove freshly-created dirs (safety margin against race with new worktrees) + const isOld = await isOlderThan(fullPath, 60_000) + if (!isOld) continue + + try { + await rm(fullPath, { recursive: true, force: true, maxRetries: 2 }) + removed++ + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + console.warn(`[WorktreeCleanup] Failed to remove orphan ${fullPath}: ${msg}`) + } + } + + // Remove now-empty slug dir + try { + const remaining = await readdir(slugPath) + if (remaining.length === 0 && isPathInsideWorktreeRoot(slugPath)) { + await rm(slugPath, { recursive: true, force: true }) + } + } catch { + // Non-fatal + } + } + + return { scanned, removed } +} + +/** + * Run the orphan scan. Capped at SCAN_TIMEOUT_MS so a slow disk can't block + * other startup work. Logs a one-line summary; never throws. + */ +export async function scanWorktreeOrphans(): Promise { + const start = Date.now() + try { + const result = await Promise.race([ + scanOnce(), + new Promise<{ scanned: number; removed: number }>((_, reject) => + setTimeout(() => reject(new Error("timeout")), SCAN_TIMEOUT_MS), + ), + ]) + const elapsed = Date.now() - start + console.log( + `[WorktreeCleanup] Scanned ${result.scanned} worktree dirs, removed ${result.removed} orphans (${elapsed}ms)`, + ) + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + console.warn(`[WorktreeCleanup] Scan failed: ${msg}`) + } +} diff --git a/src/main/lib/git/worktree.ts b/src/main/lib/git/worktree.ts index 298c3de28..4feb77c4b 100644 --- a/src/main/lib/git/worktree.ts +++ b/src/main/lib/git/worktree.ts @@ -1,8 +1,8 @@ import { execFile } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { mkdir, readFile, stat } from "node:fs/promises"; +import { mkdir, readFile, rm, stat } from "node:fs/promises"; import { devNull, homedir } from "node:os"; -import { join } from "node:path"; +import { join, resolve, sep } from "node:path"; import { promisify } from "node:util"; import simpleGit from "simple-git"; import { @@ -223,10 +223,25 @@ export async function createWorktree( } } +/** + * Path-prefix guard for worktree directory removal. + * Refuses to operate on paths outside the standard worktree root, + * even if a caller passed a corrupted or attacker-influenced path. + * + * Note: Node's fs.rm does NOT traverse symlinks (it unlinks the symlink entry + * itself), so symlinks inside the worktree cannot escape this guard to delete + * external files. + */ +export function isPathInsideWorktreeRoot(workspacePath: string): boolean { + const root = resolve(join(homedir(), ".21st", "worktrees")) + sep; + return resolve(workspacePath).startsWith(root); +} + export async function removeWorktree( mainRepoPath: string, worktreePath: string, ): Promise<{ success: boolean; error?: string }> { + let gitError: string | undefined; try { const env = await getGitEnv(); @@ -235,13 +250,36 @@ export async function removeWorktree( ["-C", mainRepoPath, "worktree", "remove", worktreePath, "--force"], { env, timeout: 60_000 }, ); + } catch (error) { + gitError = error instanceof Error ? error.message : String(error); + console.warn(`[Worktree] git worktree remove failed (will still attempt rmdir): ${gitError}`); + } + + // Always attempt directory removal — handles cases where git removes the + // worktree record but leaves the directory (e.g., uncommitted changes). + if (isPathInsideWorktreeRoot(worktreePath)) { + try { + await rm(worktreePath, { recursive: true, force: true, maxRetries: 2 }); + } catch (error) { + const rmError = error instanceof Error ? error.message : String(error); + console.warn(`[Worktree] rmdir failed for ${worktreePath}: ${rmError}`); + if (gitError) { + return { success: false, error: `${gitError}; rmdir: ${rmError}` }; + } + return { success: false, error: rmError }; + } + } else { + console.warn( + `[Worktree] Refusing to rmdir path outside ~/.21st/worktrees: ${worktreePath}`, + ); + } + if (gitError) { + // Git failed but rm succeeded — partial success. Treat as success since the + // directory is gone; the next `git worktree prune` will clean stale metadata. return { success: true }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error(`Failed to remove worktree: ${errorMessage}`); - return { success: false, error: errorMessage }; } + return { success: true }; } export async function getGitRoot(path: string): Promise { diff --git a/src/main/lib/trpc/routers/chats.ts b/src/main/lib/trpc/routers/chats.ts index 97fe1975d..c3f19a396 100644 --- a/src/main/lib/trpc/routers/chats.ts +++ b/src/main/lib/trpc/routers/chats.ts @@ -12,6 +12,7 @@ import { trackWorkspaceDeleted, } from "../../analytics" import { chats, getDatabase, projects, subChats } from "../../db" +import { computeFileStatsFromMessages } from "../../file-stats" import { createWorktreeForChat, fetchGitHubPRStatus, @@ -724,10 +725,14 @@ export const chatsRouter = router({ /** * Create a new sub-chat + * + * Accepts an optional client-provided `id` so the renderer can do optimistic UI + * (insert the row in the store synchronously, then fire-and-forget the create). */ createSubChat: publicProcedure .input( z.object({ + id: z.string().optional(), chatId: z.string(), name: z.string().optional(), mode: z.enum(["plan", "agent"]).default("agent"), @@ -738,6 +743,7 @@ export const chatsRouter = router({ return db .insert(subChats) .values({ + ...(input.id ? { id: input.id } : {}), chatId: input.chatId, name: input.name, mode: input.mode, @@ -880,10 +886,13 @@ export const chatsRouter = router({ delete m.metadata.shouldForkResume } } - db.update(subChats) - .set({ messages: JSON.stringify(forkedMessages) }) - .where(eq(subChats.id, newSubChat.id)) - .run() + { + const forkedJson = JSON.stringify(forkedMessages) + db.update(subChats) + .set({ messages: forkedJson, ...computeFileStatsFromMessages(forkedJson) }) + .where(eq(subChats.id, newSubChat.id)) + .run() + } } } @@ -905,7 +914,11 @@ export const chatsRouter = router({ const db = getDatabase() return db .update(subChats) - .set({ messages: input.messages, updatedAt: new Date() }) + .set({ + messages: input.messages, + ...computeFileStatsFromMessages(input.messages), + updatedAt: new Date(), + }) .where(eq(subChats.id, input.id)) .returning() .get() @@ -985,14 +998,18 @@ export const chatsRouter = router({ }) // 6. Update the sub-chat with truncated messages - db.update(subChats) - .set({ - messages: JSON.stringify(truncatedMessages), - updatedAt: new Date(), - }) - .where(eq(subChats.id, input.subChatId)) - .returning() - .get() + { + const truncatedJson = JSON.stringify(truncatedMessages) + db.update(subChats) + .set({ + messages: truncatedJson, + ...computeFileStatsFromMessages(truncatedJson), + updatedAt: new Date(), + }) + .where(eq(subChats.id, input.subChatId)) + .returning() + .get() + } return { success: true, @@ -1060,6 +1077,40 @@ export const chatsRouter = router({ .get() }), + /** + * Delete a sub-chat only if it has no messages. + * Used for auto-cleanup when a tab is closed without ever being used. + * Idempotent — returns null if the sub-chat doesn't exist or has messages. + */ + deleteSubChatIfEmpty: publicProcedure + .input(z.object({ id: z.string() })) + .mutation(({ input }) => { + const db = getDatabase() + return db + .delete(subChats) + .where(and(eq(subChats.id, input.id), eq(subChats.messages, "[]"))) + .returning() + .get() ?? null + }), + + /** + * Bulk-delete any sub-chats from the given id list that have no messages. + * Used on window/app close to sweep up sub-chats created in the session + * that the user never sent a message in. + */ + deleteEmptySubChatsByIds: publicProcedure + .input(z.object({ ids: z.array(z.string()) })) + .mutation(({ input }) => { + if (input.ids.length === 0) return { deleted: 0 } + const db = getDatabase() + const result = db + .delete(subChats) + .where(and(inArray(subChats.id, input.ids), eq(subChats.messages, "[]"))) + .returning() + .all() + return { deleted: result.length } + }), + /** * Get git diff for a chat's worktree */ @@ -1622,8 +1673,9 @@ export const chatsRouter = router({ }), /** - * Get file change stats for workspaces - * Parses messages from specified sub-chats and aggregates Edit/Write tool calls + * Get file change stats for workspaces. + * + * Reads from cached columns on `sub_chats` (kept in sync by every messages-write path). * Supports two modes: * - openSubChatIds: query specific sub-chats (used by main sidebar) * - chatIds: query all sub-chats for given chats (used by archive popover) @@ -1642,143 +1694,30 @@ export const chatsRouter = router({ return [] } - // Query sub-chats based on input mode - let allChats: Array<{ chatId: string | null; subChatId: string; messages: string | null }> - - if (input.chatIds && input.chatIds.length > 0) { - // Archive mode: query all sub-chats for given chat IDs - // Pre-filter with LIKE to skip sub-chats without file edits (avoids loading/parsing large JSON) - allChats = db - .select({ - chatId: subChats.chatId, - subChatId: subChats.id, - messages: subChats.messages, - }) - .from(subChats) - .where( - and( - inArray(subChats.chatId, input.chatIds), - sql`(${subChats.messages} LIKE '%tool-Edit%' OR ${subChats.messages} LIKE '%tool-Write%')` - ) - ) - .all() - } else { - // Main sidebar mode: query specific sub-chats - allChats = db - .select({ - chatId: subChats.chatId, - subChatId: subChats.id, - messages: subChats.messages, - }) - .from(subChats) - .where(inArray(subChats.id, input.openSubChatIds!)) - .all() - } - - // Aggregate stats per workspace (chatId) - const statsMap = new Map< - string, - { additions: number; deletions: number; fileCount: number } - >() - - for (const row of allChats) { - if (!row.messages || !row.chatId) continue - const chatId = row.chatId // TypeScript narrowing - - try { - const messages = JSON.parse(row.messages) as Array<{ - role: string - parts?: Array<{ - type: string - input?: { - file_path?: string - old_string?: string - new_string?: string - content?: string - } - }> - }> - - // Track file states for this sub-chat - const fileStates = new Map< - string, - { originalContent: string | null; currentContent: string } - >() - - for (const msg of messages) { - if (msg.role !== "assistant") continue - for (const part of msg.parts || []) { - if (part.type === "tool-Edit" || part.type === "tool-Write") { - const filePath = part.input?.file_path - if (!filePath) continue - // Skip session files - if ( - filePath.includes("claude-sessions") || - filePath.includes("Application Support") - ) - continue - - const oldString = part.input?.old_string || "" - const newString = - part.input?.new_string || part.input?.content || "" - - const existing = fileStates.get(filePath) - if (existing) { - existing.currentContent = newString - } else { - fileStates.set(filePath, { - originalContent: part.type === "tool-Write" ? null : oldString, - currentContent: newString, - }) - } - } - } - } - - // Calculate stats for this sub-chat and add to workspace total - let subChatAdditions = 0 - let subChatDeletions = 0 - let subChatFileCount = 0 + const whereClause = input.chatIds && input.chatIds.length > 0 + ? inArray(subChats.chatId, input.chatIds) + : inArray(subChats.id, input.openSubChatIds!) - for (const [, state] of fileStates) { - const original = state.originalContent || "" - if (original === state.currentContent) continue - - const oldLines = original ? original.split("\n").length : 0 - const newLines = state.currentContent - ? state.currentContent.split("\n").length - : 0 - - if (!original) { - // New file - subChatAdditions += newLines - } else { - subChatAdditions += newLines - subChatDeletions += oldLines - } - subChatFileCount += 1 - } - - // Add to workspace total - const existing = statsMap.get(chatId) || { - additions: 0, - deletions: 0, - fileCount: 0, - } - existing.additions += subChatAdditions - existing.deletions += subChatDeletions - existing.fileCount += subChatFileCount - statsMap.set(chatId, existing) - } catch { - // Skip invalid JSON - } - } + const rows = db + .select({ + chatId: subChats.chatId, + additions: sql`COALESCE(SUM(${subChats.fileStatsAdditions}), 0)`, + deletions: sql`COALESCE(SUM(${subChats.fileStatsDeletions}), 0)`, + fileCount: sql`COALESCE(SUM(${subChats.fileStatsFileCount}), 0)`, + }) + .from(subChats) + .where(whereClause) + .groupBy(subChats.chatId) + .all() - // Convert to array for easier consumption - return Array.from(statsMap.entries()).map(([chatId, stats]) => ({ - chatId, - ...stats, - })) + return rows + .filter((r) => r.chatId !== null && r.fileCount > 0) + .map((r) => ({ + chatId: r.chatId as string, + additions: Number(r.additions), + deletions: Number(r.deletions), + fileCount: Number(r.fileCount), + })) }), /** diff --git a/src/main/lib/trpc/routers/claude.ts b/src/main/lib/trpc/routers/claude.ts index 3d87349fb..73bfd672d 100644 --- a/src/main/lib/trpc/routers/claude.ts +++ b/src/main/lib/trpc/routers/claude.ts @@ -31,6 +31,7 @@ import { type McpServerConfig, } from "../../claude-config" import { anthropicAccounts, anthropicSettings, chats, claudeCodeCredentials, getDatabase, projects as projectsTable, subChats } from "../../db" +import { computeFileStatsFromMessages } from "../../file-stats" import { createRollbackStash } from "../../git/stash" import { ensureMcpTokensFresh, @@ -963,10 +964,13 @@ export const claudeRouter = router({ delete m.metadata.shouldForkResume } } - db.update(subChats) - .set({ messages: JSON.stringify(existingMessages) }) - .where(eq(subChats.id, input.subChatId)) - .run() + { + const existingJson = JSON.stringify(existingMessages) + db.update(subChats) + .set({ messages: existingJson, ...computeFileStatsFromMessages(existingJson) }) + .where(eq(subChats.id, input.subChatId)) + .run() + } } // Check if last message is already this user message (avoid duplicate) @@ -1005,14 +1009,18 @@ export const claudeRouter = router({ } messagesToSave = [...existingMessages, userMessage] - db.update(subChats) - .set({ - messages: JSON.stringify(messagesToSave), - streamId, - updatedAt: new Date(), - }) - .where(eq(subChats.id, input.subChatId)) - .run() + { + const messagesToSaveJson = JSON.stringify(messagesToSave) + db.update(subChats) + .set({ + messages: messagesToSaveJson, + ...computeFileStatsFromMessages(messagesToSaveJson), + streamId, + updatedAt: new Date(), + }) + .where(eq(subChats.id, input.subChatId)) + .run() + } } // 2.5. AUTO-FALLBACK: Check internet and switch to Ollama if offline @@ -2695,15 +2703,19 @@ ${prompt} metadata, } const finalMessages = [...messagesToSave, assistantMessage] - db.update(subChats) - .set({ - messages: JSON.stringify(finalMessages), - sessionId: metadata.sessionId, - streamId: null, - updatedAt: new Date(), - }) - .where(eq(subChats.id, input.subChatId)) - .run() + { + const finalJson = JSON.stringify(finalMessages) + db.update(subChats) + .set({ + messages: finalJson, + ...computeFileStatsFromMessages(finalJson), + sessionId: metadata.sessionId, + streamId: null, + updatedAt: new Date(), + }) + .where(eq(subChats.id, input.subChatId)) + .run() + } db.update(chats) .set({ updatedAt: new Date() }) .where(eq(chats.id, input.chatId)) @@ -2776,15 +2788,19 @@ ${prompt} const finalMessages = [...messagesToSave, assistantMessage] - db.update(subChats) - .set({ - messages: JSON.stringify(finalMessages), - sessionId: savedSessionId, - streamId: null, - updatedAt: new Date(), - }) - .where(eq(subChats.id, input.subChatId)) - .run() + { + const finalJson = JSON.stringify(finalMessages) + db.update(subChats) + .set({ + messages: finalJson, + ...computeFileStatsFromMessages(finalJson), + sessionId: savedSessionId, + streamId: null, + updatedAt: new Date(), + }) + .where(eq(subChats.id, input.subChatId)) + .run() + } } else { // No assistant response - just clear streamId db.update(subChats) diff --git a/src/main/lib/trpc/routers/codex.ts b/src/main/lib/trpc/routers/codex.ts index 36d10e344..54620fc73 100644 --- a/src/main/lib/trpc/routers/codex.ts +++ b/src/main/lib/trpc/routers/codex.ts @@ -17,6 +17,7 @@ import { import { getClaudeShellEnvironment } from "../../claude/env" import { resolveProjectPathFromWorktree } from "../../claude-config" import { getDatabase, projects as projectsTable, subChats } from "../../db" +import { computeFileStatsFromMessages } from "../../file-stats" import { fetchMcpTools, fetchMcpToolsStdio, @@ -1653,9 +1654,11 @@ export const codexRouter = router({ return false } + const json = JSON.stringify(messages) db.update(subChats) .set({ - messages: JSON.stringify(messages), + messages: json, + ...computeFileStatsFromMessages(json), updatedAt: new Date(), }) .where(eq(subChats.id, input.subChatId)) @@ -1695,13 +1698,17 @@ export const codexRouter = router({ messagesForStream = [...existingMessages, userMessage] - db.update(subChats) - .set({ - messages: JSON.stringify(messagesForStream), - updatedAt: new Date(), - }) - .where(eq(subChats.id, input.subChatId)) - .run() + { + const messagesForStreamJson = JSON.stringify(messagesForStream) + db.update(subChats) + .set({ + messages: messagesForStreamJson, + ...computeFileStatsFromMessages(messagesForStreamJson), + updatedAt: new Date(), + }) + .where(eq(subChats.id, input.subChatId)) + .run() + } } if (input.forceNewSession) { diff --git a/src/main/lib/trpc/routers/projects.ts b/src/main/lib/trpc/routers/projects.ts index 6a95a595c..b3d9b4fa1 100644 --- a/src/main/lib/trpc/routers/projects.ts +++ b/src/main/lib/trpc/routers/projects.ts @@ -1,15 +1,18 @@ import { z } from "zod" import { router, publicProcedure } from "../index" -import { getDatabase, projects, chats, subChats } from "../../db" -import { eq, desc, inArray } from "drizzle-orm" +import { chats, getDatabase, projects, subChats } from "../../db" +import { and, eq, desc, inArray, isNotNull } from "drizzle-orm" import { dialog, BrowserWindow, app } from "electron" import { basename, join } from "path" import { exec } from "node:child_process" import { promisify } from "node:util" import { existsSync } from "node:fs" -import { mkdir, copyFile, unlink } from "node:fs/promises" +import { mkdir, copyFile, readdir, rm, unlink } from "node:fs/promises" +import { homedir } from "node:os" import { extname } from "node:path" -import { getGitRemoteInfo } from "../../git" +import { getGitRemoteInfo, removeWorktree, sanitizeProjectName } from "../../git" +import { isPathInsideWorktreeRoot } from "../../git/worktree" +import { terminalManager } from "../../terminal/manager" import { trackProjectOpened } from "../../analytics" import { getLaunchDirectory } from "../../cli" import { abortClaudeSessionsForSubChats } from "./claude" @@ -187,25 +190,32 @@ export const projectsRouter = router({ }), /** - * Delete a project and all its chats + * Delete a project and all its chats. + * Cascades worktree directory cleanup so disk doesn't accumulate orphans. */ delete: publicProcedure .input(z.object({ id: z.string() })) - .mutation(({ input }) => { + .mutation(async ({ input }) => { const db = getDatabase() + const project = db.select().from(projects).where(eq(projects.id, input.id)).get() + if (!project) { + return null + } - const chatIds = db + // Abort any in-flight Claude sessions for the project's sub-chats so cleanup + // (worktree rm, FK cascade) doesn't race with active streams. + const childChatIds = db .select({ id: chats.id }) .from(chats) .where(eq(chats.projectId, input.id)) .all() .map((row) => row.id) - if (chatIds.length > 0) { + if (childChatIds.length > 0) { const subChatIds = db .select({ id: subChats.id }) .from(subChats) - .where(inArray(subChats.chatId, chatIds)) + .where(inArray(subChats.chatId, childChatIds)) .all() .map((row) => row.id) @@ -214,6 +224,42 @@ export const projectsRouter = router({ } } + // Find chats with real worktrees (branch is set; project-path-only chats have no worktree) + const childChatsWithWorktree = db + .select() + .from(chats) + .where( + and( + eq(chats.projectId, input.id), + isNotNull(chats.worktreePath), + isNotNull(chats.branch), + ), + ) + .all() + + // Kill any terminals tied to those chats and remove the worktrees in parallel + await Promise.allSettled( + childChatsWithWorktree.map(async (chat) => { + if (chat.worktreePath) { + terminalManager.killByWorkspaceId(chat.id).catch(() => {}) + await removeWorktree(project.path, chat.worktreePath) + } + }), + ) + + // Remove the project's slug directory if empty (best-effort) + const slugDir = join(homedir(), ".21st", "worktrees", sanitizeProjectName(project.name)) + if (isPathInsideWorktreeRoot(slugDir)) { + try { + const entries = await readdir(slugDir) + if (entries.length === 0) { + await rm(slugDir, { recursive: true, force: true }) + } + } catch { + // Slug dir doesn't exist or can't be read — non-fatal + } + } + return db .delete(projects) .where(eq(projects.id, input.id)) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index b4b29bcf9..102070889 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -25,7 +25,7 @@ import { } from "./lib/atoms" import { appStore } from "./lib/jotai-store" import { VSCodeThemeProvider } from "./lib/themes/theme-provider" -import { trpc } from "./lib/trpc" +import { trpc, trpcClient } from "./lib/trpc" /** * Custom Toaster that adapts to theme @@ -196,8 +196,24 @@ export function App() { } identifyUser() + // On window unload, sweep open sub-chats — any empty ones (no messages) + // are auto-deleted. This complements the on-tab-close cleanup so closing a + // window with empty tabs still cleans them up. + const handleBeforeUnload = () => { + try { + const openIds = useAgentSubChatStore.getState().openSubChatIds + if (openIds.length === 0) return + // Fire-and-forget; main process IPC will queue the request. + trpcClient.chats.deleteEmptySubChatsByIds.mutate({ ids: openIds }).catch(() => {}) + } catch { + // Swallow — this is best-effort cleanup. + } + } + window.addEventListener("beforeunload", handleBeforeUnload) + // Cleanup on unmount return () => { + window.removeEventListener("beforeunload", handleBeforeUnload) shutdown() } }, []) diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index 1c1cae5d4..1d7aca900 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -6834,7 +6834,7 @@ Make sure to preserve all functionality from both branches when resolving confli ) // Handle creating a new sub-chat - const handleCreateNewSubChat = useCallback(async () => { + const handleCreateNewSubChat = useCallback(() => { const store = useAgentSubChatStore.getState() const sourceSubChatId = activeSubChatId || "" // New sub-chats use the user's default mode preference @@ -6844,25 +6844,12 @@ Make sure to preserve all functionality from both branches when resolving confli // Check if this is a remote sandbox chat const isRemoteChat = !!(agentChat as any)?.isRemote - let newId: string + // Generate ID locally for instant UI update; persist to DB in background for local mode. + const newId = crypto.randomUUID() - if (isRemoteChat) { - // Sandbox mode: lazy creation (web app pattern) - // Sub-chat will be persisted on first message via RemoteChatTransport UPSERT - newId = crypto.randomUUID() - } else { - // Local mode: create sub-chat in DB first to get the real ID - const newSubChat = await trpcClient.chats.createSubChat.mutate({ - chatId, - name: "New Chat", - mode: newSubChatMode, - }) - newId = newSubChat.id - utils.agents.getAgentChat.invalidate({ chatId }) - - // Optimistic update: add new sub-chat to React Query cache immediately - // This is CRITICAL for workspace isolation - without this, the new sub-chat - // won't be in validSubChatIds and will be filtered out by tabsToRender + if (!isRemoteChat) { + // Local mode: optimistically add to React Query cache so workspace isolation + // (validSubChatIds / tabsToRender) immediately recognizes the new sub-chat. utils.agents.getAgentChat.setData({ chatId }, (old) => { if (!old) return old return { @@ -6881,7 +6868,30 @@ Make sure to preserve all functionality from both branches when resolving confli ], } }) + + // Fire-and-forget the DB insert. On failure, roll back the optimistic update. + // Do NOT pass `name` — leave it NULL in DB so the app-quit cleanup can + // recognize never-named, never-used sub-chats. UI displays "New Chat" via fallback. + trpcClient.chats.createSubChat + .mutate({ + id: newId, + chatId, + mode: newSubChatMode, + }) + .catch((error) => { + console.error("[handleCreateNewSubChat] Failed to create sub-chat:", error) + utils.agents.getAgentChat.setData({ chatId }, (old) => { + if (!old) return old + return { + ...old, + subChats: (old.subChats || []).filter((sc: any) => sc.id !== newId), + } + }) + useAgentSubChatStore.getState().removeFromOpenSubChats(newId) + toast.error("Failed to create chat") + }) } + // Sandbox mode (isRemoteChat === true): lazy creation via RemoteChatTransport UPSERT on first message // Track this subchat as just created for typewriter effect setJustCreatedIds((prev) => new Set([...prev, newId])) diff --git a/src/renderer/features/agents/stores/sub-chat-store.ts b/src/renderer/features/agents/stores/sub-chat-store.ts index ebf145147..3beebf4e9 100644 --- a/src/renderer/features/agents/stores/sub-chat-store.ts +++ b/src/renderer/features/agents/stores/sub-chat-store.ts @@ -6,6 +6,7 @@ import { getWindowId } from "../../../contexts/WindowContext" import { clearTaskSnapshotCache } from "../ui/agent-task-tools" import { clearSubChatRuntimeCaches } from "./sub-chat-runtime-cleanup" import { getDefaultRatios, addPaneRatio, removePaneRatio } from "../atoms" +import { trpcClient } from "../../../lib/trpc" const MAX_SPLIT_PANES = 4 @@ -242,10 +243,22 @@ export const useAgentSubChatStore = create((set, get) => ({ // Cleanup queue, streaming status, Chat instance, and task snapshot cache // to prevent memory leaks and race conditions (QueueProcessor sending to closed subChat) useMessageQueueStore.getState().clearQueue(subChatId) + const isStreaming = useStreamingStatusStore.getState().isStreaming(subChatId) useStreamingStatusStore.getState().clearStatus(subChatId) clearSubChatRuntimeCaches(subChatId) agentChatStore.delete(subChatId) clearTaskSnapshotCache(subChatId) + + // Auto-delete the DB row if the sub-chat was never used (no messages). + // Skip if a stream is in flight — the final write would land on a deleted row. + if (!isStreaming) { + trpcClient.chats.deleteSubChatIfEmpty + .mutate({ id: subChatId }) + .catch(() => { + // Ignore — non-fatal. The row may have been streamed-into between the + // gate and the request, or the sub-chat may not yet be persisted (sandbox). + }) + } }, togglePinSubChat: (subChatId) => { diff --git a/src/renderer/features/agents/ui/sub-chat-selector.tsx b/src/renderer/features/agents/ui/sub-chat-selector.tsx index d17cb3875..7ce4e2a4a 100644 --- a/src/renderer/features/agents/ui/sub-chat-selector.tsx +++ b/src/renderer/features/agents/ui/sub-chat-selector.tsx @@ -275,6 +275,59 @@ export function SubChatSelector({ const rightGradientRef = useRef(null) const truncatedTabsRef = useRef>(new Set()) const searchHistoryPopoverRef = useRef(null) + // Native HTML5 DnD state — track which tab is being dragged and which is hovered + const [draggedTabId, setDraggedTabId] = useState(null) + const [dragOverTabId, setDragOverTabId] = useState(null) + const [dragOverSide, setDragOverSide] = useState<"left" | "right" | null>(null) + + const handleTabDragStart = useCallback((e: React.DragEvent, subChatId: string) => { + e.dataTransfer.setData("application/x-subchat-tab-id", subChatId) + e.dataTransfer.effectAllowed = "move" + setDraggedTabId(subChatId) + }, []) + + const handleTabDragEnd = useCallback(() => { + setDraggedTabId(null) + setDragOverTabId(null) + setDragOverSide(null) + }, []) + + const handleTabDragOver = useCallback((e: React.DragEvent, subChatId: string) => { + if (!draggedTabId || draggedTabId === subChatId) return + e.preventDefault() + e.dataTransfer.dropEffect = "move" + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() + const side: "left" | "right" = e.clientX - rect.left < rect.width / 2 ? "left" : "right" + setDragOverTabId(subChatId) + setDragOverSide(side) + }, [draggedTabId]) + + const handleTabDrop = useCallback( + (e: React.DragEvent, targetId: string) => { + e.preventDefault() + const sourceId = e.dataTransfer.getData("application/x-subchat-tab-id") + if (!sourceId || sourceId === targetId) { + setDraggedTabId(null) + setDragOverTabId(null) + setDragOverSide(null) + return + } + const store = useAgentSubChatStore.getState() + const ids = store.openSubChatIds + const fromIdx = ids.indexOf(sourceId) + const toIdxBase = ids.indexOf(targetId) + if (fromIdx < 0 || toIdxBase < 0) return + const without = ids.filter((id) => id !== sourceId) + const targetIdxAfterRemove = without.indexOf(targetId) + const insertAt = dragOverSide === "right" ? targetIdxAfterRemove + 1 : targetIdxAfterRemove + const next = [...without.slice(0, insertAt), sourceId, ...without.slice(insertAt)] + store.setOpenSubChats(next) + setDraggedTabId(null) + setDragOverTabId(null) + setDragOverSide(null) + }, + [dragOverSide], + ) const allSubChatsById = useMemo(() => { const map = new Map() @@ -715,6 +768,11 @@ export function SubChatSelector({ // Check if this chat has a pending plan approval const hasPendingPlan = pendingPlanApprovals.has(subChat.id) + const isDragOverHere = dragOverTabId === subChat.id && draggedTabId !== subChat.id + const isBeingDragged = draggedTabId === subChat.id + // Disable native DnD for split-pair tabs so auto-adjacency logic stays intact + const tabIsDraggable = !isInSplitPair && editingSubChatId !== subChat.id + return ( @@ -726,6 +784,23 @@ export function SubChatSelector({ tabRefs.current.delete(subChat.id) } }} + draggable={tabIsDraggable} + onDragStart={(e) => { + if (!tabIsDraggable) { + e.preventDefault() + return + } + handleTabDragStart(e, subChat.id) + }} + onDragEnd={handleTabDragEnd} + onDragOver={(e) => handleTabDragOver(e, subChat.id)} + onDragLeave={() => { + if (dragOverTabId === subChat.id) { + setDragOverTabId(null) + setDragOverSide(null) + } + }} + onDrop={(e) => handleTabDrop(e, subChat.id)} onClick={(e) => { e.stopPropagation() e.preventDefault() @@ -756,7 +831,10 @@ export function SubChatSelector({ } }} className={cn( - "group relative flex items-center text-sm rounded-md transition-colors duration-75 cursor-pointer h-6 flex-shrink-0", + "group relative flex items-center text-sm rounded-md transition-colors duration-75 h-6 flex-shrink-0", + tabIsDraggable + ? (isBeingDragged ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing") + : "cursor-pointer", "outline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70", editingSubChatId === subChat.id ? "overflow-visible px-0" @@ -768,6 +846,10 @@ export function SubChatSelector({ isInSplitPair && !isActive && "bg-muted/40 hover:bg-muted/60", isInSplitPair && hasSplitPrev && "-ml-1 rounded-l-none", isInSplitPair && hasSplitNext && "rounded-r-none", + isBeingDragged && "opacity-40", + // Insertion marker (left/right edge of hovered tab) + isDragOverHere && dragOverSide === "left" && "shadow-[inset_2px_0_0_0_hsl(var(--primary))]", + isDragOverHere && dragOverSide === "right" && "shadow-[inset_-2px_0_0_0_hsl(var(--primary))]", )} style={{ // @ts-expect-error - WebKit-specific property diff --git a/src/renderer/features/agents/ui/text-selection-popover.tsx b/src/renderer/features/agents/ui/text-selection-popover.tsx index aecebfce9..1c37480f6 100644 --- a/src/renderer/features/agents/ui/text-selection-popover.tsx +++ b/src/renderer/features/agents/ui/text-selection-popover.tsx @@ -3,6 +3,9 @@ import { useEffect, useCallback, useState, useRef } from "react" import { createPortal } from "react-dom" import { useTextSelection, type TextSelectionSource } from "../context/text-selection-context" +import { CheckIcon, CopyIcon } from "../../../components/ui/icons" +import { cn } from "../../../lib/utils" +import { useHaptic } from "../hooks/use-haptic" interface TextSelectionPopoverProps { onAddToContext: (text: string, source: TextSelectionSource) => void @@ -19,7 +22,9 @@ export function TextSelectionPopover({ useTextSelection() const [isVisible, setIsVisible] = useState(false) const [isMouseDown, setIsMouseDown] = useState(false) + const [copied, setCopied] = useState(false) const popoverRef = useRef(null) + const { trigger: triggerHaptic } = useHaptic() const handleAddToContext = useCallback(() => { if (selectedText && source) { @@ -33,6 +38,15 @@ export function TextSelectionPopover({ } }, [selectedText, source, onAddToContext, clearSelection, onFocusInput]) + const handleCopy = useCallback(() => { + if (selectedText) { + navigator.clipboard.writeText(selectedText) + triggerHaptic("medium") + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + }, [selectedText, triggerHaptic]) + const handleQuickComment = useCallback(() => { if (selectedText && source && selectionRect && onQuickComment) { onQuickComment(selectedText, source, selectionRect) @@ -93,7 +107,8 @@ export function TextSelectionPopover({ left = Math.max(popoverWidth / 2 + 8, Math.min(left, viewportWidth - popoverWidth / 2 - 8)) // Calculate actual left position accounting for centering - const popoverWidthEstimate = onQuickComment && (source.type === "diff" || source.type === "tool-edit") ? 160 : 100 + // Width estimate includes: Add to context (~85) + Copy icon button (~28) + optional Reply (~50) + const popoverWidthEstimate = onQuickComment && (source.type === "diff" || source.type === "tool-edit") ? 188 : 128 const centeredLeft = left - popoverWidthEstimate / 2 // Position above by default, below if not enough space above @@ -129,6 +144,27 @@ export function TextSelectionPopover({ > Add to context +
+ {/* Quick comment button shows for diff and tool-edit selections */} {onQuickComment && (source.type === "diff" || source.type === "tool-edit") && ( <> diff --git a/src/renderer/features/sidebar/agents-subchats-sidebar.tsx b/src/renderer/features/sidebar/agents-subchats-sidebar.tsx index dc9c83f8d..f4571b4cd 100644 --- a/src/renderer/features/sidebar/agents-subchats-sidebar.tsx +++ b/src/renderer/features/sidebar/agents-subchats-sidebar.tsx @@ -62,7 +62,7 @@ import { isDesktopApp, getShortcutKey } from "../../lib/utils/platform" import { useResolvedHotkeyDisplay } from "../../lib/hotkeys" import { TrafficLightSpacer } from "../agents/components/traffic-light-spacer" import { PopoverTrigger } from "../../components/ui/popover" -import { AlignJustify } from "lucide-react" +import { AlignJustify, GripVertical } from "lucide-react" import { ContextMenu, ContextMenuContent, @@ -92,6 +92,21 @@ import { useHotkeys } from "react-hotkeys-hook" import { useSubChatDraftsCache, getSubChatDraftKey } from "../agents/lib/drafts" import { Checkbox } from "../../components/ui/checkbox" import { TypewriterText } from "../../components/ui/typewriter-text" +import { + DndContext, + PointerSensor, + useSensor, + useSensors, + closestCenter, + type DragEndEvent, +} from "@dnd-kit/core" +import { + SortableContext, + useSortable, + verticalListSortingStrategy, + arrayMove, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" // Isolated Search History Popover for sidebar - prevents parent re-renders when popover opens/closes interface SidebarSearchHistoryPopoverProps { @@ -197,6 +212,60 @@ interface AgentsSubChatsSidebarProps { agentName?: string } +/** + * Wrapper that makes a sub-chat row draggable via @dnd-kit. + * Drag is disabled for split-pair tabs so the auto-adjacency logic in `openSubChats` + * doesn't fight the user-defined order. + * + * Sets `data-dnd-active` while dragging so consumers can skip hover effects, and + * uses an `activationConstraint` of 4px so single clicks still pass through. + */ +function SortableSubChatRow({ + id, + disabled, + children, +}: { + id: string + disabled?: boolean + children: React.ReactNode +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id, disabled }) + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + zIndex: isDragging ? 10 : undefined, + position: "relative", + } + + return ( +
+ {!disabled && ( +
+ ) +} + export function AgentsSubChatsSidebar({ onClose, isMobile = false, @@ -217,6 +286,7 @@ export function AgentsSubChatsSidebar({ addToSplit, removeFromSplit, closeSplit, + setOpenSubChats, } = useAgentSubChatStore( useShallow((state) => ({ activeSubChatId: state.activeSubChatId, @@ -229,8 +299,31 @@ export function AgentsSubChatsSidebar({ addToSplit: state.addToSplit, removeFromSplit: state.removeFromSplit, closeSplit: state.closeSplit, + setOpenSubChats: state.setOpenSubChats, })) ) + + // DnD sensors. 4px activation distance so a click doesn't get hijacked into a drag. + const dndSensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + ) + + // Handles drag-end: rebuild openSubChatIds with the new order. + // Maps the in-section reorder back to the global openSubChatIds list (search-aware). + const handleSidebarDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + const activeId = String(active.id) + const overId = String(over.id) + const fromIdx = openSubChatIds.indexOf(activeId) + const toIdx = openSubChatIds.indexOf(overId) + if (fromIdx < 0 || toIdx < 0) return + const newIds = arrayMove(openSubChatIds, fromIdx, toIdx) + setOpenSubChats(newIds) + }, + [openSubChatIds, setOpenSubChats], + ) const [loadingSubChats] = useAtom(loadingSubChatsAtom) const subChatFiles = useAtomValue(subChatFilesAtom) const selectedTeamId = useAtomValue(selectedTeamIdAtom) @@ -349,16 +442,12 @@ export function AgentsSubChatsSidebar({ return map }, [allSubChats]) - // Map open IDs to metadata and sort by updated_at (most recent first) + // Map open IDs to metadata. Order follows openSubChatIds (user-controlled via DnD) + // rather than auto-sorting by updated_at — manual reorder must stick. const openSubChats = useMemo(() => { const chats = openSubChatIds .map((id) => allSubChatsById.get(id)) .filter((sc): sc is SubChatMeta => !!sc) - .sort((a, b) => { - const aT = new Date(a.updated_at || a.created_at || "0").getTime() - const bT = new Date(b.updated_at || b.created_at || "0").getTime() - return bT - aT // Most recent first - }) if (splitPaneIds.length < 2) return chats @@ -714,26 +803,12 @@ export function AgentsSubChatsSidebar({ [renamingSubChat, renameMutation, setJustCreatedIds], ) - const handleCreateNew = async () => { + const handleCreateNew = () => { if (!parentChatId) return const store = useAgentSubChatStore.getState() - - let newId: string - - if (chatSourceMode === "sandbox") { - // Sandbox mode: lazy creation (web app pattern) - // Sub-chat will be persisted on first message via RemoteChatTransport UPSERT - newId = crypto.randomUUID() - } else { - // Local mode: create sub-chat in DB first to get the real ID - const newSubChat = await trpcClient.chats.createSubChat.mutate({ - chatId: parentChatId, - name: "New Chat", - mode: defaultAgentMode, - }) - newId = newSubChat.id - } + const newId = crypto.randomUUID() + const capturedParentChatId = parentChatId // Track this subchat as just created for typewriter effect setJustCreatedIds((prev) => new Set([...prev, newId])) @@ -741,7 +816,7 @@ export function AgentsSubChatsSidebar({ // Initialize atomFamily mode for the new sub-chat appStore.set(subChatModeAtomFamily(newId), defaultAgentMode) - // Add to allSubChats with placeholder name + // Add to allSubChats with placeholder name (optimistic — UI updates instantly) store.addToAllSubChats({ id: newId, name: "New Chat", @@ -752,6 +827,24 @@ export function AgentsSubChatsSidebar({ // Add to open tabs and set as active store.addToOpenSubChats(newId) store.setActiveSubChat(newId) + + if (chatSourceMode !== "sandbox") { + // Local mode: persist to DB in background. On failure, roll back the optimistic insert. + // Sandbox mode persists lazily on first message via RemoteChatTransport UPSERT. + // Do NOT pass `name` — leave it NULL in DB so the app-quit cleanup can + // recognize never-named, never-used sub-chats. UI displays "New Chat" via fallback. + trpcClient.chats.createSubChat + .mutate({ + id: newId, + chatId: capturedParentChatId, + mode: defaultAgentMode, + }) + .catch((error) => { + console.error("[handleCreateNew] Failed to create sub-chat:", error) + useAgentSubChatStore.getState().removeFromOpenSubChats(newId) + toast.error("Failed to create chat") + }) + } } const handleSelectFromHistory = useCallback((subChat: SubChatMeta) => { @@ -1248,6 +1341,11 @@ export function AgentsSubChatsSidebar({
+ {/* Pinned section */} {pinnedChats.length > 0 && ( <> @@ -1262,6 +1360,10 @@ export function AgentsSubChatsSidebar({
+ c.id)} + strategy={verticalListSortingStrategy} + > {pinnedChats.map((subChat) => { const isSubChatLoading = loadingChatIds.has( subChat.id, @@ -1305,7 +1407,12 @@ export function AgentsSubChatsSidebar({ : null return ( - + +
)} + ) })} +
)} @@ -1566,6 +1676,10 @@ export function AgentsSubChatsSidebar({
+ c.id)} + strategy={verticalListSortingStrategy} + > {unpinnedChats.map((subChat) => { const isSubChatLoading = loadingChatIds.has( subChat.id, @@ -1609,7 +1723,12 @@ export function AgentsSubChatsSidebar({ : null return ( - + +
)} + ) })} +
)} +
) : searchQuery.trim() ? (
From ff0af9b8f303e682da20acdc3e5c4f310e8264cf Mon Sep 17 00:00:00 2001 From: Alejandro Tamayo Castillo Date: Sat, 18 Apr 2026 17:09:33 +0200 Subject: [PATCH 21/33] feat: PR widget + comments, branch switcher popover, two-column commit diff, VS Code/terminal fixes (#24) Co-authored-by: Claude Opus 4.7 (1M context) --- src/main/lib/git/git-operations.ts | 86 ++++- src/main/lib/git/github/github.ts | 159 +++++++++ src/main/lib/git/github/index.ts | 9 +- src/main/lib/git/github/types.ts | 39 +++ src/main/lib/terminal/session.ts | 5 + src/main/lib/trpc/routers/chats.ts | 90 ++++- src/main/lib/trpc/routers/external.ts | 83 ++++- .../agents/ui/git-activity-badges.tsx | 8 +- .../features/agents/utils/git-activity.ts | 11 +- .../branch-switcher-popover.tsx | 324 ++++++++++++++++++ .../changes-panel-header.tsx | 86 +---- .../history-view/commit-diff-split.tsx | 259 ++++++++++++++ .../components/history-view/history-view.tsx | 90 +++-- .../features/details-sidebar/atoms/index.ts | 12 +- .../details-sidebar/details-sidebar.tsx | 14 +- .../sections/changes-widget.tsx | 14 +- .../details-sidebar/sections/info-section.tsx | 120 +++++-- .../sections/pr-comments-section.tsx | 142 ++++++++ .../details-sidebar/sections/pr-widget.tsx | 195 +++++++++++ .../sections/rename-pr-title-dialog.tsx | 94 +++++ .../features/sidebar/agents-sidebar.tsx | 10 + src/renderer/features/terminal/terminal.tsx | 11 +- 22 files changed, 1667 insertions(+), 194 deletions(-) create mode 100644 src/renderer/features/changes/components/branch-switcher/branch-switcher-popover.tsx create mode 100644 src/renderer/features/changes/components/history-view/commit-diff-split.tsx create mode 100644 src/renderer/features/details-sidebar/sections/pr-comments-section.tsx create mode 100644 src/renderer/features/details-sidebar/sections/pr-widget.tsx create mode 100644 src/renderer/features/details-sidebar/sections/rename-pr-title-dialog.tsx diff --git a/src/main/lib/git/git-operations.ts b/src/main/lib/git/git-operations.ts index d961956dc..6fc7c69b9 100644 --- a/src/main/lib/git/git-operations.ts +++ b/src/main/lib/git/git-operations.ts @@ -71,27 +71,79 @@ export const createGitOperationsRouter = () => { z.object({ worktreePath: z.string(), branch: z.string(), + /** "auto-stash" stashes uncommitted changes and pops them on the + * new branch; "carry" attempts a plain checkout which git will + * allow only if there's no conflict; default aborts if dirty. */ + uncommittedStrategy: z + .enum(["abort", "carry", "stash"]) + .optional() + .default("abort"), }), ) - .mutation(async ({ input }): Promise<{ success: boolean }> => { - assertRegisteredWorktree(input.worktreePath); + .mutation( + async ({ + input, + }): Promise<{ success: boolean; stashPopFailed?: boolean }> => { + assertRegisteredWorktree(input.worktreePath); - return withGitLock(input.worktreePath, async () => { - // Check for uncommitted changes before checkout - if (await hasUncommittedChanges(input.worktreePath)) { - throw new Error( - "Cannot switch branches: you have uncommitted changes. Please commit or stash your changes first." - ); - } + return withGitLock(input.worktreePath, async () => { + const dirty = await hasUncommittedChanges(input.worktreePath); - const git = createGit(input.worktreePath); - await withLockRetry(input.worktreePath, () => - git.checkout(input.branch) - ); - invalidateGitStateCaches(input.worktreePath); - return { success: true }; - }); - }), + if (dirty && input.uncommittedStrategy === "abort") { + throw new Error( + "Cannot switch branches: you have uncommitted changes. Please commit or stash your changes first." + ); + } + + const git = createGit(input.worktreePath); + + if (dirty && input.uncommittedStrategy === "stash") { + await withLockRetry(input.worktreePath, () => + git.stash([ + "push", + "-u", + "-m", + `Auto-stash before switching to ${input.branch}`, + ]), + ); + } + + try { + await withLockRetry(input.worktreePath, () => + git.checkout(input.branch) + ); + } catch (checkoutError) { + // If we stashed, try to pop back so the user doesn't lose work + if (dirty && input.uncommittedStrategy === "stash") { + try { + await git.stash(["pop"]); + } catch { + const msg = + checkoutError instanceof Error + ? checkoutError.message + : "Checkout failed"; + throw new Error( + `${msg}. Your uncommitted changes are saved in git stash — run 'git stash pop' manually to restore them.`, + ); + } + } + throw checkoutError; + } + + let stashPopFailed = false; + if (dirty && input.uncommittedStrategy === "stash") { + try { + await git.stash(["pop"]); + } catch { + stashPopFailed = true; + } + } + + invalidateGitStateCaches(input.worktreePath); + return { success: true, stashPopFailed }; + }); + }, + ), getHistory: publicProcedure .input( diff --git a/src/main/lib/git/github/github.ts b/src/main/lib/git/github/github.ts index 5de4109b9..a223919b2 100644 --- a/src/main/lib/git/github/github.ts +++ b/src/main/lib/git/github/github.ts @@ -6,8 +6,10 @@ import { type CheckItem, type GHPRResponse, type GitHubStatus, + type PRComment, GHPRResponseSchema, GHRepoResponseSchema, + GHReviewCommentSchema, } from "./types"; const execFileAsync = promisify(execFile); @@ -16,6 +18,18 @@ const execFileAsync = promisify(execFile); const cache = new Map(); const CACHE_TTL_MS = 10_000; +/** + * Drop cached PR status for a worktree so the next fetch hits the real gh CLI. + * Call this after a mutation that changes PR state (title rename, merge, etc.). + */ +export function invalidateGitHubPRCache(worktreePath?: string): void { + if (worktreePath) { + cache.delete(worktreePath); + } else { + cache.clear(); + } +} + /** * Fetches GitHub PR status for a worktree using the `gh` CLI. * Returns null if `gh` is not installed, not authenticated, or on error. @@ -228,3 +242,148 @@ function computeChecksStatus( if (hasPending) return "pending"; return "success"; } + +// Cache for PR comments (30 second TTL — comments change less often than status) +const commentsCache = new Map< + string, + { data: PRComment[]; timestamp: number } +>(); +const COMMENTS_CACHE_TTL_MS = 30_000; + +/** + * Fetch both general (issue) and review (code-level) comments for the current + * branch's PR. Returns an empty array when there's no PR or gh can't reach it. + * Cached for 30 seconds per worktree. + */ +export async function fetchGitHubPRComments( + worktreePath: string, +): Promise { + const cached = commentsCache.get(worktreePath); + if (cached && Date.now() - cached.timestamp < COMMENTS_CACHE_TTL_MS) { + return cached.data; + } + + try { + const { stdout: branchOutput } = await execFileAsync( + "git", + ["rev-parse", "--abbrev-ref", "HEAD"], + { cwd: worktreePath }, + ); + const branchName = branchOutput.trim(); + if (!branchName) return []; + + let prNumber: number | null = null; + try { + const { stdout } = await execWithShellEnv( + "gh", + ["pr", "view", branchName, "--json", "number"], + { cwd: worktreePath }, + ); + const parsed = JSON.parse(stdout); + if (typeof parsed?.number === "number") { + prNumber = parsed.number; + } + } catch { + return []; + } + if (!prNumber) return []; + + const [issueStdout, reviewStdout] = await Promise.all([ + execWithShellEnv( + "gh", + ["pr", "view", String(prNumber), "--json", "comments"], + { cwd: worktreePath }, + ) + .then((r) => r.stdout) + .catch(() => null), + execWithShellEnv( + "gh", + [ + "api", + `repos/{owner}/{repo}/pulls/${prNumber}/comments`, + "--paginate", + ], + { cwd: worktreePath }, + ) + .then((r) => r.stdout) + .catch(() => null), + ]); + + const comments: PRComment[] = []; + + if (issueStdout) { + try { + const raw = JSON.parse(issueStdout); + const rawComments = Array.isArray(raw?.comments) ? raw.comments : []; + for (const c of rawComments) { + const login = c?.author?.login ?? "unknown"; + const createdAt = c?.createdAt ?? c?.created_at ?? null; + const body = typeof c?.body === "string" ? c.body : ""; + if (!createdAt) continue; + comments.push({ + id: typeof c?.id === "number" ? c.id : comments.length, + kind: "issue", + author: login, + avatarUrl: null, + createdAt, + body, + htmlUrl: c?.url ?? null, + }); + } + } catch (err) { + console.error("[GitHub] Failed to parse issue comments:", err); + } + } + + if (reviewStdout) { + try { + const raw = JSON.parse(reviewStdout); + const arr = Array.isArray(raw) ? raw : []; + for (const c of arr) { + const parsed = GHReviewCommentSchema.safeParse(c); + if (!parsed.success) continue; + const data = parsed.data; + comments.push({ + id: data.id, + kind: "review", + author: data.user?.login ?? "unknown", + avatarUrl: data.user?.avatar_url ?? null, + createdAt: data.created_at, + body: data.body ?? "", + htmlUrl: data.html_url ?? null, + path: data.path ?? null, + line: data.line ?? data.original_line ?? null, + diffHunk: data.diff_hunk ?? null, + }); + } + } catch (err) { + console.error("[GitHub] Failed to parse review comments:", err); + } + } + + comments.sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + + commentsCache.set(worktreePath, { + data: comments, + timestamp: Date.now(), + }); + return comments; + } catch (err) { + console.error("[GitHub] fetchGitHubPRComments failed:", err); + return []; + } +} + +/** + * Invalidate the PR comments cache for a worktree. + */ +export function invalidateGitHubPRCommentsCache(worktreePath?: string): void { + if (worktreePath) { + commentsCache.delete(worktreePath); + } else { + commentsCache.clear(); + } +} diff --git a/src/main/lib/git/github/index.ts b/src/main/lib/git/github/index.ts index 89b109a9c..c1d165175 100644 --- a/src/main/lib/git/github/index.ts +++ b/src/main/lib/git/github/index.ts @@ -1,2 +1,7 @@ -export { fetchGitHubPRStatus } from "./github"; -export type { CheckItem, GitHubStatus, MergeableStatus } from "./types"; +export { + fetchGitHubPRStatus, + fetchGitHubPRComments, + invalidateGitHubPRCache, + invalidateGitHubPRCommentsCache, +} from "./github"; +export type { CheckItem, GitHubStatus, MergeableStatus, PRComment } from "./types"; diff --git a/src/main/lib/git/github/types.ts b/src/main/lib/git/github/types.ts index 77c70d850..0329680cc 100644 --- a/src/main/lib/git/github/types.ts +++ b/src/main/lib/git/github/types.ts @@ -47,6 +47,45 @@ export const GHRepoResponseSchema = z.object({ url: z.string(), }); +/** Issue comment on a PR (from GET /repos/{owner}/{repo}/issues/{n}/comments) */ +export const GHIssueCommentSchema = z.object({ + id: z.number(), + body: z.string().nullable(), + created_at: z.string(), + updated_at: z.string().nullable().optional(), + html_url: z.string().nullable().optional(), + user: z + .object({ + login: z.string(), + avatar_url: z.string().nullable().optional(), + }) + .nullable() + .optional(), +}); + +/** Review comment on a PR (from GET /repos/{owner}/{repo}/pulls/{n}/comments) */ +export const GHReviewCommentSchema = GHIssueCommentSchema.extend({ + path: z.string().nullable().optional(), + line: z.number().nullable().optional(), + original_line: z.number().nullable().optional(), + diff_hunk: z.string().nullable().optional(), + position: z.number().nullable().optional(), +}); + +/** Unified comment type returned from the backend to the renderer */ +export interface PRComment { + id: number; + kind: "issue" | "review"; + author: string; + avatarUrl?: string | null; + createdAt: string; + body: string; + htmlUrl?: string | null; + path?: string | null; + line?: number | null; + diffHunk?: string | null; +} + export type GHPRResponse = z.infer; /** Single CI/CD check item */ diff --git a/src/main/lib/terminal/session.ts b/src/main/lib/terminal/session.ts index f5f057b7c..00751dede 100644 --- a/src/main/lib/terminal/session.ts +++ b/src/main/lib/terminal/session.ts @@ -130,6 +130,11 @@ export async function createSession( } = params const shell = useFallbackShell ? FALLBACK_SHELL : getDefaultShell() + if (!cwd) { + console.warn( + `[Terminal] No cwd provided for paneId=${paneId} — falling back to ${os.homedir()}. This usually means the workspace path wasn't ready when the terminal was created.`, + ) + } const workingDir = validateAndResolveCwd(cwd || os.homedir()) const terminalCols = cols || DEFAULT_COLS const terminalRows = rows || DEFAULT_ROWS diff --git a/src/main/lib/trpc/routers/chats.ts b/src/main/lib/trpc/routers/chats.ts index c3f19a396..f3aad2ad9 100644 --- a/src/main/lib/trpc/routers/chats.ts +++ b/src/main/lib/trpc/routers/chats.ts @@ -15,8 +15,10 @@ import { chats, getDatabase, projects, subChats } from "../../db" import { computeFileStatsFromMessages } from "../../file-stats" import { createWorktreeForChat, + fetchGitHubPRComments, fetchGitHubPRStatus, getWorktreeDiff, + invalidateGitHubPRCache, removeWorktree, sanitizeProjectName, } from "../../git" @@ -1587,7 +1589,11 @@ export const chatsRouter = router({ }), /** - * Get PR status from GitHub (via gh CLI) + * Get PR status from GitHub (via gh CLI). + * + * Back-fills `chat.prNumber` and `chat.prUrl` when the live fetch detects a + * PR — this keeps the sidebar workspace card (which reads from the DB) in + * sync without requiring callers to write those columns manually. */ getPrStatus: publicProcedure .input(z.object({ chatId: z.string() })) @@ -1603,7 +1609,24 @@ export const chatsRouter = router({ return null } - return await fetchGitHubPRStatus(chat.worktreePath) + const status = await fetchGitHubPRStatus(chat.worktreePath) + + // Back-fill DB so the sidebar badge can render from cached fields + const pr = status?.pr + const nextNumber = pr?.number ?? null + const nextUrl = pr?.url ?? null + if (nextNumber !== chat.prNumber || nextUrl !== chat.prUrl) { + try { + db.update(chats) + .set({ prNumber: nextNumber, prUrl: nextUrl }) + .where(eq(chats.id, input.chatId)) + .run() + } catch (err) { + console.error("[getPrStatus] Failed to back-fill PR fields:", err) + } + } + + return status }), /** @@ -1672,6 +1695,69 @@ export const chatsRouter = router({ } }), + /** + * Fetch issue + review comments for the current branch's PR. + */ + getPrComments: publicProcedure + .input(z.object({ chatId: z.string() })) + .query(async ({ input }) => { + const db = getDatabase() + const chat = db + .select() + .from(chats) + .where(eq(chats.id, input.chatId)) + .get() + + if (!chat?.worktreePath) return [] + return await fetchGitHubPRComments(chat.worktreePath) + }), + + /** + * Rename a PR title via `gh pr edit`. + * + * Caller passes the PR number explicitly so that switching branches + * between opening the dialog and saving can't rename the wrong PR. + * Falls back to the current-branch PR when `prNumber` is omitted. + */ + updatePrTitle: publicProcedure + .input( + z.object({ + chatId: z.string(), + title: z.string().trim().min(1).max(256), + prNumber: z.number().int().positive().optional(), + }), + ) + .mutation(async ({ input }) => { + const db = getDatabase() + const chat = db + .select() + .from(chats) + .where(eq(chats.id, input.chatId)) + .get() + + if (!chat?.worktreePath) { + throw new Error("No worktree path for this chat") + } + + const args = input.prNumber + ? ["pr", "edit", String(input.prNumber), "--title", input.title] + : ["pr", "edit", "--title", input.title] + + try { + await execWithShellEnv("gh", args, { cwd: chat.worktreePath }) + invalidateGitHubPRCache(chat.worktreePath) + return { success: true, title: input.title } + } catch (error) { + const errorMsg = + error instanceof Error ? error.message : "Failed to update PR title" + console.error("[updatePrTitle] Error:", error) + if (errorMsg.includes("no pull requests found")) { + throw new Error("No pull request exists for the current branch") + } + throw new Error(errorMsg) + } + }), + /** * Get file change stats for workspaces. * diff --git a/src/main/lib/trpc/routers/external.ts b/src/main/lib/trpc/routers/external.ts index 3a16b33db..b1b9318fb 100644 --- a/src/main/lib/trpc/routers/external.ts +++ b/src/main/lib/trpc/routers/external.ts @@ -1,5 +1,6 @@ import { clipboard, shell } from "electron"; -import { execFileSync, spawn } from "node:child_process"; +import { execFile, execFileSync, spawn } from "node:child_process"; +import { promisify } from "node:util"; import * as os from "node:os"; import * as path from "node:path"; import { z } from "zod"; @@ -9,6 +10,9 @@ import { externalAppSchema, type ExternalApp, } from "../../../../shared/external-apps"; +import { execWithShellEnv } from "../../git/shell-env"; + +const execFileAsync = promisify(execFile); function expandTilde(filePath: string): string { if (filePath.startsWith("~/") || filePath === "~") { @@ -17,29 +21,90 @@ function expandTilde(filePath: string): string { return filePath; } -function spawnAsync(command: string, args: string[]): Promise { +// CLI name per editor when one exists. Preferred over `open -a` because the +// `.app` bundle may be missing on systems installed via brew / standalone CLI. +const APP_CLI: Partial> = { + vscode: "code", + "vscode-insiders": "code-insiders", + cursor: "cursor", + windsurf: "windsurf", + zed: "zed", + sublime: "subl", + trae: "trae", + fleet: "fleet", + intellij: "idea", + webstorm: "webstorm", + pycharm: "pycharm", + phpstorm: "phpstorm", + rubymine: "rubymine", + goland: "goland", + clion: "clion", + rider: "rider", + datagrip: "datagrip", + appcode: "appcode", + rustrover: "rustrover", +}; + +function spawnDetached(command: string, args: string[]): Promise { return new Promise((resolve, reject) => { + let settled = false; const child = spawn(command, args, { detached: true, stdio: "ignore", }); - child.unref(); - child.on("error", reject); - // Resolve immediately — we just need to launch the app - resolve(); + child.on("error", (err) => { + if (settled) return; + settled = true; + reject(err); + }); + child.on("spawn", () => { + if (settled) return; + settled = true; + child.unref(); + resolve(); + }); }); } -function openPathInApp(app: ExternalApp, targetPath: string): Promise { +async function hasCommand(command: string): Promise { + try { + // execWithShellEnv lazily fixes process.env.PATH on ENOENT so homebrew/user- + // local CLIs work even when launched from Finder/Dock (minimal GUI PATH). + await execWithShellEnv("which", [command]); + return true; + } catch { + return false; + } +} + +async function openPathInApp( + app: ExternalApp, + targetPath: string, +): Promise { const expandedPath = expandTilde(targetPath); if (app === "finder") { shell.showItemInFolder(expandedPath); - return Promise.resolve(); + return; + } + + const cliCommand = APP_CLI[app]; + if (cliCommand && (await hasCommand(cliCommand))) { + try { + await spawnDetached(cliCommand, [expandedPath]); + return; + } catch (err) { + console.warn( + `[external] ${cliCommand} failed, falling back to 'open -a':`, + err, + ); + } } const meta = APP_META[app]; - return spawnAsync("open", ["-a", meta.macAppName, expandedPath]); + // `open -a` exits non-zero when the .app bundle isn't found — awaiting + // execFileAsync surfaces that as a thrown error instead of silent failure. + await execFileAsync("open", ["-a", meta.macAppName, expandedPath]); } /** diff --git a/src/renderer/features/agents/ui/git-activity-badges.tsx b/src/renderer/features/agents/ui/git-activity-badges.tsx index b3b108811..bf10d2d9e 100644 --- a/src/renderer/features/agents/ui/git-activity-badges.tsx +++ b/src/renderer/features/agents/ui/git-activity-badges.tsx @@ -1,7 +1,7 @@ "use client" import { memo, useCallback, useMemo, useState } from "react" -import { GitCommit, GitPullRequest } from "lucide-react" +import { GitBranch, GitCommit, GitPullRequest } from "lucide-react" import { useAtomValue, useSetAtom } from "jotai" import { AnimatePresence, motion } from "motion/react" import { @@ -220,6 +220,12 @@ export const GitActivityBadges = memo(function GitActivityBadges({ > {activity.title} + {activity.branch && ( + + + {activity.branch} + + )} )}
diff --git a/src/renderer/features/agents/utils/git-activity.ts b/src/renderer/features/agents/utils/git-activity.ts index 10c0aedc6..65620556f 100644 --- a/src/renderer/features/agents/utils/git-activity.ts +++ b/src/renderer/features/agents/utils/git-activity.ts @@ -10,6 +10,7 @@ export interface GitPrInfo { title: string url: string number?: number + branch?: string } export type GitActivity = GitCommitInfo | GitPrInfo @@ -81,7 +82,15 @@ function extractPrInfo(command: string, stdout: string): GitPrInfo | null { const titleMatch = command.match(/--title\s+["']([^"']+)["']/) const title = titleMatch?.[1] || `PR #${number || ""}` - return { type: "pr", title, url, number } + // Extract branch: either an explicit --head flag, or the branch gh reports + // in its "Creating pull request for into " preamble. + const headFlagMatch = command.match(/--head\s+["']?([^\s"']+)["']?/) + const preambleMatch = stdout.match( + /Creating (?:draft )?pull request for ([^\s]+) into /, + ) + const branch = headFlagMatch?.[1] || preambleMatch?.[1] || undefined + + return { type: "pr", title, url, number, branch } } /** diff --git a/src/renderer/features/changes/components/branch-switcher/branch-switcher-popover.tsx b/src/renderer/features/changes/components/branch-switcher/branch-switcher-popover.tsx new file mode 100644 index 000000000..289f4a95c --- /dev/null +++ b/src/renderer/features/changes/components/branch-switcher/branch-switcher-popover.tsx @@ -0,0 +1,324 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { Check } from "lucide-react"; +import { LuGitBranch } from "react-icons/lu"; +import { HiChevronDown } from "react-icons/hi2"; +import { SearchIcon } from "../../../../components/ui/icons"; +import { Popover, PopoverContent, PopoverTrigger } from "../../../../components/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../../../../components/ui/tooltip"; +import { Button } from "../../../../components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../../../../components/ui/dialog"; +import { toast } from "sonner"; +import { trpc } from "../../../../lib/trpc"; +import { cn } from "../../../../lib/utils"; +import { formatTimeAgo } from "../../../../lib/utils/format-time-ago"; + +interface BranchEntry { + name: string; + type: "local" | "remote"; + isDefault: boolean; + committedAt: string | null; +} + +interface BranchSwitcherPopoverProps { + worktreePath: string; + currentBranch: string; + compact?: boolean; +} + +type PendingSwitch = { + branch: string; + dirty: boolean; +}; + +export function BranchSwitcherPopover({ + worktreePath, + currentBranch, + compact = false, +}: BranchSwitcherPopoverProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [pending, setPending] = useState(null); + const listRef = useRef(null); + + const utils = trpc.useUtils(); + + const branchesQuery = trpc.changes.getBranches.useQuery( + { worktreePath }, + { enabled: !!worktreePath && open }, + ); + + const statusQuery = trpc.changes.getStatus.useQuery( + { worktreePath }, + { enabled: !!worktreePath, staleTime: 2000 }, + ); + + const checkoutMutation = trpc.changes.checkout.useMutation({ + onSuccess: (result, vars) => { + utils.changes.getBranches.invalidate({ worktreePath }); + utils.changes.getStatus.invalidate({ worktreePath }); + utils.changes.getGitHubStatus.invalidate({ worktreePath }); + utils.chats.getPrStatus.invalidate(); + if (result.stashPopFailed) { + toast.warning("Switched branch, but couldn't restore stashed changes", { + description: + "Your changes are saved in git stash. Run `git stash pop` manually to resolve the conflict.", + }); + } else { + toast.success(`Switched to ${vars.branch}`); + } + setPending(null); + }, + onError: (error) => { + toast.error("Failed to switch branch", { + description: error.message, + }); + setPending(null); + }, + }); + + const branches: BranchEntry[] = useMemo(() => { + if (!branchesQuery.data) return []; + const { local, remote, defaultBranch } = branchesQuery.data; + const result: BranchEntry[] = []; + for (const { branch, lastCommitDate } of local) { + result.push({ + name: branch, + type: "local", + isDefault: branch === defaultBranch, + committedAt: lastCommitDate ? new Date(lastCommitDate).toISOString() : null, + }); + } + for (const name of remote) { + result.push({ + name, + type: "remote", + isDefault: name === defaultBranch, + committedAt: null, + }); + } + return result.sort((a, b) => { + if (a.isDefault && !b.isDefault) return -1; + if (!a.isDefault && b.isDefault) return 1; + if (a.type !== b.type) return a.type === "local" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + }, [branchesQuery.data]); + + const filtered = useMemo(() => { + if (!search.trim()) return branches; + const q = search.toLowerCase(); + return branches.filter((b) => b.name.toLowerCase().includes(q)); + }, [branches, search]); + + const virtualizer = useVirtualizer({ + count: filtered.length, + getScrollElement: () => listRef.current, + estimateSize: () => 32, + overscan: 5, + enabled: open, + }); + + useEffect(() => { + if (open) { + const t = setTimeout(() => virtualizer.measure(), 0); + return () => clearTimeout(t); + } + }, [open, virtualizer]); + + const handleSelect = (branch: string) => { + if (branch === currentBranch) { + setOpen(false); + return; + } + setOpen(false); + setSearch(""); + + const status = statusQuery.data; + const dirty = !!status && ( + status.staged.length > 0 || + status.unstaged.length > 0 || + status.untracked.length > 0 + ); + + if (dirty) { + setPending({ branch, dirty: true }); + } else { + checkoutMutation.mutate({ worktreePath, branch, uncommittedStrategy: "abort" }); + } + }; + + const runSwitch = (strategy: "carry" | "stash") => { + if (!pending) return; + checkoutMutation.mutate({ + worktreePath, + branch: pending.branch, + uncommittedStrategy: strategy, + }); + }; + + return ( + <> + { + if (!next) setSearch(""); + setOpen(next); + }} + > + + + + + + + Switch branch + + +
+ + setSearch(e.target.value)} + className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + autoFocus + /> +
+ + {branchesQuery.isLoading ? ( +
+ Loading branches... +
+ ) : filtered.length === 0 ? ( +
+ No branches found. +
+ ) : ( +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const branch = filtered[virtualItem.index]!; + const isCurrent = branch.name === currentBranch && branch.type === "local"; + return ( + + ); + })} +
+
+ )} +
+
+ + { + if (!open) setPending(null); + }} + > + + + Uncommitted changes + + You have uncommitted changes in this worktree. How should they be handled when switching to{" "} + {pending?.branch}? + + + + + + + + + + + ); +} diff --git a/src/renderer/features/changes/components/changes-panel-header/changes-panel-header.tsx b/src/renderer/features/changes/components/changes-panel-header/changes-panel-header.tsx index e2be67e7c..3ae971e1b 100644 --- a/src/renderer/features/changes/components/changes-panel-header/changes-panel-header.tsx +++ b/src/renderer/features/changes/components/changes-panel-header/changes-panel-header.tsx @@ -1,19 +1,12 @@ import { Button } from "../../../../components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "../../../../components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../../../components/ui/tooltip"; import { useEffect, useRef, useState } from "react"; -import { HiArrowPath, HiChevronDown } from "react-icons/hi2"; -import { LuGitBranch, LuGitPullRequest } from "react-icons/lu"; +import { HiArrowPath } from "react-icons/hi2"; import { trpc } from "../../../../lib/trpc"; import { cn } from "../../../../lib/utils"; import { usePRStatus } from "../../../../hooks/usePRStatus"; import { PRIcon } from "../pr-icon"; +import { BranchSwitcherPopover } from "../branch-switcher/branch-switcher-popover"; type LayoutMode = "compact" | "standard" | "wide" | "full"; @@ -46,7 +39,7 @@ export function ChangesPanelHeader({ const utils = trpc.useUtils(); - const { data: branchData, refetch: refetchBranches } = trpc.changes.getBranches.useQuery( + const { refetch: refetchBranches } = trpc.changes.getBranches.useQuery( { worktreePath }, { enabled: !!worktreePath }, ); @@ -55,14 +48,7 @@ export function ChangesPanelHeader({ onSuccess: () => { setLastFetchTime(new Date()); refetchBranches(); - }, - }); - - const checkoutMutation = trpc.changes.checkout.useMutation({ - onSuccess: () => { - refetchBranches(); - utils.changes.getGitHubStatus.invalidate({ worktreePath }); - utils.chats.getPrStatus.invalidate(); + utils.changes.getStatus.invalidate({ worktreePath }); }, }); @@ -97,18 +83,12 @@ export function ChangesPanelHeader({ ); }; - const handleBranchSelect = (branch: string) => { - if (branch === currentBranch) return; - checkoutMutation.mutate({ worktreePath, branch }); - }; - useEffect(() => { return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }; }, []); - const branches = branchData?.local ?? []; const isCompact = layoutMode === "compact"; return ( @@ -119,59 +99,11 @@ export function ChangesPanelHeader({ )} > {/* Branch selector */} - - - - - - - - Switch branch - - - {branches.map((branchInfo) => ( - handleBranchSelect(branchInfo.branch)} - className={cn( - "text-xs", - branchInfo.branch === currentBranch && "bg-accent", - )} - > - - {branchInfo.branch} - {branchInfo.branch === branchData?.defaultBranch && ( - - default - - )} - - ))} - {branches.length > 0 && } - { - // TODO: Implement create branch dialog - }} - className="text-xs" - > - - Create new branch... - - - + {/* Right side: PR status + Fetch */}
diff --git a/src/renderer/features/changes/components/history-view/commit-diff-split.tsx b/src/renderer/features/changes/components/history-view/commit-diff-split.tsx new file mode 100644 index 000000000..c6d6f9817 --- /dev/null +++ b/src/renderer/features/changes/components/history-view/commit-diff-split.tsx @@ -0,0 +1,259 @@ +"use client" + +import { memo, useCallback, useEffect, useRef, useState } from "react" +import { useAtom } from "jotai" +import { atomWithStorage } from "jotai/utils" +import { FileText } from "lucide-react" +import { IconSpinner } from "@/components/ui/icons" +import { cn } from "@/lib/utils" +import { trpc } from "@/lib/trpc" +import type { ChangedFile } from "@/../shared/changes-types" +import { getStatusIndicator } from "../../utils/status" + +// Persist the left-column width across sessions +const commitDiffSplitWidthAtom = atomWithStorage( + "changes:commitDiffSplitWidth", + 280, + undefined, + { getOnInit: true }, +) + +interface CommitDiffSplitProps { + worktreePath: string + commitHash: string + files: ChangedFile[] + selectedFilePath?: string | null + onFileSelect?: (file: ChangedFile) => void +} + +const MIN_LEFT = 180 +const MIN_RIGHT = 240 + +export const CommitDiffSplit = memo(function CommitDiffSplit({ + worktreePath, + commitHash, + files, + selectedFilePath, + onFileSelect, +}: CommitDiffSplitProps) { + const containerRef = useRef(null) + const [leftWidth, setLeftWidth] = useAtom(commitDiffSplitWidthAtom) + const [isDragging, setIsDragging] = useState(false) + + const handleFileClick = useCallback( + (file: ChangedFile) => { + onFileSelect?.(file) + }, + [onFileSelect], + ) + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + setIsDragging(true) + }, + [], + ) + + useEffect(() => { + if (!isDragging) return + + const onMouseMove = (e: MouseEvent) => { + const container = containerRef.current + if (!container) return + const rect = container.getBoundingClientRect() + let next = e.clientX - rect.left + const maxLeft = rect.width - MIN_RIGHT + if (next < MIN_LEFT) next = MIN_LEFT + if (next > maxLeft) next = maxLeft + setLeftWidth(next) + } + + const onMouseUp = () => setIsDragging(false) + + window.addEventListener("mousemove", onMouseMove) + window.addEventListener("mouseup", onMouseUp) + document.body.style.cursor = "col-resize" + document.body.style.userSelect = "none" + + return () => { + window.removeEventListener("mousemove", onMouseMove) + window.removeEventListener("mouseup", onMouseUp) + document.body.style.cursor = "" + document.body.style.userSelect = "" + } + }, [isDragging, setLeftWidth]) + + return ( +
+ {/* Left: file list */} +
+ {files.length === 0 ? ( +
+ No files in this commit. +
+ ) : ( + files.map((file) => ( + handleFileClick(file)} + /> + )) + )} +
+ + {/* Resize handle */} +
+ + {/* Right: diff */} +
+ {selectedFilePath ? ( + + ) : ( +
+ Select a file to view its diff. +
+ )} +
+
+ ) +}) + +const CommitFileRow = memo(function CommitFileRow({ + file, + isSelected, + onClick, +}: { + file: ChangedFile + isSelected: boolean + onClick: () => void +}) { + const fileName = file.path.split("/").pop() || file.path + const dirPath = file.path.includes("/") + ? file.path.substring(0, file.path.lastIndexOf("/")) + : "" + + return ( + + ) +}) + +const CommitFileDiff = memo(function CommitFileDiff({ + worktreePath, + commitHash, + filePath, +}: { + worktreePath: string + commitHash: string + filePath: string +}) { + const { data, isLoading, error } = trpc.changes.getCommitFileDiff.useQuery( + { worktreePath, commitHash, filePath }, + { enabled: !!worktreePath && !!commitHash && !!filePath, staleTime: 60_000 }, + ) + + if (isLoading && !data) { + return ( +
+ +
+ ) + } + if (error) { + return ( +
+ Failed to load diff: {error.message} +
+ ) + } + if (!data || data.trim() === "") { + return ( +
+ No diff available for this file. +
+ ) + } + + return ( +
+
+ + {filePath} +
+
+        {data.split("\n").map((line, i) => {
+          let toneClass = ""
+          if (line.startsWith("+") && !line.startsWith("+++")) {
+            toneClass = "text-emerald-700 dark:text-emerald-400 bg-emerald-500/5"
+          } else if (line.startsWith("-") && !line.startsWith("---")) {
+            toneClass = "text-red-700 dark:text-red-400 bg-red-500/5"
+          } else if (line.startsWith("@@")) {
+            toneClass = "text-sky-700 dark:text-sky-400"
+          } else if (
+            line.startsWith("diff ") ||
+            line.startsWith("+++") ||
+            line.startsWith("---") ||
+            line.startsWith("index ")
+          ) {
+            toneClass = "text-muted-foreground"
+          }
+          return (
+            
+ {line || "\u00A0"} +
+ ) + })} +
+
+ ) +}) diff --git a/src/renderer/features/changes/components/history-view/history-view.tsx b/src/renderer/features/changes/components/history-view/history-view.tsx index 66f9f3661..40a9388cc 100644 --- a/src/renderer/features/changes/components/history-view/history-view.tsx +++ b/src/renderer/features/changes/components/history-view/history-view.tsx @@ -1,9 +1,8 @@ import { memo, useMemo, useCallback, useEffect } from "react"; import { trpc } from "../../../../lib/trpc"; import { formatRelativeDate } from "../../utils/date"; -import { FileText, ArrowUp } from "lucide-react"; +import { ArrowUp } from "lucide-react"; import { cn } from "../../../../lib/utils"; -import { getStatusIndicator } from "../../utils/status"; import { Button } from "../../../../components/ui/button"; import type { ChangedFile } from "../../../../../shared/changes-types"; import { @@ -15,6 +14,7 @@ import { import { toast } from "sonner"; import { useAtomValue } from "jotai"; import { selectedProjectAtom } from "../../../agents/atoms"; +import { CommitDiffSplit } from "./commit-diff-split"; export interface CommitInfo { hash: string; @@ -135,7 +135,7 @@ export const HistoryView = memo(function HistoryView({ } return ( -
+
{/* Worktree not registered warning */} {isWorktreeRegistered === false && worktreePath && (
@@ -143,16 +143,42 @@ export const HistoryView = memo(function HistoryView({
)} - {/* Commits list - only commits, files are shown in right panel */} - {commits.map((commit, index) => ( - handleCommitClick(commit)} - /> - ))} + {/* Commits list — fixed ~40% of the pane so the diff split has room. */} +
+ {commits.map((commit, index) => ( + handleCommitClick(commit)} + /> + ))} +
+ + {/* Two-column file list + diff for the selected commit */} + {selectedCommitHash && ( + isLoadingFiles && !commitFiles ? ( +
+ Loading files… +
+ ) : filesError ? ( +
+ Failed to load files: {filesError.message} +
+ ) : ( + handleFileClick(file)} + /> + ) + )}
); }); @@ -245,41 +271,3 @@ const HistoryCommitItem = memo(function HistoryCommitItem({ ); }); -const CommitFileItem = memo(function CommitFileItem({ - file, - isSelected, - onClick, -}: { - file: ChangedFile; - isSelected: boolean; - onClick: () => void; -}) { - const fileName = file.path.split("/").pop() || file.path; - const dirPath = file.path.includes("/") - ? file.path.substring(0, file.path.lastIndexOf("/")) - : ""; - - return ( -
- -
- {dirPath && ( - - {dirPath}/ - - )} - - {fileName} - -
-
{getStatusIndicator(file.status)}
-
- ); -}); diff --git a/src/renderer/features/details-sidebar/atoms/index.ts b/src/renderer/features/details-sidebar/atoms/index.ts index 787e05627..415aa2706 100644 --- a/src/renderer/features/details-sidebar/atoms/index.ts +++ b/src/renderer/features/details-sidebar/atoms/index.ts @@ -2,14 +2,21 @@ import { atom } from "jotai" import { atomFamily, atomWithStorage } from "jotai/utils" import { atomWithWindowStorage } from "../../../lib/window-storage" import type { LucideIcon } from "lucide-react" -import { Box, FileText, Terminal, FileDiff, ListTodo } from "lucide-react" +import { + Box, + FileText, + Terminal, + FileDiff, + ListTodo, + GitPullRequest, +} from "lucide-react" import { OriginalMCPIcon } from "../../../components/ui/icons" // ============================================================================ // Widget System Types & Registry // ============================================================================ -export type WidgetId = "info" | "todo" | "plan" | "terminal" | "diff" | "mcp" +export type WidgetId = "info" | "todo" | "plan" | "terminal" | "diff" | "mcp" | "pr" export interface WidgetConfig { id: WidgetId @@ -21,6 +28,7 @@ export interface WidgetConfig { export const WIDGET_REGISTRY: WidgetConfig[] = [ { id: "info", label: "Workspace", icon: Box, canExpand: false, defaultVisible: true }, + { id: "pr", label: "Pull Request", icon: GitPullRequest, canExpand: false, defaultVisible: false }, { id: "todo", label: "To-dos", icon: ListTodo, canExpand: false, defaultVisible: true }, { id: "plan", label: "Plan", icon: FileText, canExpand: true, defaultVisible: true }, { id: "terminal", label: "Terminal", icon: Terminal, canExpand: true, defaultVisible: false }, diff --git a/src/renderer/features/details-sidebar/details-sidebar.tsx b/src/renderer/features/details-sidebar/details-sidebar.tsx index a3e1ccdf8..ecae42d69 100644 --- a/src/renderer/features/details-sidebar/details-sidebar.tsx +++ b/src/renderer/features/details-sidebar/details-sidebar.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useAtom, useAtomValue, useSetAtom } from "jotai" -import { ArrowUpRight, TerminalSquare, Box, ListTodo } from "lucide-react" +import { ArrowUpRight, TerminalSquare, Box, ListTodo, GitPullRequest } from "lucide-react" import { ResizableSidebar } from "@/components/ui/resizable-sidebar" import { Button } from "@/components/ui/button" import { @@ -39,6 +39,7 @@ import { PlanWidget } from "./sections/plan-widget" import { TerminalWidget } from "./sections/terminal-widget" import { ChangesWidget } from "./sections/changes-widget" import { McpWidget } from "./sections/mcp-widget" +import { PrWidget } from "./sections/pr-widget" import { FilesTab, type FilesTabHandle } from "./sections/files-tab" import type { ParsedDiffFile } from "./types" import { fileViewerOpenAtomFamily, type AgentMode } from "../agents/atoms" @@ -65,6 +66,8 @@ function getWidgetIcon(widgetId: WidgetId) { return DiffIcon case "mcp": return OriginalMCPIcon + case "pr": + return GitPullRequest default: return Box } @@ -501,6 +504,15 @@ export function DetailsSidebar({ /> ) + case "pr": + // Only show for local chats with a worktree + if (!worktreePath) return null + return ( + + + + ) + case "mcp": return ( Changes - {currentBranch && ( + {currentBranch && worktreePath ? ( + + on + + + ) : currentBranch ? ( on {currentBranch} - )} + ) : null}
{/* Stats in header - total lines changed */} diff --git a/src/renderer/features/details-sidebar/sections/info-section.tsx b/src/renderer/features/details-sidebar/sections/info-section.tsx index 240992d5c..0fd74c30d 100644 --- a/src/renderer/features/details-sidebar/sections/info-section.tsx +++ b/src/renderer/features/details-sidebar/sections/info-section.tsx @@ -2,12 +2,14 @@ import { memo, useState, useCallback, useEffect } from "react" import { useAtomValue } from "jotai" +import { Pencil } from "lucide-react" import { GitBranchFilledIcon, FolderFilledIcon, GitPullRequestFilledIcon, ExternalLinkIcon, } from "@/components/ui/icons" +import { RenamePrTitleDialog } from "./rename-pr-title-dialog" import { Kbd } from "@/components/ui/kbd" import { Tooltip, @@ -19,6 +21,7 @@ import { preferredEditorAtom } from "@/lib/atoms" import { useResolvedHotkeyDisplay } from "@/lib/hotkeys" import { APP_META } from "../../../../shared/external-apps" import { EDITOR_ICONS } from "@/lib/editor-icons" +import { toast } from "sonner" interface InfoSectionProps { chatId: string @@ -41,6 +44,7 @@ function PropertyRow({ onClick, copyable, tooltip, + badge, }: { icon: React.ComponentType<{ className?: string }> label: string @@ -50,6 +54,8 @@ function PropertyRow({ copyable?: boolean /** Tooltip to show on hover (for clickable items) */ tooltip?: string + /** Optional trailing element rendered next to the value (e.g. branch pill on PR row) */ + badge?: React.ReactNode }) { const [showCopied, setShowCopied] = useState(false) @@ -80,6 +86,28 @@ function PropertyRow({ ) + const wrappedValue = copyable ? ( + + + {valueEl} + + + {showCopied ? "Copied" : "Click to copy"} + + + ) : tooltip ? ( + + + {valueEl} + + + {tooltip} + + + ) : ( + valueEl + ) + return (
{/* Label column - fixed width */} @@ -88,28 +116,9 @@ function PropertyRow({ {label}
{/* Value column - flexible */} -
- {copyable ? ( - - - {valueEl} - - - {showCopied ? "Copied" : "Click to copy"} - - - ) : tooltip ? ( - - - {valueEl} - - - {tooltip} - - - ) : ( - valueEl - )} +
+
{wrappedValue}
+ {badge}
) @@ -129,13 +138,22 @@ export const InfoSection = memo(function InfoSection({ // Extract folder name from path const folderName = worktreePath?.split("/").pop() || "Unknown" + const [isRenamePrOpen, setIsRenamePrOpen] = useState(false) + // Preferred editor from settings const preferredEditor = useAtomValue(preferredEditorAtom) const editorMeta = APP_META[preferredEditor] // Mutations const openInFinderMutation = trpc.external.openInFinder.useMutation() - const openInAppMutation = trpc.external.openInApp.useMutation() + const openInAppMutation = trpc.external.openInApp.useMutation({ + onError: (error, vars) => { + const appLabel = APP_META[vars.app]?.label ?? vars.app + toast.error(`Couldn't open ${appLabel}`, { + description: error.message || "Make sure the app is installed and its CLI is on your PATH.", + }) + }, + }) // Check if this is a remote sandbox chat (no local worktree) const isRemoteChat = !worktreePath && !!remoteInfo @@ -171,7 +189,10 @@ export const InfoSection = memo(function InfoSection({ } } - const isWorktree = !!worktreePath && worktreePath.includes(".21st/worktrees") + // Show the "Open in editor" row for any local chat with a repo path, + // whether that path is a worktree (~/.21st/worktrees/...) or the project + // folder itself (project-mode chats). + const canOpenInEditor = !!worktreePath const openInEditorHotkey = useResolvedHotkeyDisplay("open-in-editor") const handleOpenInEditor = useCallback(() => { @@ -182,11 +203,11 @@ export const InfoSection = memo(function InfoSection({ // Listen for ⌘O hotkey event useEffect(() => { - if (!isWorktree) return + if (!canOpenInEditor) return const handler = () => handleOpenInEditor() window.addEventListener("open-in-editor", handler) return () => window.removeEventListener("open-in-editor", handler) - }, [isWorktree, handleOpenInEditor]) + }, [canOpenInEditor, handleOpenInEditor]) const handleOpenPr = () => { if (pr?.url) { @@ -274,6 +295,49 @@ export const InfoSection = memo(function InfoSection({ title={pr.title} onClick={handleOpenPr} tooltip="Open in GitHub" + badge={ +
+ {branchName && ( + + + + + {branchName} + + + + PR branch: {branchName} + + + )} + + + + + + Rename PR title + + +
+ } + /> + )} + {pr && ( + )} {/* Path - only for local chats */} @@ -287,8 +351,8 @@ export const InfoSection = memo(function InfoSection({ tooltip="Open in Finder" /> )} - {/* Open in Editor - only for actual git worktrees (under ~/.21st/worktrees/) */} - {isWorktree && ( + {/* Open in Editor — any local chat with a repo path (project or worktree) */} + {canOpenInEditor && (
diff --git a/src/renderer/features/details-sidebar/sections/pr-comments-section.tsx b/src/renderer/features/details-sidebar/sections/pr-comments-section.tsx new file mode 100644 index 000000000..068a0f531 --- /dev/null +++ b/src/renderer/features/details-sidebar/sections/pr-comments-section.tsx @@ -0,0 +1,142 @@ +"use client" + +import { trpc } from "@/lib/trpc" +import { IconSpinner } from "@/components/ui/icons" +import { Button } from "@/components/ui/button" +import { Copy } from "lucide-react" +import { toast } from "sonner" + +interface PrCommentsListProps { + chatId: string +} + +function relativeTime(iso: string): string { + const diff = Date.now() - new Date(iso).getTime() + const minutes = Math.floor(diff / 60_000) + if (minutes < 1) return "just now" + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + if (days < 30) return `${days}d ago` + return new Date(iso).toLocaleDateString() +} + +function buildCopyText(c: { + author: string + createdAt: string + body: string + path?: string | null + diffHunk?: string | null +}): string { + const header = `${c.author} · ${new Date(c.createdAt).toLocaleString()}${ + c.path ? `\n${c.path}` : "" + }` + const hunk = c.diffHunk ? `\n\n${c.diffHunk}` : "" + return `${header}\n\n${c.body}${hunk}` +} + +export function PrCommentsList({ chatId }: PrCommentsListProps) { + const { data, isLoading, isError, error } = trpc.chats.getPrComments.useQuery( + { chatId }, + { refetchInterval: 60_000, enabled: !!chatId }, + ) + + if (isLoading) { + return ( +
+ + Loading comments… +
+ ) + } + + if (isError) { + return ( +
+ Couldn't load comments: {error?.message} +
+ ) + } + + const comments = data ?? [] + if (comments.length === 0) { + return ( +
+ No comments yet. +
+ ) + } + + const copyAll = async () => { + const text = comments.map(buildCopyText).join("\n\n---\n\n") + await navigator.clipboard.writeText(text) + toast.success(`Copied ${comments.length} comment${comments.length === 1 ? "" : "s"}`) + } + + const copyOne = async (c: (typeof comments)[number]) => { + await navigator.clipboard.writeText(buildCopyText(c)) + toast.success("Comment copied") + } + + return ( +
+
+ + {comments.length} comment{comments.length === 1 ? "" : "s"} + + +
+
    + {comments.map((c) => ( +
  • +
    +
    + + {c.author} + + · + {relativeTime(c.createdAt)} + {c.kind === "review" && ( + + review + + )} +
    + +
    + {c.path && ( +
    + {c.path} + {c.line ? `:${c.line}` : ""} +
    + )} + {c.diffHunk && ( +
    +                {c.diffHunk}
    +              
    + )} +
    + {c.body} +
    +
  • + ))} +
+
+ ) +} diff --git a/src/renderer/features/details-sidebar/sections/pr-widget.tsx b/src/renderer/features/details-sidebar/sections/pr-widget.tsx new file mode 100644 index 000000000..6f1d3c7d3 --- /dev/null +++ b/src/renderer/features/details-sidebar/sections/pr-widget.tsx @@ -0,0 +1,195 @@ +"use client" + +import { memo, useState } from "react" +import { + Check, + CircleDashed, + ExternalLink, + MessageSquare, + TriangleAlert, + X, +} from "lucide-react" +import { trpc } from "@/lib/trpc" +import { cn } from "@/lib/utils" +import { IconSpinner } from "@/components/ui/icons" +import { Button } from "@/components/ui/button" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { PRIcon } from "@/features/changes/components/pr-icon" +import { RenamePrTitleDialog } from "./rename-pr-title-dialog" +import { PrCommentsList } from "./pr-comments-section" + +interface PrWidgetProps { + chatId: string +} + +type ReviewDecision = "approved" | "changes_requested" | "pending" + +function reviewLabel(decision?: ReviewDecision | null): string | null { + if (!decision) return null + if (decision === "approved") return "Approved" + if (decision === "changes_requested") return "Changes requested" + return "Review pending" +} + +function reviewTone(decision?: ReviewDecision | null): string { + if (decision === "approved") return "text-emerald-600 dark:text-emerald-400" + if (decision === "changes_requested") return "text-amber-600 dark:text-amber-400" + return "text-muted-foreground" +} + +function stateLabel(state: string, isDraft?: boolean): string { + if (state === "merged") return "Merged" + if (state === "closed") return "Closed" + if (isDraft || state === "draft") return "Draft" + return "Open" +} + +export const PrWidget = memo(function PrWidget({ chatId }: PrWidgetProps) { + const { data: status, isLoading } = trpc.chats.getPrStatus.useQuery( + { chatId }, + { refetchInterval: 30000, enabled: !!chatId }, + ) + + const [isRenameOpen, setIsRenameOpen] = useState(false) + const [showComments, setShowComments] = useState(false) + + if (isLoading && !status) { + return ( +
+ + Loading PR status… +
+ ) + } + + const pr = status?.pr + if (!pr) { + return ( +
+ No pull request for this branch yet. +
+ ) + } + + const openPr = () => { + window.desktopApi.openExternal(pr.url) + } + + const checks = pr.checks ?? [] + const successCount = checks.filter((c) => c.status === "success").length + const failureCount = checks.filter((c) => c.status === "failure").length + const pendingCount = checks.filter((c) => c.status === "pending").length + + return ( +
+
+ {/* Title row */} +
+ +
+
+ #{pr.number} + · + {stateLabel(pr.state)} +
+ +
+ + + + + + Open on GitHub + + +
+ + {/* Review + checks row */} +
+ {reviewLabel(pr.reviewDecision) && ( + + {pr.reviewDecision === "approved" ? ( + + ) : pr.reviewDecision === "changes_requested" ? ( + + ) : ( + + )} + {reviewLabel(pr.reviewDecision)} + + )} + {checks.length > 0 && ( + + {successCount > 0 && ( + + + {successCount} + + )} + {failureCount > 0 && ( + + + {failureCount} + + )} + {pendingCount > 0 && ( + + + {pendingCount} + + )} + + )} + {(pr.additions !== undefined || pr.deletions !== undefined) && ( + + + +{pr.additions ?? 0} + {" "} + + −{pr.deletions ?? 0} + + + )} +
+ + {/* Comments toggle */} + +
+ + {showComments && } + + +
+ ) +}) diff --git a/src/renderer/features/details-sidebar/sections/rename-pr-title-dialog.tsx b/src/renderer/features/details-sidebar/sections/rename-pr-title-dialog.tsx new file mode 100644 index 000000000..37c758050 --- /dev/null +++ b/src/renderer/features/details-sidebar/sections/rename-pr-title-dialog.tsx @@ -0,0 +1,94 @@ +import { useEffect, useState } from "react" +import { toast } from "sonner" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { trpc } from "@/lib/trpc" + +interface RenamePrTitleDialogProps { + chatId: string + open: boolean + initialTitle: string + prNumber: number + onOpenChange: (open: boolean) => void +} + +export function RenamePrTitleDialog({ + chatId, + open, + initialTitle, + prNumber, + onOpenChange, +}: RenamePrTitleDialogProps) { + const [title, setTitle] = useState(initialTitle) + const utils = trpc.useUtils() + + useEffect(() => { + if (open) setTitle(initialTitle) + }, [open, initialTitle]) + + const mutation = trpc.chats.updatePrTitle.useMutation({ + onSuccess: () => { + utils.chats.getPrStatus.invalidate({ chatId }) + toast.success(`Renamed PR #${prNumber}`) + onOpenChange(false) + }, + onError: (error) => { + toast.error("Couldn't rename PR", { description: error.message }) + }, + }) + + const trimmed = title.trim() + const canSave = + trimmed.length > 0 && trimmed !== initialTitle.trim() && !mutation.isPending + + const handleSave = () => { + if (!canSave) return + mutation.mutate({ chatId, title: trimmed, prNumber }) + } + + return ( + + + + Rename PR #{prNumber} + + Update the title of this pull request on GitHub. + + + setTitle(e.target.value)} + placeholder="PR title" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleSave() + } + }} + disabled={mutation.isPending} + /> + + + + + + + ) +} diff --git a/src/renderer/features/sidebar/agents-sidebar.tsx b/src/renderer/features/sidebar/agents-sidebar.tsx index eb9ce8cc8..8463071d8 100644 --- a/src/renderer/features/sidebar/agents-sidebar.tsx +++ b/src/renderer/features/sidebar/agents-sidebar.tsx @@ -99,6 +99,7 @@ import { KeyboardIcon, TicketIcon, CloudIcon, + GitPullRequestFilledIcon, } from "../../components/ui/icons" import { Logo } from "../../components/ui/logo" import { Input } from "../../components/ui/input" @@ -439,6 +440,7 @@ const AgentChatItem = React.memo(function AgentChatItem({ gitOwner, gitProvider, stats, + prNumber, selectedChatIdsSize, canShowPinOption, areAllSelectedPinned, @@ -487,6 +489,7 @@ const AgentChatItem = React.memo(function AgentChatItem({ gitOwner: string | null | undefined gitProvider: string | null | undefined stats: { fileCount: number; additions: number; deletions: number } | undefined + prNumber: number | null selectedChatIdsSize: number canShowPinOption: boolean areAllSelectedPinned: boolean @@ -673,6 +676,12 @@ const AgentChatItem = React.memo(function AgentChatItem({ )} {displayText}
+ {prNumber != null && ( + + + {prNumber} + + )} {stats && (stats.additions > 0 || stats.deletions > 0) && ( <> @@ -1015,6 +1024,7 @@ const ChatListSection = React.memo(function ChatListSection({ gitOwner={gitOwner} gitProvider={gitProvider} stats={stats ?? undefined} + prNumber={chat.prNumber} selectedChatIdsSize={selectedChatIds.size} canShowPinOption={canShowPinOption} areAllSelectedPinned={areAllSelectedPinned} diff --git a/src/renderer/features/terminal/terminal.tsx b/src/renderer/features/terminal/terminal.tsx index 630778018..193b05d5d 100644 --- a/src/renderer/features/terminal/terminal.tsx +++ b/src/renderer/features/terminal/terminal.tsx @@ -137,6 +137,15 @@ export function Terminal({ const container = containerRef.current if (!container) return + // Defer terminal creation until a valid cwd is available. Without this, a + // transient mount with empty cwd creates a session that silently falls back + // to $HOME on the main side, and the session is then cached forever. + const startupCwd = initialCwd || cwd + if (!startupCwd) { + console.warn("[Terminal:useEffect] Skipping mount — no cwd yet") + return + } + console.log("[Terminal:useEffect] MOUNT - paneId:", paneId) console.log( "[Terminal:useEffect] Container rect:", @@ -250,7 +259,7 @@ export function Terminal({ scopeKey, cols: xterm.cols, rows: xterm.rows, - cwd: initialCwd || cwd, + cwd: startupCwd, initialCommands, }, { From f2cc80e75b35427e5c5cdc13e42781ae56424800 Mon Sep 17 00:00:00 2001 From: Alejandro Tamayo Castillo Date: Sat, 18 Apr 2026 17:14:17 +0200 Subject: [PATCH 22/33] fix(worktrees): prevent accidental worktree deletion (#25) Audit found three code paths that could destroy worktree source code without explicit user consent. Worktrees now only get deleted through the archive flow with the "Delete worktree" checkbox explicitly checked. - projects.delete no longer removes worktrees or the slug directory. The "Remove Project" dialog promises "Your files will not be deleted"; the backend now honors that promise. Only DB rows + terminals + Claude sessions are cleaned up. - Startup orphan scanner is no longer auto-invoked. Any automatic deletion risks destroying uncommitted code if the DB is empty, stale, or transiently errors. scanWorktreeOrphans() is kept for a future opt-in settings UI. - chats.delete gains a deleteWorktree flag (default false) and only calls removeWorktree when the caller opts in, matching chats.archive. - removeWorktree now logs path + caller stack before deleting so any unexpected loss can be traced. Co-authored-by: Claude Opus 4.7 (1M context) --- src/main/index.ts | 11 ++---- src/main/lib/git/worktree.ts | 5 +++ src/main/lib/trpc/routers/chats.ts | 15 ++++++-- src/main/lib/trpc/routers/projects.ts | 54 +++++---------------------- 4 files changed, 30 insertions(+), 55 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 5a90d0804..dda864953 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -948,13 +948,10 @@ if (gotTheLock) { console.error("[App] Failed to initialize database:", error) } - // Scan for orphan worktree directories in the background (non-blocking). - // Runs after a short delay so it doesn't compete with main-window paint. - setTimeout(() => { - import("./lib/git/worktree-cleanup") - .then(({ scanWorktreeOrphans }) => scanWorktreeOrphans()) - .catch((err) => console.warn("[App] Worktree orphan scan failed to start:", err)) - }, 5_000) + // Worktree orphan cleanup is intentionally NOT auto-run. Any automatic + // deletion risks destroying uncommitted source code if the DB is empty, + // stale, or transiently errors. Worktrees are only deleted via explicit + // user opt-in (archive dialog → "Delete worktree" checkbox). // Create main window createMainWindow() diff --git a/src/main/lib/git/worktree.ts b/src/main/lib/git/worktree.ts index 4feb77c4b..1e45f90df 100644 --- a/src/main/lib/git/worktree.ts +++ b/src/main/lib/git/worktree.ts @@ -241,6 +241,11 @@ export async function removeWorktree( mainRepoPath: string, worktreePath: string, ): Promise<{ success: boolean; error?: string }> { + // Forensic log: every worktree deletion is recorded so unexpected losses can be traced. + // Includes a short stack snippet to identify the calling code path. + const callerStack = new Error().stack?.split("\n").slice(2, 6).join(" | ") ?? "unknown"; + console.log(`[Worktree] removeWorktree called: path=${worktreePath} mainRepo=${mainRepoPath} caller=${callerStack}`); + let gitError: string | undefined; try { const env = await getGitEnv(); diff --git a/src/main/lib/trpc/routers/chats.ts b/src/main/lib/trpc/routers/chats.ts index f3aad2ad9..49330d926 100644 --- a/src/main/lib/trpc/routers/chats.ts +++ b/src/main/lib/trpc/routers/chats.ts @@ -635,10 +635,17 @@ export const chatsRouter = router({ }), /** - * Delete a chat permanently (with worktree cleanup) + * Delete a chat permanently. Worktree directory is preserved by default; + * callers must pass `deleteWorktree: true` to remove it. This matches the + * archive flow and ensures worktrees are never deleted without explicit opt-in. */ delete: publicProcedure - .input(z.object({ id: z.string() })) + .input( + z.object({ + id: z.string(), + deleteWorktree: z.boolean().default(false), + }), + ) .mutation(async ({ input }) => { const db = getDatabase() @@ -656,8 +663,8 @@ export const chatsRouter = router({ abortClaudeSessionsForSubChats(subChatIds) } - // Cleanup worktree if it was created (has branch = was a real worktree, not just project path) - if (chat?.worktreePath && chat?.branch) { + // Only delete worktree if the caller explicitly opted in. + if (input.deleteWorktree && chat?.worktreePath && chat?.branch) { const project = db .select() .from(projects) diff --git a/src/main/lib/trpc/routers/projects.ts b/src/main/lib/trpc/routers/projects.ts index b3d9b4fa1..5c15be890 100644 --- a/src/main/lib/trpc/routers/projects.ts +++ b/src/main/lib/trpc/routers/projects.ts @@ -1,17 +1,15 @@ import { z } from "zod" import { router, publicProcedure } from "../index" import { chats, getDatabase, projects, subChats } from "../../db" -import { and, eq, desc, inArray, isNotNull } from "drizzle-orm" +import { eq, desc, inArray } from "drizzle-orm" import { dialog, BrowserWindow, app } from "electron" import { basename, join } from "path" import { exec } from "node:child_process" import { promisify } from "node:util" import { existsSync } from "node:fs" -import { mkdir, copyFile, readdir, rm, unlink } from "node:fs/promises" -import { homedir } from "node:os" +import { mkdir, copyFile, unlink } from "node:fs/promises" import { extname } from "node:path" -import { getGitRemoteInfo, removeWorktree, sanitizeProjectName } from "../../git" -import { isPathInsideWorktreeRoot } from "../../git/worktree" +import { getGitRemoteInfo } from "../../git" import { terminalManager } from "../../terminal/manager" import { trackProjectOpened } from "../../analytics" import { getLaunchDirectory } from "../../cli" @@ -190,20 +188,20 @@ export const projectsRouter = router({ }), /** - * Delete a project and all its chats. - * Cascades worktree directory cleanup so disk doesn't accumulate orphans. + * Remove a project from the list. Worktree directories on disk are preserved — + * the "Remove" dialog explicitly promises "Your files will not be deleted." + * The only path that may delete a worktree is archiving a workspace with the + * "Delete worktree" checkbox explicitly checked. */ delete: publicProcedure .input(z.object({ id: z.string() })) - .mutation(async ({ input }) => { + .mutation(({ input }) => { const db = getDatabase() const project = db.select().from(projects).where(eq(projects.id, input.id)).get() if (!project) { return null } - // Abort any in-flight Claude sessions for the project's sub-chats so cleanup - // (worktree rm, FK cascade) doesn't race with active streams. const childChatIds = db .select({ id: chats.id }) .from(chats) @@ -222,41 +220,9 @@ export const projectsRouter = router({ if (subChatIds.length > 0) { abortClaudeSessionsForSubChats(subChatIds) } - } - - // Find chats with real worktrees (branch is set; project-path-only chats have no worktree) - const childChatsWithWorktree = db - .select() - .from(chats) - .where( - and( - eq(chats.projectId, input.id), - isNotNull(chats.worktreePath), - isNotNull(chats.branch), - ), - ) - .all() - - // Kill any terminals tied to those chats and remove the worktrees in parallel - await Promise.allSettled( - childChatsWithWorktree.map(async (chat) => { - if (chat.worktreePath) { - terminalManager.killByWorkspaceId(chat.id).catch(() => {}) - await removeWorktree(project.path, chat.worktreePath) - } - }), - ) - // Remove the project's slug directory if empty (best-effort) - const slugDir = join(homedir(), ".21st", "worktrees", sanitizeProjectName(project.name)) - if (isPathInsideWorktreeRoot(slugDir)) { - try { - const entries = await readdir(slugDir) - if (entries.length === 0) { - await rm(slugDir, { recursive: true, force: true }) - } - } catch { - // Slug dir doesn't exist or can't be read — non-fatal + for (const chatId of childChatIds) { + terminalManager.killByWorkspaceId(chatId).catch(() => {}) } } From 4178b94e2b14abe3a085864aae19facf4f7e88f6 Mon Sep 17 00:00:00 2001 From: Alejandro Tamayo Castillo Date: Sat, 18 Apr 2026 18:33:42 +0200 Subject: [PATCH 23/33] feat: drag-to-split, queue reorder, Cmd+Shift+T, PR auto-refresh (#26) Adds drag-and-drop to create/extend the split view by dragging sub-chats from the sidebar, drag reordering for queued messages, a Cmd+Shift+T shortcut (and tooltip) that opens a new sub-chat directly in split view, and PR status auto-refresh on git commit/push. Also adds a per-pane close button inline with the title, hides the Background/sandbox option, and lifts the sidebar's DndContext to a shared parent so drag can cross from sidebar to main content without overflow clipping (DragOverlay portals the preview at document root). Fix: `removeFromSplit` now shifts `activeSubChatId` when the removed pane was active so the correct pane stays visible after close. Co-authored-by: Claude Opus 4.7 (1M context) --- .../agents/components/work-mode-selector.tsx | 15 +- .../features/agents/main/active-chat.tsx | 133 +++++++++++++++--- .../agents/stores/message-queue-store.ts | 24 ++++ .../features/agents/stores/sub-chat-store.ts | 61 ++++++-- .../agents/ui/agent-queue-indicator.tsx | 103 ++++++++++++-- .../features/agents/ui/agents-content.tsx | 75 +++++++++- .../agents/ui/split-view-container.tsx | 64 ++++++++- .../features/agents/ui/sub-chat-selector.tsx | 16 ++- .../sidebar/agents-subchats-sidebar.tsx | 45 +----- 9 files changed, 446 insertions(+), 90 deletions(-) diff --git a/src/renderer/features/agents/components/work-mode-selector.tsx b/src/renderer/features/agents/components/work-mode-selector.tsx index 2f477fc62..563158a9b 100644 --- a/src/renderer/features/agents/components/work-mode-selector.tsx +++ b/src/renderer/features/agents/components/work-mode-selector.tsx @@ -28,13 +28,14 @@ const workModeOptions = [ label: "Worktree", icon: GitBranch, }, - { - id: "sandbox" as const, - label: "Background", - icon: CloudIcon, - disabled: true, - soon: true, - }, + // Hidden until ready — uncomment to re-enable the Background/sandbox mode. + // { + // id: "sandbox" as const, + // label: "Background", + // icon: CloudIcon, + // disabled: true, + // soon: true, + // }, ] export function WorkModeSelector({ diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index 1d7aca900..359cf9011 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -41,7 +41,8 @@ import { ChevronDown, GitFork, ListTree, - TerminalSquare + TerminalSquare, + X as XIcon, } from "lucide-react" import { AnimatePresence, motion } from "motion/react" import { @@ -229,10 +230,11 @@ import { MobileChatHeader } from "../ui/mobile-chat-header" import { QuickCommentInput } from "../ui/quick-comment-input" import { SubChatSelector } from "../ui/sub-chat-selector" import { SubChatStatusCard } from "../ui/sub-chat-status-card" -import { SplitViewContainer } from "../ui/split-view-container" +import { SplitViewContainer, SplitDropZone } from "../ui/split-view-container" import { TextSelectionPopover } from "../ui/text-selection-popover" import { autoRenameAgentChat } from "../utils/auto-rename" import { generateCommitToPrMessage, generatePrMessage, generateReviewMessage } from "../utils/pr-message" +import { extractGitActivity } from "../utils/git-activity" import { ChatInputArea } from "./chat-input-area" import { IsolatedMessagesSection } from "./isolated-messages-section" const clearSubChatSelectionAtom = atom(null, () => {}) @@ -775,6 +777,37 @@ function PlayButton({ ) } +// Persistent (not hover-to-reveal) — hiding the button on hover caused it to +// vanish as the pointer approached it. +const SplitPaneInlineClose = memo(function SplitPaneInlineClose({ + subChatId, +}: { + subChatId: string +}) { + const removeFromSplit = useAgentSubChatStore((s) => s.removeFromSplit) + const splitPaneCount = useAgentSubChatStore((s) => s.splitPaneIds.length) + const isLastPair = splitPaneCount === 2 + const label = isLastPair ? "Close split view" : "Remove from split" + return ( + + + + + {label} + + ) +}) + // Isolated scroll-to-bottom button - uses own scroll listener to avoid re-renders of parent const ScrollToBottomButton = memo(function ScrollToBottomButton({ containerRef, @@ -2153,6 +2186,7 @@ const ChatViewInner = memo(function ChatViewInner({ // tRPC utils for cache invalidation const utils = api.useUtils() + const trpcUtils = trpc.useUtils() // Get sub-chat name from store const subChatName = useAgentSubChatStore( @@ -2824,6 +2858,30 @@ const ChatViewInner = memo(function ChatViewInner({ } }, [isStreaming, subChatId, pendingQuestions, setPendingQuestionsMap]) + // PR status auto-refresh on stream end. `messages` is tracked via a ref so + // the effect doesn't re-run on every streamed chunk — only on the transition. + const prAutoRefreshWasStreamingRef = useRef(false) + const prAutoRefreshMessagesRef = useRef(messages) + prAutoRefreshMessagesRef.current = messages + useEffect(() => { + const wasStreaming = prAutoRefreshWasStreamingRef.current + prAutoRefreshWasStreamingRef.current = isStreaming + if (!(wasStreaming && !isStreaming)) return + + const allParts = prAutoRefreshMessagesRef.current.flatMap( + (m: any) => m.parts || [], + ) + const activity = extractGitActivity(allParts) + if (!activity) return + + trpcUtils.chats.getPrStatus.invalidate({ chatId: parentChatId }) + if (projectPath) { + trpcUtils.changes.getGitHubStatus.invalidate({ + worktreePath: projectPath, + }) + } + }, [isStreaming, parentChatId, projectPath, trpcUtils]) + // Sync pending questions with messages state // This handles: 1) restoring on chat switch, 2) clearing when question is answered/timed out useEffect(() => { @@ -4653,14 +4711,22 @@ const ChatViewInner = memo(function ChatViewInner({ isSubChatsSidebarOpen ? "pt-[52px]" : "pt-2", )} > - + {/* Title row: ChatTitleEditor on the left, per-pane close X on the + right for split panes. Flex layout ensures the X sits on the same + visual row as the title rather than floating in a corner. */} +
+
+ +
+ {isSplitPane && } +
{/* Workspace subtitle: repo • branch */} {(workspaceRepoName || workspaceBranch) && (
@@ -4762,6 +4828,9 @@ const ChatViewInner = memo(function ChatViewInner({ queue={queue} onRemoveItem={handleRemoveFromQueue} onSendNow={handleSendFromQueue} + onReorder={(from, to) => + useMessageQueueStore.getState().reorderQueue(subChatId, from, to) + } isStreaming={isStreaming} hasStatusCardBelow={shouldShowStatusCard} /> @@ -7063,6 +7132,8 @@ Make sure to preserve all functionality from both branches when resolving confli agentChatStore.setStreamId(newId, null) // New chat has no active stream forceUpdate({}) // Trigger re-render } + + return newId }, [ worktreePath, chatId, @@ -7080,22 +7151,48 @@ Make sure to preserve all functionality from both branches when resolving confli agentChat?.name, ]) + // Create a new sub-chat AND place it in split view with the previously active tab. + // Used by Cmd+Shift+T. Passes the pre-creation active tab as the explicit first pane + // because handleCreateNewSubChat flips activeSubChatId to the new id. + const handleCreateNewSubChatInSplit = useCallback(() => { + const prevActive = useAgentSubChatStore.getState().activeSubChatId + const newId = handleCreateNewSubChat() + if (!newId || !prevActive) return + useAgentSubChatStore.getState().addToSplit(newId, prevActive) + }, [handleCreateNewSubChat]) + // Keyboard shortcut: New sub-chat // Web: Opt+Cmd+T (browser uses Cmd+T for new tab) // Desktop: Cmd+T + // Cmd+Shift+T (desktop) / Opt+Cmd+Shift+T (web) opens the new sub-chat in split view. useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const isDesktop = isDesktopApp() - // Desktop: Cmd+T (without Alt) - if (isDesktop && e.metaKey && e.code === "KeyT" && !e.altKey) { + // Desktop: Cmd+Shift+T — new sub-chat in split view. + // Must be checked BEFORE the plain Cmd+T branch (which doesn't require Shift). + if (isDesktop && e.metaKey && e.shiftKey && e.code === "KeyT" && !e.altKey) { + e.preventDefault() + handleCreateNewSubChatInSplit() + return + } + + // Web: Opt+Cmd+Shift+T — new sub-chat in split view. + if (e.altKey && e.metaKey && e.shiftKey && e.code === "KeyT") { + e.preventDefault() + handleCreateNewSubChatInSplit() + return + } + + // Desktop: Cmd+T (without Alt, without Shift) + if (isDesktop && e.metaKey && e.code === "KeyT" && !e.altKey && !e.shiftKey) { e.preventDefault() handleCreateNewSubChat() return } - // Web: Opt+Cmd+T (with Alt) - if (e.altKey && e.metaKey && e.code === "KeyT") { + // Web: Opt+Cmd+T (with Alt, without Shift) + if (e.altKey && e.metaKey && e.code === "KeyT" && !e.shiftKey) { e.preventDefault() handleCreateNewSubChat() } @@ -7103,7 +7200,7 @@ Make sure to preserve all functionality from both branches when resolving confli window.addEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown) - }, [handleCreateNewSubChat]) + }, [handleCreateNewSubChat, handleCreateNewSubChatInSplit]) // NOTE: Desktop notifications for pending questions are now triggered directly // in ipc-chat-transport.ts when the ask-user-question chunk arrives. @@ -7815,7 +7912,8 @@ Make sure to preserve all functionality from both branches when resolving confli } /> ) : ( - tabsToRender.map(subChatId => { + + {tabsToRender.map(subChatId => { const chat = getOrCreateChat(subChatId) const isActive = subChatId === activeSubChatId const isFirstSubChat = getFirstSubChatId(agentSubChats) === subChatId @@ -7873,7 +7971,8 @@ Make sure to preserve all functionality from both branches when resolving confli />
) - }) + })} + )}
) : ( diff --git a/src/renderer/features/agents/stores/message-queue-store.ts b/src/renderer/features/agents/stores/message-queue-store.ts index 4af9d9bfa..433048dfc 100644 --- a/src/renderer/features/agents/stores/message-queue-store.ts +++ b/src/renderer/features/agents/stores/message-queue-store.ts @@ -1,5 +1,6 @@ import { create } from "zustand" import { subscribeWithSelector } from "zustand/middleware" +import { arrayMove } from "@dnd-kit/sortable" import type { AgentQueueItem } from "../lib/queue-utils" import { removeQueueItem } from "../lib/queue-utils" @@ -27,6 +28,8 @@ interface MessageQueueState { prependItem: (subChatId: string, item: AgentQueueItem) => void // Signal that a queued message was auto-sent (for scroll triggering) triggerQueueSent: (subChatId: string) => void + // Reorder queue via drag-and-drop (user-driven reprioritization). + reorderQueue: (subChatId: string, fromIndex: number, toIndex: number) => void } export const useMessageQueueStore = create()( @@ -108,4 +111,25 @@ export const useMessageQueueStore = create()( }, })) }, + + reorderQueue: (subChatId, fromIndex, toIndex) => { + set((state) => { + const current = state.queues[subChatId] || [] + if ( + fromIndex === toIndex || + fromIndex < 0 || + toIndex < 0 || + fromIndex >= current.length || + toIndex >= current.length + ) { + return state + } + return { + queues: { + ...state.queues, + [subChatId]: arrayMove(current, fromIndex, toIndex), + }, + } + }) + }, }))) diff --git a/src/renderer/features/agents/stores/sub-chat-store.ts b/src/renderer/features/agents/stores/sub-chat-store.ts index 3beebf4e9..b400d7ad3 100644 --- a/src/renderer/features/agents/stores/sub-chat-store.ts +++ b/src/renderer/features/agents/stores/sub-chat-store.ts @@ -8,7 +8,29 @@ import { clearSubChatRuntimeCaches } from "./sub-chat-runtime-cleanup" import { getDefaultRatios, addPaneRatio, removePaneRatio } from "../atoms" import { trpcClient } from "../../../lib/trpc" -const MAX_SPLIT_PANES = 4 +export const MAX_SPLIT_PANES = 4 + +/** + * Whether a sub-chat can be added to split via drag-and-drop. + * Mirrors the guards in `addToSplit`; used by droppables to skip the + * "drop would silently do nothing" case so no hover highlight shows. + */ +export function canAddToSplit( + state: Pick< + AgentSubChatStore, + "activeSubChatId" | "splitPaneIds" + >, + subChatId: string, +): boolean { + if (state.splitPaneIds.includes(subChatId)) return false + if (state.splitPaneIds.length >= MAX_SPLIT_PANES) return false + if (state.splitPaneIds.length === 0) { + // Need an active tab to pair with the dragged one. + if (!state.activeSubChatId) return false + if (subChatId === state.activeSubChatId) return false + } + return true +} export interface SubChatMeta { id: string @@ -42,7 +64,7 @@ interface AgentSubChatStore { updateSubChatName: (subChatId: string, name: string) => void updateSubChatMode: (subChatId: string, mode: "plan" | "agent") => void updateSubChatTimestamp: (subChatId: string) => void - addToSplit: (subChatId: string) => void + addToSplit: (subChatId: string, explicitFirstPane?: string) => void removeFromSplit: (subChatId: string) => void closeSplit: () => void setSplitRatios: (ratios: number[]) => void @@ -318,17 +340,20 @@ export const useAgentSubChatStore = create((set, get) => ({ }) }, - addToSplit: (subChatId) => { + addToSplit: (subChatId, explicitFirstPane) => { const { chatId, activeSubChatId, splitPaneIds, splitRatios, openSubChatIds } = get() - if (subChatId === activeSubChatId) return + // Pane 1 source: explicit override (for "create new in split" flows where active + // has already been flipped to the new id) or the current active tab. + const firstPane = explicitFirstPane ?? activeSubChatId + if (subChatId === firstPane) return if (splitPaneIds.includes(subChatId)) return let newPaneIds: string[] let newRatios: number[] if (splitPaneIds.length === 0) { - // Start new split group: [active, new] - if (!activeSubChatId) return - newPaneIds = [activeSubChatId, subChatId] + // Start new split group: [firstPane, new] + if (!firstPane) return + newPaneIds = [firstPane, subChatId] newRatios = getDefaultRatios(2) } else if (splitPaneIds.length < MAX_SPLIT_PANES) { newPaneIds = [...splitPaneIds, subChatId] @@ -352,7 +377,7 @@ export const useAgentSubChatStore = create((set, get) => ({ }, removeFromSplit: (subChatId) => { - const { chatId, splitPaneIds, splitRatios } = get() + const { chatId, splitPaneIds, splitRatios, activeSubChatId } = get() if (!splitPaneIds.includes(subChatId)) return const removeIdx = splitPaneIds.indexOf(subChatId) @@ -360,10 +385,28 @@ export const useAgentSubChatStore = create((set, get) => ({ let newRatios = removePaneRatio(splitRatios, removeIdx) if (newPaneIds.length < 2) { newPaneIds = []; newRatios = [] } - set({ splitPaneIds: newPaneIds, splitRatios: newRatios }) + // If the removed pane was active, shift active to an adjacent remaining + // pane. Without this, clicking X on the active pane collapses the split + // but leaves `activeSubChatId` pointing at the just-removed pane — the + // user sees the closed chat stay visible and the other one "disappear". + let newActiveSubChatId = activeSubChatId + if (activeSubChatId === subChatId) { + const remaining = splitPaneIds.filter((id) => id !== subChatId) + newActiveSubChatId = + remaining[removeIdx] ?? remaining[removeIdx - 1] ?? remaining[0] ?? activeSubChatId + } + + set({ + splitPaneIds: newPaneIds, + splitRatios: newRatios, + activeSubChatId: newActiveSubChatId, + }) if (chatId) { saveToLS(chatId, "splitPanes", newPaneIds) saveToLS(chatId, "splitRatios", newRatios) + if (newActiveSubChatId !== activeSubChatId) { + saveToLS(chatId, "active", newActiveSubChatId) + } } }, diff --git a/src/renderer/features/agents/ui/agent-queue-indicator.tsx b/src/renderer/features/agents/ui/agent-queue-indicator.tsx index 35ffa31f0..810ba1eba 100644 --- a/src/renderer/features/agents/ui/agent-queue-indicator.tsx +++ b/src/renderer/features/agents/ui/agent-queue-indicator.tsx @@ -1,8 +1,22 @@ "use client" -import { memo, useState, useCallback, useEffect } from "react" -import { ChevronDown, ArrowUp, X } from "lucide-react" +import { memo, useState, useCallback, useEffect, useMemo } from "react" +import { ChevronDown, ArrowUp, X, GripVertical } from "lucide-react" import { motion, AnimatePresence } from "motion/react" +import { + DndContext, + PointerSensor, + useSensor, + useSensors, + closestCenter, + type DragEndEvent, +} from "@dnd-kit/core" +import { + SortableContext, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" import { Tooltip, TooltipContent, @@ -21,11 +35,27 @@ const QueueItemRow = memo(function QueueItemRow({ item, onRemove, onSendNow, + isReorderable = false, }: { item: AgentQueueItem onRemove?: (itemId: string) => void onSendNow?: (itemId: string) => void + isReorderable?: boolean }) { + // Items currently being processed must not be draggable — the queue processor + // and the user would fight over ordering, and the row would disappear mid-drag. + const isDraggable = isReorderable && item.status !== "processing" + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id: item.id, disabled: !isDraggable }) + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + zIndex: isDragging ? 10 : undefined, + position: "relative", + } + const handleRemove = useCallback( (e: React.MouseEvent) => { e.stopPropagation() @@ -67,7 +97,25 @@ const QueueItemRow = memo(function QueueItemRow({ } return ( -
+
+ {isDraggable && ( +