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/CLAUDE.md b/CLAUDE.md index 724c9cd9a..65a783829 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,7 +120,7 @@ const projectChats = db.select().from(chats).where(eq(chats.projectId, id)).all( - **React Query**: Server state via tRPC (auto-caching, refetch) ### Claude Integration -- Dynamic import of `@anthropic-ai/claude-code` SDK +- Dynamic import of `@anthropic-ai/claude-agent-sdk` SDK - Two modes: "plan" (read-only) and "agent" (full permissions) - Session resume via `sessionId` stored in SubChat - Message streaming via tRPC subscription (`claude.onMessage`) diff --git a/README.md b/README.md index 082960e66..dc334f30f 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,52 @@ 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: + +### Workflow & UI + +- **Split View with Drag-to-Split** - Drag a sub-chat from the sidebar to create or extend a split-view layout; per-pane close button in the title bar +- **Cmd+Shift+T: New Sub-Chat in Split** - Dedicated shortcut (and tooltip) that opens a new sub-chat directly into split view +- **Sortable Sidebar** - Reorder chats in the sidebar via drag-and-drop (@dnd-kit), with a grab-cursor + grip-handle hint on hover +- **Draggable Tab Bar** - Native HTML5 drag-and-drop on tabs with an insertion marker; split pairs stay locked +- **Queue Reorder** - Drag to reorder queued messages before they're sent +- **Text-Selection Copy Popover** - Copy button appears when you highlight text inside a chat message +- **Optimistic Sub-Chat Creation** - New sub-chats appear instantly and roll back on RPC failure +- **Per-Mode Thinking Effort** - Set Claude's thinking budget independently for Plan and Agent modes +- **Per-Mode Default Models** - Configure a default model per mode with automatic switching on mode change +- **Usage Statistics** - Built-in page showing Claude + Codex token and cost tracking +- **Wider Chat Column** - Expanded chat area (max-w-4xl) for better readability +- **Enter / Shift+Enter Swap** - Enter submits, Shift+Enter inserts a newline (matches common conventions) + +### Git, PRs & Worktrees + +- **PR Widget with Comments** - Inline PR status, comments, and details alongside the chat +- **Branch Switcher Popover** - Switch branches from a popover in the changes panel; PR chip refreshes immediately on switch (no more 30-second polling wait) +- **PR Auto-Refresh on Commit/Push** - PR status updates automatically when you commit or push from the app +- **Two-Column Commit Diff** - Side-by-side diff view for commit contents +- **Pull & Push Recovery Dialog** - When `git push` fails because the remote is ahead, a one-click dialog auto-stashes, rebases, and re-pushes instead of surfacing a raw "non-fast-forward" error +- **Worktree Deletion Safety** - Worktrees are only removed when you explicitly opt in via the archive flow with the "Delete worktree" checkbox; project delete and app startup no longer auto-remove worktrees + +### Models + +- **Latest Claude Models** - Opus 4.7 and updated model list including the latest Claude releases +- **Sonnet 4.6 1M Context** - Full 1M-token context for Sonnet (`sonnet[1m]`) alongside the existing Opus 1M, with an amber "1M · higher cost" badge in the selector +- **One-Click 1M Recovery** - On rate-limit or context errors against a 1M model, the toast action becomes "Switch to \" — one click moves the sub-chat back to the 200K variant +- **GPT-5.4 & GPT-5.4 Mini** - Latest Codex models registered as the default; gpt-5.3-codex remains available + +### Stability & Polish + +- **Rich Tool Rendering** - Proper icons and labels for `Skill`, `ScheduleWakeup`, `EnterPlanMode`, `Cron*`, `Monitor`, `PushNotification`, `TaskOutput`/`TaskStop`, `EnterWorktree`/`ExitWorktree`, `RemoteTrigger`, and `ToolSearch` (previously rendered as plain text) +- **Stream Wedge Timeout** - 90-second first-chunk timeout aborts and surfaces a `STREAM_WEDGE` error instead of hanging the UI indefinitely +- **Crash Auto-Recovery** - App-root error boundary + one-shot auto-reload (10s debounce) for IPC race crashes, so you get a visible error state instead of a black screen +- **Session Abort on Delete** - In-flight Claude sessions are aborted before their workspace is removed on project/chat/sub-chat delete +- **Lazy Archive Popover** - Archive queries no longer fire until the popover opens, reducing startup network chatter +- **Windows Git Path Fix** - POSIX-normalized git paths so the sidebar tree view works on Windows + +--- + ## Highlights - **Multi-Agent Support** - Claude Code and Codex in one app, switch instantly diff --git a/build.sh b/build.sh new file mode 100755 index 000000000..f1c2569bd --- /dev/null +++ b/build.sh @@ -0,0 +1,7 @@ +bun install +bun run claude:download # Download Claude binary (required!) +bun run codex:download # Download Codex binary (required!) +bun run build +bun run package:mac +bun run package:win +bun run package:linux \ No newline at end of file diff --git a/bun.lock b/bun.lock index f338c9b34..22275e89b 100644 --- a/bun.lock +++ b/bun.lock @@ -1,17 +1,21 @@ { "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.118", + "@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", - "@modelcontextprotocol/sdk": "^1.25.3", + "@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", @@ -42,7 +46,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.11.1", "ai": "^6.0.14", "async-mutex": "^0.5.0", "better-sqlite3": "^12.6.2", @@ -90,6 +94,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 +131,25 @@ "@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.118", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.118", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.118", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.118", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.118", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.118", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.118", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.118", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.118" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-OfxCTzmfqvctpTLd3CP+UrpC0JdhYcJp12rD+SK29k+9+hrbblCrLobvhdWpTuYFejTPJuiLVsbHxq0BkEuELQ=="], + + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.118", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RudnoBekv0c9CPL0EeMc4RqDe4Pb7tdz/2oxa5EYqaajXNRlYtTvru9q7wq7Zvp40JQ24hz38swOTJ7PkW7G/g=="], + + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.118", "", { "os": "darwin", "cpu": "x64" }, "sha512-Hf/H46uElpfygALlb4KZR2EuyyJRe7jBuWa+TDA4jmAHVblNfwkVyaCp8s61hZINB3kAmXdLdM81VI+xwruWzA=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.118", "", { "os": "linux", "cpu": "arm64" }, "sha512-lwMXnweJKpzESezJFM8mngRxJfaq/N0gqyFXBm5bOYaPIZnlGlP3h1JMKsJeqC4neLVGbe5a3Hq4T22Rr7OoAA=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.118", "", { "os": "linux", "cpu": "arm64" }, "sha512-gSuZS8GM8MZuklzAJS8VCCjqK2UJJeerV+JpVYzXNMelotq4sXUg2dp17VbjCJ1jhUC9u1gpzlQDWkmYrXCbOg=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.118", "", { "os": "linux", "cpu": "x64" }, "sha512-m0KBbwN9s0+hQwAPzeUFvegrEqoT9EOC+Vz3vr4dd9FcZyvKZE0yiv9S7YbFp1ZKWDQmppmvpcB+9eME7WQ0yA=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.118", "", { "os": "linux", "cpu": "x64" }, "sha512-36lG1F9IsuNBV7AzJY98z8KwryoWZCeEtMzgZL7614zPBhZGBsziQUZEBm2Eu7FVWbRQmYv6BL52+gffpkM4Gw=="], + + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.118", "", { "os": "win32", "cpu": "arm64" }, "sha512-o30/SL084+a8wJ+5cgKM1BflxiBUEy+xEcEpZPW+zCFtiqY0b1Pr+K35ECsbKBrv+w5/0Byp4/CvCkP15Otsgw=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.118", "", { "os": "win32", "cpu": "x64" }, "sha512-TSqsVBUaZGgYMkjCZckXhPvmJDTS7C6VAl4IOeMVNB/oPINVFaobtVagjYvY0BFnlDCOzz6sb8puafHwcm7qQA=="], + + "@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 +189,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=="], @@ -186,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=="], @@ -284,36 +317,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.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], - - "@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-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="], - - "@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-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-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="], - - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="], - - "@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-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="], - - "@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-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-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-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-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-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], - "@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=="], @@ -344,7 +347,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=="], @@ -436,7 +439,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=="], @@ -626,9 +631,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=="], @@ -648,6 +653,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=="], @@ -818,19 +825,19 @@ "@xterm/xterm": ["@xterm/xterm@5.5.0", "", {}, "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="], - "@zed-industries/codex-acp": ["@zed-industries/codex-acp@0.9.3", "", { "optionalDependencies": { "@zed-industries/codex-acp-darwin-arm64": "0.9.3", "@zed-industries/codex-acp-darwin-x64": "0.9.3", "@zed-industries/codex-acp-linux-arm64": "0.9.3", "@zed-industries/codex-acp-linux-x64": "0.9.3", "@zed-industries/codex-acp-win32-arm64": "0.9.3", "@zed-industries/codex-acp-win32-x64": "0.9.3" }, "bin": { "codex-acp": "bin/codex-acp.js" } }, "sha512-RqY1Z1Usqal5kC2mnVyZnuyz7VI/GOwPNGav51Lj9Epkrj34m/hieQXheodnSUDlfcC1REp5DELC5DzIwWvtKg=="], + "@zed-industries/codex-acp": ["@zed-industries/codex-acp@0.11.1", "", { "optionalDependencies": { "@zed-industries/codex-acp-darwin-arm64": "0.11.1", "@zed-industries/codex-acp-darwin-x64": "0.11.1", "@zed-industries/codex-acp-linux-arm64": "0.11.1", "@zed-industries/codex-acp-linux-x64": "0.11.1", "@zed-industries/codex-acp-win32-arm64": "0.11.1", "@zed-industries/codex-acp-win32-x64": "0.11.1" }, "bin": { "codex-acp": "bin/codex-acp.js" } }, "sha512-My2VSlBtvJipJhImHjFDej2ut/p00QqOISRnZgLgLrSIzjgvdcQvAhaZviWj7XPhk4UIdIb0OoA+Lrls824uiQ=="], - "@zed-industries/codex-acp-darwin-arm64": ["@zed-industries/codex-acp-darwin-arm64@0.9.3", "", { "os": "darwin", "cpu": "arm64", "bin": { "codex-acp-darwin-arm64": "bin/codex-acp" } }, "sha512-E+rCADM8n5EW7+YTFCsUhEOQy/8cfHM5YaEyGDnYcuDf263xyb12/PEodhVuwZkjIPkHKgLhs6LpGEcYLDkmWg=="], + "@zed-industries/codex-acp-darwin-arm64": ["@zed-industries/codex-acp-darwin-arm64@0.11.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "codex-acp-darwin-arm64": "bin/codex-acp" } }, "sha512-zJ/CsOSH1NniKGF/liBtWokdkFtymTWeELV4lDlgMgzVSqMHQTB+t6fFSrxhwOcXHK86TErxAT81Z61e+bq5gg=="], - "@zed-industries/codex-acp-darwin-x64": ["@zed-industries/codex-acp-darwin-x64@0.9.3", "", { "os": "darwin", "cpu": "x64", "bin": { "codex-acp-darwin-x64": "bin/codex-acp" } }, "sha512-LjbqfErO3z42WhwUvJ9kxXZeNMrD4vpwYz+yA9Bke7eSVJUjipfSbT8PxrYhWtZ+RhP1q0L2mudIfoblyTFruA=="], + "@zed-industries/codex-acp-darwin-x64": ["@zed-industries/codex-acp-darwin-x64@0.11.1", "", { "os": "darwin", "cpu": "x64", "bin": { "codex-acp-darwin-x64": "bin/codex-acp" } }, "sha512-UZjIsEZPLeYMk+fj2ot1oT+tWuJpw+iZS9awnbmJYxTEEXMpY8BE6xQXMy7iyyxJ346We5MEpAdxg730vcem5Q=="], - "@zed-industries/codex-acp-linux-arm64": ["@zed-industries/codex-acp-linux-arm64@0.9.3", "", { "os": "linux", "cpu": "arm64", "bin": { "codex-acp-linux-arm64": "bin/codex-acp" } }, "sha512-7lwOYbg1wo0vaehV++QUkJ9CoaV+PJOUB3MD8iu45ajjRZPxHd6U7MR4J4Fp1IHIs3mzENxVLu/tSVxn2TPJ+g=="], + "@zed-industries/codex-acp-linux-arm64": ["@zed-industries/codex-acp-linux-arm64@0.11.1", "", { "os": "linux", "cpu": "arm64", "bin": { "codex-acp-linux-arm64": "bin/codex-acp" } }, "sha512-I1f6WoSLbLlsWq4zH+vtwdoc4Y41mqRXPpSkfgIifxBw34QmWJmi37etZ7lKTYp6R+J/Z4PUN0rsmnsmKpBZTw=="], - "@zed-industries/codex-acp-linux-x64": ["@zed-industries/codex-acp-linux-x64@0.9.3", "", { "os": "linux", "cpu": "x64", "bin": { "codex-acp-linux-x64": "bin/codex-acp" } }, "sha512-buqH4H98VQrcmt5f9lhfOpGc7TTA4tlbopSYrEgRxZjSaXDPqKxdOLl8DzNVCrqlNJM/KqieeBvf5g31PosyyA=="], + "@zed-industries/codex-acp-linux-x64": ["@zed-industries/codex-acp-linux-x64@0.11.1", "", { "os": "linux", "cpu": "x64", "bin": { "codex-acp-linux-x64": "bin/codex-acp" } }, "sha512-30vSoZuW1DP6Nuz24Gg3jgVC37IYe0bZ/Fgc5+372gc0h72NN4zHYAbu5bRd/gUJ9GdwABKrrEPCoFPlOTVTnQ=="], - "@zed-industries/codex-acp-win32-arm64": ["@zed-industries/codex-acp-win32-arm64@0.9.3", "", { "os": "win32", "cpu": "arm64", "bin": { "codex-acp-win32-arm64": "bin/codex-acp.exe" } }, "sha512-iv9kjq3Z1kVvNxB5OvKsd6nC+jaiiQj7oXP3PJ84tmCOE+5wIeNLkV6+r26A9KD2we0fi5bOjc5DyfpFMBvCRQ=="], + "@zed-industries/codex-acp-win32-arm64": ["@zed-industries/codex-acp-win32-arm64@0.11.1", "", { "os": "win32", "cpu": "arm64", "bin": { "codex-acp-win32-arm64": "bin/codex-acp.exe" } }, "sha512-yisGPG7JMJBtOTOB6qwzroOLfiQebDrBnybzvjOfWiSIHeha25Jf1nTlWrlZcEiV/eeX3/lERuU1MxftjK3Vgg=="], - "@zed-industries/codex-acp-win32-x64": ["@zed-industries/codex-acp-win32-x64@0.9.3", "", { "os": "win32", "cpu": "x64", "bin": { "codex-acp-win32-x64": "bin/codex-acp.exe" } }, "sha512-ptMNDyLJU0rICptyKThAS2VySgoUPU3UWC08yVenDi+OZ04oVrwwyxTvrhp4/u25yqiVjf210P2z5n7Iqw5tTg=="], + "@zed-industries/codex-acp-win32-x64": ["@zed-industries/codex-acp-win32-x64@0.11.1", "", { "os": "win32", "cpu": "x64", "bin": { "codex-acp-win32-x64": "bin/codex-acp.exe" } }, "sha512-nOdlp/xHQGzqt+GnB2rrpk0sT7pPJVKkL9M+UhmoFPSFwjWrkzAOJukbT4P59wU1mVBTe84dEW6UnzydWIrGJw=="], "abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], @@ -1262,7 +1269,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=="], @@ -1502,6 +1509,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=="], @@ -1770,7 +1779,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=="], @@ -1928,9 +1937,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=="], @@ -2146,6 +2155,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=="], @@ -2310,6 +2321,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=="], @@ -2358,10 +2371,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 +2469,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 +2531,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=="], @@ -2540,6 +2549,12 @@ "@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/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=="], @@ -2598,14 +2613,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=="], @@ -2620,14 +2637,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/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/0009_wise_rumiko_fujikawa.sql b/drizzle/0009_wise_rumiko_fujikawa.sql new file mode 100644 index 000000000..aa86b6b84 --- /dev/null +++ b/drizzle/0009_wise_rumiko_fujikawa.sql @@ -0,0 +1,3 @@ +CREATE INDEX `chats_project_id_idx` ON `chats` (`project_id`);--> statement-breakpoint +CREATE INDEX `sub_chats_chat_id_idx` ON `sub_chats` (`chat_id`);--> statement-breakpoint +CREATE INDEX `sub_chats_stream_id_idx` ON `sub_chats` (`stream_id`); \ No newline at end of file diff --git a/drizzle/0010_careless_proudstar.sql b/drizzle/0010_careless_proudstar.sql new file mode 100644 index 000000000..96ab78e16 --- /dev/null +++ b/drizzle/0010_careless_proudstar.sql @@ -0,0 +1 @@ +ALTER TABLE `projects` ADD `git_project` text; \ 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/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 000000000..2c341534c --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,480 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "117fd460-8aeb-4646-8ff8-010720741f5f", + "prevId": "703af73c-d5ad-4d1e-9405-252a3086df88", + "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 + }, + "chats_project_id_idx": { + "name": "chats_project_id_idx", + "columns": [ + "project_id" + ], + "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": { + "sub_chats_chat_id_idx": { + "name": "sub_chats_chat_id_idx", + "columns": [ + "chat_id" + ], + "isUnique": false + }, + "sub_chats_stream_id_idx": { + "name": "sub_chats_stream_id_idx", + "columns": [ + "stream_id" + ], + "isUnique": false + } + }, + "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/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 000000000..99afe8987 --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,487 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "8791bf3c-58ec-4622-bca1-219e8b1e9c67", + "prevId": "117fd460-8aeb-4646-8ff8-010720741f5f", + "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 + }, + "chats_project_id_idx": { + "name": "chats_project_id_idx", + "columns": [ + "project_id" + ], + "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 + }, + "git_project": { + "name": "git_project", + "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": { + "sub_chats_chat_id_idx": { + "name": "sub_chats_chat_id_idx", + "columns": [ + "chat_id" + ], + "isUnique": false + }, + "sub_chats_stream_id_idx": { + "name": "sub_chats_stream_id_idx", + "columns": [ + "stream_id" + ], + "isUnique": false + } + }, + "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..65650fed3 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,27 @@ "when": 1769810815497, "tag": "0007_clammy_grim_reaper", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1776516765268, + "tag": "0008_shiny_hydra", + "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1776530863612, + "tag": "0009_wise_rumiko_fujikawa", + "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1776705123163, + "tag": "0010_careless_proudstar", + "breakpoints": true } ] } \ No newline at end of file diff --git a/openspec/project.md b/openspec/project.md index 9fa38c628..6d1711332 100644 --- a/openspec/project.md +++ b/openspec/project.md @@ -11,7 +11,7 @@ | Components | Radix UI, Lucide icons, Motion, Sonner | | State | Jotai, Zustand, React Query | | Backend | tRPC, Drizzle ORM, better-sqlite3 | -| AI | @anthropic-ai/claude-code | +| AI | @anthropic-ai/claude-agent-sdk | | Package Manager | bun | ## Project Conventions @@ -30,7 +30,7 @@ - Zustand: Sub-chat tabs and pinned state (persisted to localStorage) - React Query: Server state via tRPC (auto-caching, refetch) - **Database**: Drizzle ORM with SQLite, auto-migration on app startup -- **Claude Integration**: Dynamic import of `@anthropic-ai/claude-code` SDK with two modes: "plan" (read-only) and "agent" (full permissions) +- **Claude Integration**: Dynamic import of `@anthropic-ai/claude-agent-sdk` SDK with two modes: "plan" (read-only) and "agent" (full permissions) ### Testing Strategy [Testing approach not yet established - to be defined] @@ -53,6 +53,6 @@ - Dev vs Production use separate userData paths and protocols ## External Dependencies -- **Claude Code SDK**: `@anthropic-ai/claude-code` for AI interactions +- **Claude Code SDK**: `@anthropic-ai/claude-agent-sdk` for AI interactions - **21st.dev CDN**: Auto-update manifests and releases at `cdn.21st.dev` - **OAuth Provider**: Authentication flow diff --git a/package.json b/package.json index da2a5e747..38fa79c90 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", @@ -20,10 +20,10 @@ "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", - "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", + "claude:download": "node scripts/download-claude-binary.mjs --version=2.1.118", + "claude:download:all": "node scripts/download-claude-binary.mjs --version=2.1.118 --all", + "codex:download": "node scripts/download-codex-binary.mjs --version=0.124.0", + "codex:download:all": "node scripts/download-codex-binary.mjs --version=0.124.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", "release:dev": "rm -rf release && bun run claude:download && bun run codex:download && bun run build && bun run package:mac && rm -rf node_modules && bun i", "sync:public": "./scripts/sync-to-public.sh", @@ -36,13 +36,16 @@ }, "dependencies": { "@ai-sdk/react": "^3.0.14", - "@anthropic-ai/claude-agent-sdk": "0.2.45", + "@anthropic-ai/claude-agent-sdk": "0.2.118", + "@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", - "@modelcontextprotocol/sdk": "^1.25.3", + "@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", @@ -73,7 +76,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.11.1", "ai": "^6.0.14", "async-mutex": "^0.5.0", "better-sqlite3": "^12.6.2", diff --git a/scripts/download-codex-binary.mjs b/scripts/download-codex-binary.mjs index 30fd9477d..707d766ab 100644 --- a/scripts/download-codex-binary.mjs +++ b/scripts/download-codex-binary.mjs @@ -5,7 +5,7 @@ * Usage: * node scripts/download-codex-binary.mjs # Download for current platform * node scripts/download-codex-binary.mjs --all # Download all platforms - * node scripts/download-codex-binary.mjs --version=0.98.0 + * node scripts/download-codex-binary.mjs --version=0.124.0 */ import fs from "node:fs" diff --git a/src/main/auth-manager.ts b/src/main/auth-manager.ts index e31b7bc1b..2d5c6fe71 100644 --- a/src/main/auth-manager.ts +++ b/src/main/auth-manager.ts @@ -1,6 +1,10 @@ import { AuthStore, AuthData, AuthUser } from "./auth-store" import { app, BrowserWindow } from "electron" import { AUTH_SERVER_PORT } from "./constants" +import { randomBytes, timingSafeEqual } from "node:crypto" + +// How long a `state` nonce is accepted after startAuthFlow() is called. +const AUTH_STATE_TTL_MS = 10 * 60 * 1000 // Get API URL - in packaged app always use production, in dev allow override function getApiBaseUrl(): string { @@ -15,6 +19,9 @@ export class AuthManager { private refreshTimer?: NodeJS.Timeout private isDev: boolean private onTokenRefresh?: (authData: AuthData) => void + // Tracks the in-flight OAuth `state` nonce from the most recent + // startAuthFlow() call. Used to reject unsolicited deep-link codes. + private pendingAuthState: { state: string; issuedAt: number } | null = null constructor(isDev: boolean = false) { this.store = new AuthStore(app.getPath("userData")) @@ -26,6 +33,41 @@ export class AuthManager { } } + /** + * Verify that an incoming OAuth callback was solicited by this app. + * + * - If the callback carries a `state` parameter, it must match the stored + * nonce (constant-time compare). + * - If no `state` is present (backend doesn't echo it), fall back to + * requiring that startAuthFlow() was called within the TTL window. + * + * Either path consumes the nonce — codes can't be replayed. + */ + verifyAndConsumeAuthState(incomingState: string | null): boolean { + const pending = this.pendingAuthState + this.pendingAuthState = null + + if (!pending) return false + if (Date.now() - pending.issuedAt > AUTH_STATE_TTL_MS) return false + + if (incomingState) { + const expected = Buffer.from(pending.state, "utf8") + const actual = Buffer.from(incomingState, "utf8") + if (expected.length !== actual.length) return false + try { + return timingSafeEqual(expected, actual) + } catch { + return false + } + } + + // No state echoed by server — accept only because a recent flow is + // in-flight. This is weaker than state matching but still prevents the + // passive-CSRF drive-by (attacker can't land a code on a user who didn't + // just click Sign In). + return true + } + /** * Set callback to be called when token is refreshed * This allows the main process to update cookies when tokens change @@ -209,7 +251,13 @@ export class AuthManager { startAuthFlow(mainWindow: BrowserWindow | null): void { const { shell } = require("electron") - let authUrl = `${this.getApiUrl()}/auth/desktop?auto=true` + // Generate a fresh anti-CSRF nonce. If the backend echoes `state` back in + // the callback, we strict-match it. If not, the presence of a recent + // in-flight flow alone gates code acceptance (see verifyAndConsumeAuthState). + const state = randomBytes(32).toString("hex") + this.pendingAuthState = { state, issuedAt: Date.now() } + + let authUrl = `${this.getApiUrl()}/auth/desktop?auto=true&state=${state}` // In dev mode, use localhost callback (we run HTTP server on AUTH_SERVER_PORT) // Also pass the protocol so web knows which deep link to use as fallback diff --git a/src/main/index.ts b/src/main/index.ts index 57af873f0..83ad366f6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -192,10 +192,19 @@ function handleDeepLink(url: string): void { try { const parsed = new URL(url) - // Handle auth callback: twentyfirst-agents://auth?code=xxx + // Handle auth callback: twentyfirst-agents://auth?code=xxx&state=yyy if (parsed.pathname === "/auth" || parsed.host === "auth") { const code = parsed.searchParams.get("code") + const state = parsed.searchParams.get("state") if (code) { + const verified = + authManager.verifyAndConsumeAuthState(state) + if (!verified) { + console.warn( + "[DeepLink] Rejected /auth code: no in-flight auth flow or state mismatch", + ) + return + } handleAuthCode(code) return } @@ -621,8 +630,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, @@ -631,6 +639,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}...` @@ -649,6 +659,7 @@ if (gotTheLock) { } }, }, + */ { type: "separator" }, { label: "Settings...", @@ -868,9 +879,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) @@ -882,8 +898,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 @@ -939,9 +957,16 @@ if (gotTheLock) { console.error("[App] Failed to initialize database:", error) } + // 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() + // UPDATES-DISABLED: re-enable to restore auto-updater startup + /* // Initialize auto-updater (production only) if (app.isPackaged) { await initAutoUpdater(getAllWindows) @@ -952,6 +977,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 @@ -1003,8 +1033,47 @@ if (gotTheLock) { app.on("before-quit", async () => { console.log("[App] Shutting down...") cancelAllPendingOAuth() - await cleanupGitWatchers() + + // Bound the watcher cleanup so a hung chokidar instance can't block quit. + // 1500ms is enough for well-behaved close handlers; OS will reclaim handles + // if we have to move on without them. + const WATCHER_CLEANUP_TIMEOUT_MS = 1500 + try { + await Promise.race([ + cleanupGitWatchers(), + new Promise((resolve) => + setTimeout(() => { + console.warn( + "[App] cleanupGitWatchers() exceeded timeout; continuing shutdown", + ) + resolve() + }, WATCHER_CLEANUP_TIMEOUT_MS), + ), + ]) + } catch (err) { + console.warn("[App] cleanupGitWatchers() threw during shutdown:", err) + } + 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/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/main/lib/claude-token.ts b/src/main/lib/claude-token.ts index 204c45c56..7ecd33792 100644 --- a/src/main/lib/claude-token.ts +++ b/src/main/lib/claude-token.ts @@ -254,26 +254,35 @@ function getExtendedPath(): string { } /** - * Check if Claude CLI is installed (cross-platform) - * Uses extended PATH to find claude even when running from Finder/Dock + * Resolve the absolute path to the `claude` CLI using an extended PATH. + * Returns null if the binary cannot be found. */ -export function isClaudeCliInstalled(): boolean { +function resolveClaudeCliPath(): string | null { try { - // Use 'where' on Windows, 'which' on Unix-like systems - const command = isWindows() ? 'where claude' : 'which claude'; const fullPath = getExtendedPath(); - - execSync(command, { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, PATH: fullPath } - }); - return true; + const result = execSync( + isWindows() ? 'where claude' : 'which claude', + { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, PATH: fullPath }, + } + ); + const firstLine = result.split(/\r?\n/).find((line) => line.trim().length > 0); + return firstLine?.trim() ?? null; } catch { - return false; + return null; } } +/** + * Check if Claude CLI is installed (cross-platform) + * Uses extended PATH to find claude even when running from Finder/Dock + */ +export function isClaudeCliInstalled(): boolean { + return resolveClaudeCliPath() !== null; +} + /** * Run `claude setup-token` to authenticate with Claude * Returns a promise that resolves when the process completes @@ -288,12 +297,22 @@ export function runClaudeSetupToken( onStatus('Starting Claude setup-token...'); const fullPath = getExtendedPath(); + const claudePath = resolveClaudeCliPath(); + + if (!claudePath) { + resolve({ + success: false, + error: 'Claude CLI not found on PATH. Install it and retry.', + }); + return; + } - const child = spawn('claude', ['setup-token'], { + // Spawn the resolved absolute binary directly — no `shell: true`, so + // metacharacters/spaces in PATH or env are treated as literal args. + const child = spawn(claudePath, ['setup-token'], { // Don't use 'inherit' - it causes hang in non-TTY environments // Use 'ignore' for stdin and 'pipe' for stdout/stderr stdio: ['ignore', 'pipe', 'pipe'], - shell: true, env: { ...process.env, PATH: fullPath }, }); diff --git a/src/main/lib/claude/env.ts b/src/main/lib/claude/env.ts index 0ea2ab0cf..23fa6f650 100644 --- a/src/main/lib/claude/env.ts +++ b/src/main/lib/claude/env.ts @@ -1,5 +1,5 @@ import { app } from "electron" -import { execSync } from "node:child_process" +import { execFileSync } from "node:child_process" import fs from "node:fs" import os from "node:os" import path from "node:path" @@ -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 @@ -172,7 +179,10 @@ export function getClaudeShellEnvironment(): Record { const command = `echo -n "${DELIMITER}"; env; echo -n "${DELIMITER}"; exit` try { - const output = execSync(`${shell} -ilc '${command}'`, { + // Use execFileSync with argv array — no shell-string interpolation, so a + // compromised `shell` path can't smuggle additional commands via the outer + // template literal. + const output = execFileSync(shell, ["-ilc", command], { encoding: "utf8", timeout: 5000, env: { diff --git a/src/main/lib/db/index.ts b/src/main/lib/db/index.ts index d76e964ad..8a499dc12 100644 --- a/src/main/lib/db/index.ts +++ b/src/main/lib/db/index.ts @@ -3,7 +3,7 @@ import { drizzle } from "drizzle-orm/better-sqlite3" import { migrate } from "drizzle-orm/better-sqlite3/migrator" import { app } from "electron" import { join } from "path" -import { existsSync, mkdirSync } from "fs" +import { existsSync, mkdirSync, renameSync } from "fs" import * as schema from "./schema" let db: ReturnType> | null = null @@ -37,8 +37,22 @@ function getMigrationsPath(): string { return join(__dirname, "../../drizzle") } +function openConnection(dbPath: string) { + const conn = new Database(dbPath) + conn.pragma("journal_mode = WAL") + // synchronous=NORMAL is safe under WAL and materially reduces fsync load + // during high-frequency writes (e.g., streaming message persistence). + conn.pragma("synchronous = NORMAL") + conn.pragma("foreign_keys = ON") + return conn +} + /** - * Initialize the database with Drizzle ORM + * Initialize the database with Drizzle ORM. + * + * If migrations fail (e.g., corrupted DB, downgrade), the broken file is + * renamed to `agents.db.broken-` and a fresh DB is created so the + * app remains launchable. The broken file is kept for user/support triage. */ export function initDatabase() { if (db) { @@ -48,15 +62,9 @@ export function initDatabase() { const dbPath = getDatabasePath() console.log(`[DB] Initializing database at: ${dbPath}`) - // Create SQLite connection - sqlite = new Database(dbPath) - sqlite.pragma("journal_mode = WAL") - sqlite.pragma("foreign_keys = ON") - - // Create Drizzle instance + sqlite = openConnection(dbPath) db = drizzle(sqlite, { schema }) - // Run migrations const migrationsPath = getMigrationsPath() console.log(`[DB] Running migrations from: ${migrationsPath}`) @@ -65,7 +73,27 @@ export function initDatabase() { console.log("[DB] Migrations completed") } catch (error) { console.error("[DB] Migration error:", error) - throw error + + // Recovery: close the connection, quarantine the file, start fresh. + try { + sqlite?.close() + } catch {} + sqlite = null + db = null + + const brokenPath = `${dbPath}.broken-${Date.now()}` + try { + renameSync(dbPath, brokenPath) + console.warn(`[DB] Quarantined broken DB to: ${brokenPath}`) + } catch (renameErr) { + console.error("[DB] Could not rename broken DB:", renameErr) + throw error + } + + sqlite = openConnection(dbPath) + db = drizzle(sqlite, { schema }) + migrate(db, { migrationsFolder: migrationsPath }) + console.log("[DB] Recovery complete: fresh DB initialized") } return db diff --git a/src/main/lib/db/schema/index.ts b/src/main/lib/db/schema/index.ts index fe6aa3490..e7bcb2f5b 100644 --- a/src/main/lib/db/schema/index.ts +++ b/src/main/lib/db/schema/index.ts @@ -17,9 +17,10 @@ export const projects = sqliteTable("projects", { ), // Git remote info (extracted from local .git) gitRemoteUrl: text("git_remote_url"), - gitProvider: text("git_provider"), // "github" | "gitlab" | "bitbucket" | null + gitProvider: text("git_provider"), // "github" | "gitlab" | "bitbucket" | "azure" | null gitOwner: text("git_owner"), gitRepo: text("git_repo"), + gitProject: text("git_project"), // Azure DevOps project (null for other providers) // Custom project icon (absolute path to local image file) iconPath: text("icon_path"), }) @@ -53,6 +54,7 @@ export const chats = sqliteTable("chats", { prNumber: integer("pr_number"), }, (table) => [ index("chats_worktree_path_idx").on(table.worktreePath), + index("chats_project_id_idx").on(table.projectId), ]) export const chatsRelations = relations(chats, ({ one, many }) => ({ @@ -76,13 +78,20 @@ 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(), ), updatedAt: integer("updated_at", { mode: "timestamp" }).$defaultFn( () => new Date(), ), -}) +}, (table) => [ + index("sub_chats_chat_id_idx").on(table.chatId), + index("sub_chats_stream_id_idx").on(table.streamId), +]) export const subChatsRelations = relations(subChats, ({ one }) => ({ chat: one(chats, { 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/git-operations.ts b/src/main/lib/git/git-operations.ts index e027077a4..23f39a23d 100644 --- a/src/main/lib/git/git-operations.ts +++ b/src/main/lib/git/git-operations.ts @@ -2,9 +2,13 @@ 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 { buildCreatePRWebUrl, fetchPRStatus, resolveProvider } from "./providers"; import { gitCache } from "./cache"; import { createGit, @@ -67,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( @@ -247,13 +303,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); @@ -577,6 +644,7 @@ export const createGitOperationsRouter = () => { .input( z.object({ worktreePath: z.string(), + baseBranch: z.string().optional(), }), ) .mutation( @@ -598,18 +666,20 @@ export const createGitOperationsRouter = () => { await withLockRetry(input.worktreePath, () => git.push()); } - // Get the remote URL to construct the GitHub compare URL const remoteUrl = (await git.remote(["get-url", "origin"])) || ""; - const repoMatch = remoteUrl - .trim() - .match(/github\.com[:/](.+?)(?:\.git)?$/); - - if (!repoMatch) { - throw new Error("Could not determine GitHub repository URL"); + const provider = await resolveProvider(input.worktreePath); + if (!provider) { + throw new Error( + "Could not determine repository provider from remote URL", + ); } - const repo = repoMatch[1].replace(/\.git$/, ""); - const url = `https://github.com/${repo}/compare/${branch}?expand=1`; + const url = buildCreatePRWebUrl({ + provider, + remoteUrl: remoteUrl.trim(), + branch, + baseBranch: input.baseBranch ?? "main", + }); await shell.openExternal(url); await git.fetch(); @@ -620,6 +690,9 @@ export const createGitOperationsRouter = () => { }, ), + // Procedure name preserved for back-compat with the renderer (usePRStatus + // hook calls `trpc.changes.getGitHubStatus.useQuery`). Under the hood it + // now dispatches by provider — GitHub stays byte-identical, Azure uses az. getGitHubStatus: publicProcedure .input( z.object({ @@ -628,7 +701,7 @@ export const createGitOperationsRouter = () => { ) .query(async ({ input }) => { assertRegisteredWorktree(input.worktreePath); - return await fetchGitHubPRStatus(input.worktreePath); + return await fetchPRStatus(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/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/git/index.ts b/src/main/lib/git/index.ts index eca95f673..9074b923e 100644 --- a/src/main/lib/git/index.ts +++ b/src/main/lib/git/index.ts @@ -40,13 +40,15 @@ export const createGitRouter = () => { // ============ GIT REMOTE INFO ============ -export type GitProvider = "github" | "gitlab" | "bitbucket" | null; +export type GitProvider = "github" | "gitlab" | "bitbucket" | "azure" | null; export interface GitRemoteInfo { remoteUrl: string | null; provider: GitProvider; owner: string | null; repo: string | null; + /** Only populated for Azure DevOps (org / project / repo). Null otherwise. */ + project: string | null; } /** @@ -81,6 +83,62 @@ function parseGitRemoteUrl(url: string): Omit { let owner: string | null = null; let repo: string | null = null; + // Azure HTTPS: https://[org@]dev.azure.com/{org}/{project}/_git/{repo} + const azureHttpsMatch = normalized.match( + /https?:\/\/(?:[^@/]+@)?dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)/, + ); + if (azureHttpsMatch) { + const [, orgPart, projectPart, repoPart] = azureHttpsMatch; + return { + provider: "azure", + owner: orgPart || null, + project: projectPart || null, + repo: repoPart || null, + }; + } + + // Legacy Azure HTTPS: https://{org}.visualstudio.com/[DefaultCollection/]{project}/_git/{repo} + const legacyAzureHttpsMatch = normalized.match( + /https?:\/\/([^.]+)\.visualstudio\.com\/(?:DefaultCollection\/)?([^/]+)\/_git\/([^/]+)/, + ); + if (legacyAzureHttpsMatch) { + const [, orgPart, projectPart, repoPart] = legacyAzureHttpsMatch; + return { + provider: "azure", + owner: orgPart || null, + project: projectPart || null, + repo: repoPart || null, + }; + } + + // Azure SSH: git@ssh.dev.azure.com:v3/{org}/{project}/{repo} + const azureSshMatch = normalized.match( + /git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/(.+)/, + ); + if (azureSshMatch) { + const [, orgPart, projectPart, repoPart] = azureSshMatch; + return { + provider: "azure", + owner: orgPart || null, + project: projectPart || null, + repo: repoPart || null, + }; + } + + // Legacy Azure SSH: {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo} + const legacyVsSshMatch = normalized.match( + /[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/(.+)/, + ); + if (legacyVsSshMatch) { + const [, orgPart, projectPart, repoPart] = legacyVsSshMatch; + return { + provider: "azure", + owner: orgPart || null, + project: projectPart || null, + repo: repoPart || null, + }; + } + // Match HTTPS format: https://github.com/owner/repo const httpsMatch = normalized.match( /https?:\/\/(github\.com|gitlab\.com|bitbucket\.org)\/([^/]+)\/([^/]+)/, @@ -97,7 +155,7 @@ function parseGitRemoteUrl(url: string): Omit { : null; owner = ownerPart || null; repo = repoPart || null; - return { provider, owner, repo }; + return { provider, owner, repo, project: null }; } // Match SSH format: git@github.com:owner/repo @@ -116,10 +174,10 @@ function parseGitRemoteUrl(url: string): Omit { : null; owner = ownerPart || null; repo = repoPart || null; - return { provider, owner, repo }; + return { provider, owner, repo, project: null }; } - return { provider: null, owner: null, repo: null }; + return { provider: null, owner: null, repo: null, project: null }; } /** @@ -134,6 +192,7 @@ export async function getGitRemoteInfo( provider: null, owner: null, repo: null, + project: null, }; // Check if it's a git repo diff --git a/src/main/lib/git/providers/azure/azure.ts b/src/main/lib/git/providers/azure/azure.ts new file mode 100644 index 000000000..f03a2ea6b --- /dev/null +++ b/src/main/lib/git/providers/azure/azure.ts @@ -0,0 +1,450 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { execWithShellEnv } from "../../shell-env"; +import { branchExistsOnRemote } from "../../worktree"; +import { getGitRemoteInfo } from "../../index"; +import type { GitHubStatus, PRComment } from "../../github/types"; +import { detectAzureCli, detectionToToastMessage } from "./detect"; +import { + type AzurePR, + type AzurePolicyEval, + AzurePRSchema, + AzurePolicyEvalSchema, +} from "./types"; +import { parseAzureRemoteUrl, type AzureRemote } from "./parse-url"; + +const execFileAsync = promisify(execFile); + +// 10s status cache — matches the GitHub provider. +const statusCache = new Map(); +const STATUS_TTL_MS = 10_000; + +// 30s comments cache — stubbed to [] in v1 but wired for future use. +const commentsCache = new Map(); +const COMMENTS_TTL_MS = 30_000; + +export function invalidateAzurePRCache(worktreePath?: string): void { + if (worktreePath) { + statusCache.delete(worktreePath); + } else { + statusCache.clear(); + } +} + +export function invalidateAzurePRCommentsCache(worktreePath?: string): void { + if (worktreePath) { + commentsCache.delete(worktreePath); + } else { + commentsCache.clear(); + } +} + +/** + * Fetch PR status for the current branch in an Azure DevOps worktree. + * Returns null for any failure (missing CLI, not logged in, no PR, network, etc.) + * so the polling UI never throws. Cached 10s per worktree. + */ +export async function fetchAzurePRStatus( + worktreePath: string, +): Promise { + const cached = statusCache.get(worktreePath); + if (cached && Date.now() - cached.timestamp < STATUS_TTL_MS) { + return cached.data; + } + + try { + const detection = await detectAzureCli(); + if (detection.status !== "ok") return null; + + const remote = await resolveAzureRemote(worktreePath); + if (!remote) return null; + + const branch = await getCurrentBranch(worktreePath); + if (!branch) return null; + + const [branchCheck, prSummary] = await Promise.all([ + branchExistsOnRemote(worktreePath, branch), + findPRForBranch(worktreePath, remote, branch), + ]); + const existsOnRemote = branchCheck.status === "exists"; + + let prData: GitHubStatus["pr"] = null; + if (prSummary) { + const [full, checks] = await Promise.all([ + showPR(worktreePath, remote, prSummary.pullRequestId), + listPolicyChecks(worktreePath, remote, prSummary.pullRequestId), + ]); + if (full) { + prData = mapAzurePRToStatus(full, checks, remote); + } + } + + const result: GitHubStatus = { + pr: prData, + repoUrl: remote.repoWebUrl, + branchExistsOnRemote: existsOnRemote, + lastRefreshed: Date.now(), + }; + + statusCache.set(worktreePath, { data: result, timestamp: Date.now() }); + return result; + } catch (err) { + console.warn("[Azure] fetchAzurePRStatus failed:", err); + return null; + } +} + +/** v1: stubbed to [] — Azure's thread-based comment model is deferred to v2. */ +export async function fetchAzurePRComments( + _worktreePath: string, +): Promise { + return []; +} + +/** + * Complete an Azure PR. Throws on user-visible errors with MERGE_CONFLICT: + * prefix when appropriate (matches GitHub's contract with the renderer). + */ +export async function mergeAzurePR(args: { + worktreePath: string; + prNumber: number; + method: "merge" | "squash" | "rebase"; +}): Promise<{ success: true }> { + const detection = await detectAzureCli(); + if (detection.status !== "ok") { + throw new Error(detectionToToastMessage(detection)); + } + + const remote = await resolveAzureRemote(args.worktreePath); + if (!remote) { + throw new Error("Could not determine Azure DevOps remote for this worktree."); + } + + // Precheck mergeability so we can surface the existing MERGE_CONFLICT: contract. + const pr = await showPR(args.worktreePath, remote, args.prNumber); + if (pr?.mergeStatus === "conflicts") { + throw new Error( + "MERGE_CONFLICT: PR has merge conflicts. Sync with the target branch and resolve them.", + ); + } + + if (args.method === "rebase") { + console.warn( + "[Azure] Rebase merge not directly supported by az CLI; falling back to non-squash merge.", + ); + } + + const azArgs = [ + "repos", + "pr", + "update", + "--id", + String(args.prNumber), + "--status", + "completed", + "--squash", + args.method === "squash" ? "true" : "false", + "--delete-source-branch", + "true", + "--organization", + remote.orgUrl, + "--output", + "json", + ]; + + try { + await execWithShellEnv("az", azArgs, { cwd: args.worktreePath }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/conflict/i.test(msg) || /not mergeable/i.test(msg)) { + throw new Error(`MERGE_CONFLICT: ${msg}`); + } + throw new Error(`Azure PR merge failed: ${msg}`); + } + + invalidateAzurePRCache(args.worktreePath); + invalidateAzurePRCommentsCache(args.worktreePath); + return { success: true }; +} + +export async function updateAzurePRTitle(args: { + worktreePath: string; + title: string; + prNumber?: number; +}): Promise<{ success: true; title: string }> { + const detection = await detectAzureCli(); + if (detection.status !== "ok") { + throw new Error(detectionToToastMessage(detection)); + } + + const remote = await resolveAzureRemote(args.worktreePath); + if (!remote) { + throw new Error("Could not determine Azure DevOps remote for this worktree."); + } + + let prNumber = args.prNumber; + if (prNumber == null) { + const branch = await getCurrentBranch(args.worktreePath); + if (!branch) { + throw new Error("Could not determine current branch."); + } + const summary = await findPRForBranch(args.worktreePath, remote, branch); + if (!summary) { + throw new Error("No pull request found for the current branch."); + } + prNumber = summary.pullRequestId; + } + + const azArgs = [ + "repos", + "pr", + "update", + "--id", + String(prNumber), + "--title", + args.title, + "--organization", + remote.orgUrl, + "--output", + "json", + ]; + + try { + await execWithShellEnv("az", azArgs, { cwd: args.worktreePath }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Azure PR title update failed: ${msg}`); + } + + invalidateAzurePRCache(args.worktreePath); + return { success: true, title: args.title }; +} + +// ---------- internal helpers ---------- + +async function resolveAzureRemote( + worktreePath: string, +): Promise { + const info = await getGitRemoteInfo(worktreePath); + if (info.provider !== "azure" || !info.remoteUrl) return null; + return parseAzureRemoteUrl(info.remoteUrl); +} + +async function getCurrentBranch(worktreePath: string): Promise { + try { + const { stdout } = await execFileAsync( + "git", + ["rev-parse", "--abbrev-ref", "HEAD"], + { cwd: worktreePath }, + ); + const b = stdout.trim(); + return b || null; + } catch { + return null; + } +} + +/** Return the first active PR (or most recent non-active) for the branch. */ +async function findPRForBranch( + worktreePath: string, + remote: AzureRemote, + branch: string, +): Promise { + const base = [ + "repos", + "pr", + "list", + "--source-branch", + `refs/heads/${branch}`, + "--repository", + remote.repository, + "--project", + remote.project, + "--organization", + remote.orgUrl, + "--output", + "json", + ]; + + // Try active first, fall back to all (so merged/abandoned PRs still display). + for (const status of ["active", "all"] as const) { + try { + const { stdout } = await execWithShellEnv( + "az", + [...base, "--status", status], + { cwd: worktreePath }, + ); + const raw = JSON.parse(stdout); + if (!Array.isArray(raw) || raw.length === 0) continue; + + // Pick the highest pullRequestId (most recent) + const sorted = raw + .map((r) => AzurePRSchema.safeParse(r)) + .filter((r) => r.success) + .map((r) => (r.success ? r.data : null)) + .filter((r): r is AzurePR => r !== null) + .sort((a, b) => b.pullRequestId - a.pullRequestId); + + if (sorted[0]) return sorted[0]; + } catch { + // fall through to next status + } + } + + return null; +} + +async function showPR( + worktreePath: string, + remote: AzureRemote, + prId: number, +): Promise { + try { + const { stdout } = await execWithShellEnv( + "az", + [ + "repos", + "pr", + "show", + "--id", + String(prId), + "--organization", + remote.orgUrl, + "--output", + "json", + ], + { cwd: worktreePath }, + ); + const raw = JSON.parse(stdout); + const parsed = AzurePRSchema.safeParse(raw); + if (!parsed.success) { + console.warn("[Azure] PR schema validation failed:", parsed.error); + return null; + } + return parsed.data; + } catch (err) { + console.warn("[Azure] showPR failed:", err); + return null; + } +} + +async function listPolicyChecks( + worktreePath: string, + remote: AzureRemote, + prId: number, +): Promise { + try { + const { stdout } = await execWithShellEnv( + "az", + [ + "repos", + "pr", + "policy", + "list", + "--id", + String(prId), + "--organization", + remote.orgUrl, + "--output", + "json", + ], + { cwd: worktreePath }, + ); + const raw = JSON.parse(stdout); + if (!Array.isArray(raw)) return []; + return raw + .map((r) => AzurePolicyEvalSchema.safeParse(r)) + .filter((r) => r.success) + .map((r) => (r.success ? r.data : null)) + .filter((r): r is AzurePolicyEval => r !== null); + } catch { + return []; + } +} + +function mapAzurePRToStatus( + pr: AzurePR, + policies: AzurePolicyEval[], + remote: AzureRemote, +): NonNullable { + return { + number: pr.pullRequestId, + title: pr.title, + url: `${remote.repoWebUrl}/pullrequest/${pr.pullRequestId}`, + state: mapState(pr.status, pr.isDraft ?? false), + mergedAt: + pr.status === "completed" && pr.closedDate + ? Date.parse(pr.closedDate) || undefined + : undefined, + additions: 0, // v1: Azure JSON doesn't expose this cheaply + deletions: 0, + reviewDecision: mapReviewDecision(pr.reviewers ?? []), + checksStatus: computeChecksStatus(policies), + checks: policies.map((p) => ({ + name: p.configuration?.type?.displayName ?? "Policy", + status: mapPolicyStatus(p.status), + })), + mergeable: mapMergeable(pr.mergeStatus), + }; +} + +function mapState( + status: AzurePR["status"], + isDraft: boolean, +): NonNullable["state"] { + if (status === "completed") return "merged"; + if (status === "abandoned") return "closed"; + if (isDraft) return "draft"; + return "open"; +} + +function mapReviewDecision( + reviewers: NonNullable, +): NonNullable["reviewDecision"] { + if (reviewers.some((r) => r.vote === -10)) return "changes_requested"; + const required = reviewers.filter((r) => r.isRequired); + if (required.length > 0 && required.every((r) => r.vote >= 5)) { + return "approved"; + } + if (reviewers.some((r) => r.vote >= 5) && required.length === 0) { + return "approved"; + } + return "pending"; +} + +function mapMergeable( + status: AzurePR["mergeStatus"], +): NonNullable["mergeable"] { + if (status === "conflicts" || status === "rejectedByPolicy" || status === "failure") { + return "CONFLICTING"; + } + if (status === "succeeded") return "MERGEABLE"; + return "UNKNOWN"; +} + +function mapPolicyStatus( + status: string, +): NonNullable["checks"][number]["status"] { + const s = status.toLowerCase(); + if (s === "approved") return "success"; + if (s === "rejected" || s === "broken") return "failure"; + if (s === "queued" || s === "running") return "pending"; + if (s === "notapplicable") return "skipped"; + return "pending"; +} + +function computeChecksStatus( + policies: AzurePolicyEval[], +): NonNullable["checksStatus"] { + const relevant = policies.filter( + (p) => p.status.toLowerCase() !== "notapplicable", + ); + if (relevant.length === 0) return "none"; + if (relevant.some((p) => /^(rejected|broken)$/i.test(p.status))) { + return "failure"; + } + if (relevant.some((p) => /^(queued|running)$/i.test(p.status))) { + return "pending"; + } + return "success"; +} diff --git a/src/main/lib/git/providers/azure/detect.ts b/src/main/lib/git/providers/azure/detect.ts new file mode 100644 index 000000000..d8f7f59d7 --- /dev/null +++ b/src/main/lib/git/providers/azure/detect.ts @@ -0,0 +1,88 @@ +import { execWithShellEnv } from "../../shell-env"; + +export type AzureDetection = + | { status: "ok" } + | { status: "missing_cli" } + | { status: "missing_extension" } + | { status: "not_logged_in" } + | { status: "error"; message: string }; + +// Cached for 60s to avoid repeated shell spawns on every PR poll. +let cached: { value: AzureDetection; timestamp: number } | null = null; +const TTL_MS = 60_000; + +/** + * Silent, cached detection of az CLI + azure-devops extension + `az account` auth. + * Never prompts the user, never auto-installs. Safe to call from polling queries. + */ +export async function detectAzureCli(): Promise { + if (cached && Date.now() - cached.timestamp < TTL_MS) { + return cached.value; + } + const value = await runDetection(); + cached = { value, timestamp: Date.now() }; + return value; +} + +/** Drop the cached detection result (e.g. after the user installs `az`). */ +export function invalidateAzureDetection(): void { + cached = null; +} + +async function runDetection(): Promise { + // 1. `az` on PATH? + try { + await execWithShellEnv("which", ["az"]); + } catch { + return { status: "missing_cli" }; + } + + // 2. azure-devops extension installed? + try { + const { stdout } = await execWithShellEnv("az", [ + "extension", + "list", + "--query", + "[?name=='azure-devops'].name", + "-o", + "tsv", + ]); + if (!stdout.trim()) { + return { status: "missing_extension" }; + } + } catch (err) { + return { + status: "error", + message: err instanceof Error ? err.message : String(err), + }; + } + + // 3. logged in? `az account show` errors when there's no subscription context. + try { + await execWithShellEnv("az", ["account", "show", "--output", "json"]); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/az login/i.test(msg)) { + return { status: "not_logged_in" }; + } + return { status: "not_logged_in" }; + } + + return { status: "ok" }; +} + +/** Human-readable toast text for a detection failure. Used by mutations. */ +export function detectionToToastMessage(d: AzureDetection): string { + switch (d.status) { + case "ok": + return ""; + case "missing_cli": + return "Azure CLI not found. Install from https://aka.ms/install-az and retry."; + case "missing_extension": + return "Azure DevOps extension missing. Run: az extension add --name azure-devops"; + case "not_logged_in": + return "Not logged in to Azure. Run az login and retry."; + case "error": + return d.message; + } +} diff --git a/src/main/lib/git/providers/azure/parse-url.ts b/src/main/lib/git/providers/azure/parse-url.ts new file mode 100644 index 000000000..6bd287771 --- /dev/null +++ b/src/main/lib/git/providers/azure/parse-url.ts @@ -0,0 +1,96 @@ +/** + * Azure DevOps remote URL parsing helpers. + * + * Cloud only (v1): dev.azure.com + legacy visualstudio.com hosts. + * On-prem Azure DevOps Server is intentionally out of scope. + */ + +export interface AzureRemote { + organization: string; + project: string; + repository: string; + /** Base org URL without trailing slash, e.g. "https://dev.azure.com/myorg". */ + orgUrl: string; + /** Canonical web URL of the repo, e.g. "https://dev.azure.com/myorg/MyProject/_git/myrepo". */ + repoWebUrl: string; +} + +/** + * Parse any supported Azure DevOps remote URL into structured org/project/repo parts. + * Returns null if the URL doesn't match an Azure pattern. + */ +export function parseAzureRemoteUrl(url: string): AzureRemote | null { + let normalized = url.trim(); + if (normalized.endsWith(".git")) { + normalized = normalized.slice(0, -4); + } + + // HTTPS: https://[org@]dev.azure.com/{org}/{project}/_git/{repo} + const azureHttps = normalized.match( + /https?:\/\/(?:[^@/]+@)?dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)/, + ); + if (azureHttps) { + const [, org, project, repo] = azureHttps; + return buildRemote(org, project, repo); + } + + // Legacy HTTPS: https://{org}.visualstudio.com/[DefaultCollection/]{project}/_git/{repo} + const legacyHttps = normalized.match( + /https?:\/\/([^.]+)\.visualstudio\.com\/(?:DefaultCollection\/)?([^/]+)\/_git\/([^/]+)/, + ); + if (legacyHttps) { + const [, org, project, repo] = legacyHttps; + return buildRemote(org, project, repo); + } + + // SSH: git@ssh.dev.azure.com:v3/{org}/{project}/{repo} + const azureSsh = normalized.match( + /git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/(.+)/, + ); + if (azureSsh) { + const [, org, project, repo] = azureSsh; + return buildRemote(org, project, repo); + } + + // Legacy SSH: {anything}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo} + const legacyVsSsh = normalized.match( + /[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/(.+)/, + ); + if (legacyVsSsh) { + const [, org, project, repo] = legacyVsSsh; + return buildRemote(org, project, repo); + } + + return null; +} + +function buildRemote( + org: string, + project: string, + repo: string, +): AzureRemote { + const orgUrl = `https://dev.azure.com/${org}`; + return { + organization: org, + project, + repository: repo, + orgUrl, + repoWebUrl: `${orgUrl}/${encodeURIComponent(project)}/_git/${encodeURIComponent(repo)}`, + }; +} + +/** + * Build the web URL for an Azure DevOps "Create PR" page, preselecting source + target. + */ +export function buildAzureCreatePRWebUrl(args: { + remote: AzureRemote; + branch: string; + baseBranch: string; +}): string { + const { remote, branch, baseBranch } = args; + return ( + `${remote.repoWebUrl}/pullrequestcreate` + + `?sourceRef=${encodeURIComponent(branch)}` + + `&targetRef=${encodeURIComponent(baseBranch)}` + ); +} diff --git a/src/main/lib/git/providers/azure/types.ts b/src/main/lib/git/providers/azure/types.ts new file mode 100644 index 000000000..61e9e1267 --- /dev/null +++ b/src/main/lib/git/providers/azure/types.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; + +/** + * Narrow zod schemas for az-CLI JSON output. Azure returns a lot more fields than + * we care about — these schemas only validate what we read, everything else is + * permissively ignored. + */ + +export const AzureReviewerSchema = z.object({ + vote: z.number(), + isRequired: z.boolean().optional(), + hasDeclined: z.boolean().optional(), + displayName: z.string().optional(), +}); + +export const AzurePRSchema = z.object({ + pullRequestId: z.number(), + title: z.string(), + status: z.enum(["active", "completed", "abandoned"]), + isDraft: z.boolean().optional(), + mergeStatus: z + .enum([ + "succeeded", + "conflicts", + "queued", + "rejectedByPolicy", + "notSet", + "failure", + ]) + .optional(), + closedDate: z.string().nullable().optional(), + reviewers: z.array(AzureReviewerSchema).optional(), + url: z.string().optional(), +}); + +/** `az repos pr policy list` entry — the interesting bits for check rendering. */ +export const AzurePolicyEvalSchema = z.object({ + status: z.string(), // "approved" | "queued" | "running" | "rejected" | "notApplicable" | "broken" + configuration: z + .object({ + type: z + .object({ + displayName: z.string().optional(), + }) + .optional(), + }) + .optional(), +}); + +export type AzurePR = z.infer; +export type AzurePolicyEval = z.infer; diff --git a/src/main/lib/git/providers/index.ts b/src/main/lib/git/providers/index.ts new file mode 100644 index 000000000..245d2c243 --- /dev/null +++ b/src/main/lib/git/providers/index.ts @@ -0,0 +1,221 @@ +/** + * Provider dispatcher for PR operations. + * + * Resolves the git host from the worktree's origin remote, then delegates to + * the matching provider implementation. GitHub calls are pure forwarders so + * existing behavior stays byte-identical; Azure calls go through the az CLI. + * + * Unknown providers (gitlab, bitbucket, on-prem, etc.) return null / [] from + * queries and throw PROVIDER_UNSUPPORTED from mutations — the renderer handles + * null gracefully (PR widget empty state) and throws as toasts. + */ + +import { getGitRemoteInfo } from "../index"; +import { + fetchGitHubPRStatus, + fetchGitHubPRComments, + invalidateGitHubPRCache, + invalidateGitHubPRCommentsCache, +} from "../github"; +import { + fetchAzurePRStatus, + fetchAzurePRComments, + invalidateAzurePRCache, + invalidateAzurePRCommentsCache, + mergeAzurePR, + updateAzurePRTitle, +} from "./azure/azure"; +import { buildAzureCreatePRWebUrl, parseAzureRemoteUrl } from "./azure/parse-url"; +import type { PullRequestStatus, SupportedProvider, PRComment } from "./types"; + +export type { PullRequestStatus, SupportedProvider, PRComment }; + +// 60s resolver cache — git remotes effectively never change within a session +// and we don't want to shell out to `git remote get-url` on every 10s poll. +const providerCache = new Map< + string, + { value: SupportedProvider | null; timestamp: number } +>(); +const PROVIDER_CACHE_TTL_MS = 60_000; + +/** + * Resolve the supported provider for a worktree. Returns null for gitlab, + * bitbucket, on-prem, or any unrecognized remote. + */ +export async function resolveProvider( + worktreePath: string, +): Promise { + const cached = providerCache.get(worktreePath); + if (cached && Date.now() - cached.timestamp < PROVIDER_CACHE_TTL_MS) { + return cached.value; + } + const info = await getGitRemoteInfo(worktreePath); + const value: SupportedProvider | null = + info.provider === "github" || info.provider === "azure" + ? info.provider + : null; + providerCache.set(worktreePath, { value, timestamp: Date.now() }); + return value; +} + +export function invalidateProviderCache(worktreePath?: string): void { + if (worktreePath) { + providerCache.delete(worktreePath); + } else { + providerCache.clear(); + } +} + +/** + * Fetch PR status for the current branch. Null on any failure — never throws. + * Safe to call from a 10s polling hook. + */ +export async function fetchPRStatus( + worktreePath: string, +): Promise { + const provider = await resolveProvider(worktreePath); + if (provider === "github") return fetchGitHubPRStatus(worktreePath); + if (provider === "azure") return fetchAzurePRStatus(worktreePath); + return null; +} + +/** + * Fetch PR comments for the current branch. Returns [] on failure. Azure v1 + * always returns [] (thread-model stub — see plan "Out of scope"). + */ +export async function fetchPRComments( + worktreePath: string, +): Promise { + const provider = await resolveProvider(worktreePath); + if (provider === "github") return fetchGitHubPRComments(worktreePath); + if (provider === "azure") return fetchAzurePRComments(worktreePath); + return []; +} + +/** + * Merge (complete) a PR. Throws with MERGE_CONFLICT: prefix on conflicts — + * the renderer already recognizes that contract. + */ +export async function mergePR(args: { + worktreePath: string; + prNumber: number; + method: "merge" | "squash" | "rebase"; +}): Promise<{ success: true }> { + const provider = await resolveProvider(args.worktreePath); + if (provider === "azure") return mergeAzurePR(args); + if (provider === "github") { + // Forward to the existing inline gh logic in chats.ts via a tiny helper. + return mergeGitHubPR(args); + } + throw new Error("PROVIDER_UNSUPPORTED: This repository's host is not supported for PR merge."); +} + +/** Update PR title. Throws on failure. */ +export async function updatePRTitle(args: { + worktreePath: string; + title: string; + prNumber?: number; +}): Promise<{ success: true; title: string }> { + const provider = await resolveProvider(args.worktreePath); + if (provider === "azure") return updateAzurePRTitle(args); + if (provider === "github") return updateGitHubPRTitle(args); + throw new Error("PROVIDER_UNSUPPORTED: This repository's host is not supported for PR title updates."); +} + +/** Invalidate PR status cache in BOTH providers (safe no-op per provider). */ +export function invalidatePRCache(worktreePath?: string): void { + invalidateGitHubPRCache(worktreePath); + invalidateAzurePRCache(worktreePath); +} + +/** Invalidate PR comments cache in BOTH providers. */ +export function invalidatePRCommentsCache(worktreePath?: string): void { + invalidateGitHubPRCommentsCache(worktreePath); + invalidateAzurePRCommentsCache(worktreePath); +} + +/** + * Build the browser URL for the "Create PR" page on the given provider. + */ +export function buildCreatePRWebUrl(args: { + provider: SupportedProvider; + remoteUrl: string; + branch: string; + baseBranch: string; +}): string { + if (args.provider === "github") { + // Preserves existing behavior from git-operations.createPR — a compare + // URL for the branch. `baseBranch` isn't needed in the GitHub form; + // GitHub's UI defaults to the repo's default branch. + const match = args.remoteUrl.match( + /github\.com[:/]([^/]+)\/([^/.]+)/, + ); + if (!match) { + throw new Error("Could not parse GitHub remote URL"); + } + const [, owner, repo] = match; + return `https://github.com/${owner}/${repo}/compare/${encodeURIComponent(args.branch)}?expand=1`; + } + + const remote = parseAzureRemoteUrl(args.remoteUrl); + if (!remote) { + throw new Error("Could not parse Azure DevOps remote URL"); + } + return buildAzureCreatePRWebUrl({ + remote, + branch: args.branch, + baseBranch: args.baseBranch, + }); +} + +// ---------- GitHub mutation adapters ---------- +// These wrap the existing gh-CLI calls currently inlined in chats.ts, so +// the tRPC router can call a single dispatcher regardless of provider. + +import { execWithShellEnv } from "../shell-env"; + +async function mergeGitHubPR(args: { + worktreePath: string; + prNumber: number; + method: "merge" | "squash" | "rebase"; +}): Promise<{ success: true }> { + const flag = `--${args.method}`; + try { + await execWithShellEnv( + "gh", + ["pr", "merge", String(args.prNumber), flag, "--delete-branch"], + { cwd: args.worktreePath }, + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/conflict/i.test(msg) || /not mergeable/i.test(msg) || /CONFLICTING/.test(msg)) { + throw new Error(`MERGE_CONFLICT: ${msg}`); + } + throw new Error(`GitHub PR merge failed: ${msg}`); + } + invalidateGitHubPRCache(args.worktreePath); + invalidateGitHubPRCommentsCache(args.worktreePath); + return { success: true }; +} + +async function updateGitHubPRTitle(args: { + worktreePath: string; + title: string; + prNumber?: number; +}): Promise<{ success: true; title: string }> { + const cmd = + args.prNumber != null + ? ["pr", "edit", String(args.prNumber), "--title", args.title] + : ["pr", "edit", "--title", args.title]; + try { + await execWithShellEnv("gh", cmd, { cwd: args.worktreePath }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/no pull request/i.test(msg)) { + throw new Error("No pull request exists for the current branch."); + } + throw new Error(`GitHub PR title update failed: ${msg}`); + } + invalidateGitHubPRCache(args.worktreePath); + return { success: true, title: args.title }; +} diff --git a/src/main/lib/git/providers/types.ts b/src/main/lib/git/providers/types.ts new file mode 100644 index 000000000..7e9127c59 --- /dev/null +++ b/src/main/lib/git/providers/types.ts @@ -0,0 +1,12 @@ +import type { GitHubStatus, PRComment } from "../github/types"; + +/** + * The provider-agnostic PR status shape. Aliased to GitHubStatus for v1 since + * the GitHub provider defined the canonical shape and the renderer types + * already import it everywhere. Renaming later is a mechanical follow-up. + */ +export type PullRequestStatus = GitHubStatus; + +export type { PRComment }; + +export type SupportedProvider = "github" | "azure"; 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, 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-config.ts b/src/main/lib/git/worktree-config.ts index 40112cc2b..b2171bc4a 100644 --- a/src/main/lib/git/worktree-config.ts +++ b/src/main/lib/git/worktree-config.ts @@ -2,6 +2,7 @@ import { readFile, writeFile, mkdir, access } from "node:fs/promises" import { join, dirname, isAbsolute } from "node:path" import { exec } from "node:child_process" import { promisify } from "node:util" +import { getShellEnvironment } from "./shell-env" const execAsync = promisify(exec) @@ -206,6 +207,11 @@ export async function executeWorktreeSetup( console.log(`[worktree-setup] Running ${commandList.length} setup commands in ${worktreePath}`) + // Resolve the user's login-shell PATH so commands like `bun`, `pnpm`, homebrew + // binaries etc. are found when the app is launched from Finder/Dock (Electron + // GUI processes inherit a stripped PATH). + const shellEnv = await getShellEnvironment() + // Execute each command for (const cmd of commandList) { if (!cmd.trim()) continue @@ -217,6 +223,8 @@ export async function executeWorktreeSetup( cwd: worktreePath, env: { ...process.env, + ...shellEnv, + PATH: shellEnv.PATH ?? process.env.PATH, ROOT_WORKTREE_PATH: mainRepoPath, }, timeout: 300_000, // 5 minutes per command diff --git a/src/main/lib/git/worktree.ts b/src/main/lib/git/worktree.ts index 298c3de28..463c02f57 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 { @@ -10,6 +10,7 @@ import { animals, uniqueNamesGenerator, } from "unique-names-generator"; +import { createGitForNetwork, withGitLock } from "./git-factory"; import { checkGitLfsAvailable, getShellEnvironment } from "./shell-env"; import { executeWorktreeSetup } from "./worktree-config"; import type { WorktreeSetupResult } from "./worktree-config"; @@ -223,10 +224,30 @@ 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 }> { + // 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(); @@ -235,13 +256,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 { @@ -926,6 +970,24 @@ export async function createWorktreeForChat( // Use provided base branch or auto-detect const baseBranch = selectedBaseBranch || await getDefaultBranch(projectPath); + // Best-effort: refresh origin/ so the worktree is based on the + // latest remote state. Skipped for local-type branches (user opted into a + // local ref) and when no origin remote exists. Never blocks creation. + if (branchType !== "local" && (await hasOriginRemote(projectPath))) { + try { + await withGitLock(projectPath, async () => { + const netGit = createGitForNetwork(projectPath); + await netGit.fetch("origin", baseBranch); + }); + } catch (fetchError) { + const msg = + fetchError instanceof Error ? fetchError.message : String(fetchError); + console.warn( + `[worktree] Pre-create fetch of origin/${baseBranch} failed: ${msg}`, + ); + } + } + const branch = generateBranchName(); const worktreesDir = join(homedir(), ".21st", "worktrees"); const projectWorktreeDir = join(worktreesDir, projectSlug); 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/agent-utils.ts b/src/main/lib/trpc/routers/agent-utils.ts index ababf03b9..a599bb9a0 100644 --- a/src/main/lib/trpc/routers/agent-utils.ts +++ b/src/main/lib/trpc/routers/agent-utils.ts @@ -5,6 +5,7 @@ import matter from "gray-matter" import { discoverInstalledPlugins, getPluginComponentPaths } from "../../plugins" import { resolveDirentType } from "../../fs/dirent" import { getEnabledPlugins } from "./claude-settings" +import { BUILTIN_SUBAGENTS } from "./builtin-agents" // Valid model values for agents export const VALID_AGENT_MODELS = ["sonnet", "opus", "haiku", "inherit"] as const @@ -277,15 +278,32 @@ export async function buildAgentsOption( ): Promise< Record< string, - { description: string; prompt: string; tools?: string[]; model?: AgentModel } + { + description: string + prompt: string + tools?: string[] + disallowedTools?: string[] + model?: AgentModel + } > > { - if (agentNames.length === 0) return {} - + // Seed with CLI-parity built-ins (Explore, Plan, general-purpose, etc.) so + // Claude inside the app has the same subagent toolkit as CLI users. The SDK + // ships zero built-in subagents, so without this step `@mention`-less queries + // would have no agents at all. + // + // User/project/plugin-defined agents loaded below overwrite same-named + // entries here — matches the CLI override behavior. const agents: Record< string, - { description: string; prompt: string; tools?: string[]; model?: AgentModel } - > = {} + { + description: string + prompt: string + tools?: string[] + disallowedTools?: string[] + model?: AgentModel + } + > = { ...BUILTIN_SUBAGENTS } for (const name of agentNames) { // Create cache key including cwd to handle project-specific agents @@ -307,6 +325,7 @@ export async function buildAgentsOption( description: agent.description, prompt: agent.prompt, ...(agent.tools && { tools: agent.tools }), + ...(agent.disallowedTools && { disallowedTools: agent.disallowedTools }), ...(agent.model && { model: agent.model }), } } diff --git a/src/main/lib/trpc/routers/builtin-agents.ts b/src/main/lib/trpc/routers/builtin-agents.ts new file mode 100644 index 000000000..a7e64910b --- /dev/null +++ b/src/main/lib/trpc/routers/builtin-agents.ts @@ -0,0 +1,82 @@ +import type { AgentModel } from "./agent-utils" + +/** + * CLI-parity built-in subagents. + * + * The Claude Code CLI ships built-in subagents, but + * `@anthropic-ai/claude-agent-sdk` does NOT — the SDK requires every subagent + * to be declared via `options.agents`. This constant restores parity so that + * Claude running inside the 1Code app can invoke the same subagent_types + * (Explore, Plan, general-purpose, etc.) as CLI users. + * + * `buildAgentsOption()` in agent-utils.ts seeds this object first, then + * overlays user/project/plugin-defined agents — so a user-authored `Explore.md` + * correctly overrides the built-in (matches CLI behavior). + * + * Official sources: + * - https://code.claude.com/docs/en/sub-agents + * - https://code.claude.com/docs/en/agent-sdk/subagents + * + * Caveat: Anthropic's docs only formally document `general-purpose`, `Explore`, + * `Plan`, and `statusline-setup` as built-ins. Their exact system prompts and + * tool restrictions are undocumented implementation details that may change + * between CLI versions. The descriptions below are taken from the CLI's Agent + * tool schema at the time of writing (CLI ~v2.1.118). `claude-code-guide` is + * observable in the CLI but not in the public docs — included here for + * completeness. + */ + +type BuiltinAgent = { + description: string + prompt: string + tools?: string[] + disallowedTools?: string[] + model?: AgentModel +} + +/** + * Tools that mutate state — disallowed for read-only subagents (Explore, Plan). + * Using `disallowedTools` (rather than a hand-curated allowlist) keeps parity + * with the CLI's "all tools except X" phrasing and auto-picks up any new tools + * added in future SDK versions. Tool names verified against the SDK's + * AgentDefinition type and 1Code's tool registry at + * src/renderer/features/agents/ui/agent-tool-registry.tsx. + */ +const MUTATING_TOOLS = ["Edit", "Write", "NotebookEdit", "ExitPlanMode"] + +export const BUILTIN_SUBAGENTS: Record = { + Explore: { + description: + 'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.', + // Inherit all tools from parent, minus mutating ones — matches CLI's + // "all tools except Agent, ExitPlanMode, Edit, Write, NotebookEdit". + disallowedTools: MUTATING_TOOLS, + // model omitted → inherits the user's selected model (matches 1Code UX). + prompt: `You are a codebase exploration specialist. Use Glob, Grep, and Read to answer questions about the code. Scale depth to the thoroughness hint in the prompt (quick/medium/very thorough). Return concrete absolute file paths and minimal code excerpts that directly answer the question — no filler. You are READ-ONLY: never Edit, Write, or run mutating shell commands.`, + }, + Plan: { + description: + "Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs.", + disallowedTools: MUTATING_TOOLS, + prompt: `You are a software architect. Read the relevant code first, then produce a concrete step-by-step implementation plan. Consider trade-offs explicitly (simplicity vs. flexibility, perf vs. clarity, etc.). End with a "### Critical Files for Implementation" section listing 3-5 absolute paths the implementer will touch. You are READ-ONLY.`, + }, + "general-purpose": { + description: + "General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.", + // No `tools` or `disallowedTools` → inherits all tools from parent. + // Matches the CLI's "*" semantics for general-purpose. + prompt: `You are a general-purpose agent. Use any available tool to complete the task end-to-end. Prefer doing the whole task rather than reporting partial progress. Return a concise summary of what you did, what you found, and any follow-ups.`, + }, + "statusline-setup": { + description: + "Use this agent to configure the user's Claude Code status line setting.", + tools: ["Read", "Edit"], + prompt: `You help the user configure the statusLine property in ~/.claude/settings.json. Read the file, propose a concrete change, and apply it with Edit. Confirm the shape of the statusLine setting before writing.`, + }, + "claude-code-guide": { + description: + 'Use this agent when the user asks questions ("Can Claude...", "Does Claude...", "How do I...") about: (1) Claude Code (the CLI tool) - features, hooks, slash commands, MCP servers, settings, IDE integrations, keyboard shortcuts; (2) Claude Agent SDK - building custom agents; (3) Claude API (formerly Anthropic API) - API usage, tool use, Anthropic SDK usage.', + tools: ["Glob", "Grep", "Read", "WebFetch", "WebSearch"], + prompt: `You are a Claude Code / Agent SDK / Claude API reference expert. Use WebFetch against https://docs.claude.com/ and https://code.claude.com/docs/ for authoritative answers. Cite the URL of every doc page you rely on. If the answer isn't in the docs, say so — don't guess.`, + }, +} diff --git a/src/main/lib/trpc/routers/chats.ts b/src/main/lib/trpc/routers/chats.ts index a699b445d..8fbbd956c 100644 --- a/src/main/lib/trpc/routers/chats.ts +++ b/src/main/lib/trpc/routers/chats.ts @@ -12,21 +12,28 @@ import { trackWorkspaceDeleted, } from "../../analytics" import { chats, getDatabase, projects, subChats } from "../../db" +import { computeFileStatsFromMessages } from "../../file-stats" import { createWorktreeForChat, - fetchGitHubPRStatus, getWorktreeDiff, removeWorktree, sanitizeProjectName, } from "../../git" +import { + fetchPRStatus, + fetchPRComments, + invalidatePRCache, + mergePR, + updatePRTitle, +} from "../../git/providers" import type { WorktreeSetupResult } from "../../git/worktree-config" import { computeContentHash, gitCache } from "../../git/cache" import { splitUnifiedDiffByFile } from "../../git/diff-parser" -import { execWithShellEnv } from "../../git/shell-env" 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" @@ -631,18 +638,36 @@ 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() // Get chat before deletion const chat = db.select().from(chats).where(eq(chats.id, input.id)).get() - // Cleanup worktree if it was created (has branch = was a real worktree, not just project path) - if (chat?.worktreePath && chat?.branch) { + // 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) + } + + // Only delete worktree if the caller explicitly opted in. + if (input.deleteWorktree && chat?.worktreePath && chat?.branch) { const project = db .select() .from(projects) @@ -676,6 +701,117 @@ export const chatsRouter = router({ return db.delete(chats).where(eq(chats.id, input.id)).returning().get() }), + /** + * Delete all archived chats permanently (and their worktrees). + */ + deleteAllArchived: publicProcedure + .input(z.object({}).default({})) + .mutation(async () => { + const db = getDatabase() + + const archived = db + .select({ + id: chats.id, + branch: chats.branch, + worktreePath: chats.worktreePath, + projectId: chats.projectId, + }) + .from(chats) + .where(isNotNull(chats.archivedAt)) + .all() + + if (archived.length === 0) return [] + + const archivedIds = archived.map((c) => c.id) + + const subChatIds = db + .select({ id: subChats.id }) + .from(subChats) + .where(inArray(subChats.chatId, archivedIds)) + .all() + .map((row) => row.id) + if (subChatIds.length > 0) { + abortClaudeSessionsForSubChats(subChatIds) + } + + const worktreeChats = archived.filter( + (c) => c.branch != null && c.worktreePath != null, + ) + + if (worktreeChats.length > 0) { + const projectIds = Array.from( + new Set(worktreeChats.map((c) => c.projectId)), + ) + const projectRows = db + .select() + .from(projects) + .where(inArray(projects.id, projectIds)) + .all() + const projectPathById = new Map( + projectRows.map((p) => [p.id, p.path]), + ) + + Promise.allSettled( + worktreeChats.map((c) => { + const projectPath = projectPathById.get(c.projectId) + if (!projectPath || !c.worktreePath) { + return Promise.resolve({ success: false, error: "missing-project" }) + } + return removeWorktree(projectPath, c.worktreePath) + }), + ) + .then((results) => { + const failures = results.filter( + (r) => r.status === "rejected" || (r.status === "fulfilled" && !r.value.success), + ).length + if (failures > 0) { + console.warn( + `[chats.deleteAllArchived] ${failures}/${worktreeChats.length} worktree removals failed`, + ) + } else { + console.log( + `[chats.deleteAllArchived] Removed ${worktreeChats.length} worktree(s)`, + ) + } + }) + .catch((error) => { + console.error(`[chats.deleteAllArchived] Worktree removal error:`, error) + }) + + Promise.allSettled( + worktreeChats.map((c) => terminalManager.killByWorkspaceId(c.id)), + ) + .then((results) => { + const totalKilled = results.reduce((sum, r) => { + if (r.status === "fulfilled") return sum + r.value.killed + return sum + }, 0) + if (totalKilled > 0) { + console.log( + `[chats.deleteAllArchived] Killed ${totalKilled} terminal session(s)`, + ) + } + }) + .catch((error) => { + console.error(`[chats.deleteAllArchived] Terminal cleanup error:`, error) + }) + } + + for (const c of archived) { + trackWorkspaceDeleted(c.id) + if (c.worktreePath) { + gitCache.invalidateStatus(c.worktreePath) + gitCache.invalidateParsedDiff(c.worktreePath) + } + } + + return db + .delete(chats) + .where(inArray(chats.id, archivedIds)) + .returning() + .all() + }), + // ============ Sub-chat procedures ============ /** @@ -712,10 +848,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"), @@ -726,6 +866,7 @@ export const chatsRouter = router({ return db .insert(subChats) .values({ + ...(input.id ? { id: input.id } : {}), chatId: input.chatId, name: input.name, mode: input.mode, @@ -868,10 +1009,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() + } } } @@ -893,7 +1037,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() @@ -973,14 +1121,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, @@ -1040,6 +1192,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)) @@ -1047,6 +1200,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 */ @@ -1461,6 +1648,12 @@ export const chatsRouter = router({ return null } + const project = db + .select() + .from(projects) + .where(eq(projects.id, chat.projectId)) + .get() + try { const git = simpleGit(chat.worktreePath) const status = await git.status() @@ -1478,11 +1671,31 @@ export const chatsRouter = router({ hasUpstream = false } + // Provider info for agent prompt generation. Null/undefined for + // unsupported providers keeps the renderer on the GitHub default. + const provider = + project?.gitProvider === "github" || project?.gitProvider === "azure" + ? project.gitProvider + : null + const azure = + provider === "azure" && + project?.gitOwner && + project?.gitProject && + project?.gitRepo + ? { + organization: project.gitOwner, + project: project.gitProject, + repository: project.gitRepo, + } + : undefined + return { branch: chat.branch || status.current || "unknown", baseBranch: chat.baseBranch || "main", uncommittedCount: status.files.length, hasUpstream, + provider, + azure, } } catch (error) { console.error("[getPrContext] Error:", error) @@ -1523,7 +1736,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() })) @@ -1539,7 +1756,24 @@ export const chatsRouter = router({ return null } - return await fetchGitHubPRStatus(chat.worktreePath) + const status = await fetchPRStatus(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 }), /** @@ -1565,8 +1799,8 @@ export const chatsRouter = router({ throw new Error("No PR to merge") } - // Check PR mergeability before attempting merge - const prStatus = await fetchGitHubPRStatus(chat.worktreePath) + // Check PR mergeability before attempting merge (provider-agnostic) + const prStatus = await fetchPRStatus(chat.worktreePath) if (prStatus?.pr?.mergeable === "CONFLICTING") { throw new Error( "MERGE_CONFLICT: This PR has merge conflicts with the base branch. " + @@ -1575,32 +1809,28 @@ export const chatsRouter = router({ } try { - await execWithShellEnv( - "gh", - [ - "pr", - "merge", - String(chat.prNumber), - `--${input.method}`, - "--delete-branch", - ], - { cwd: chat.worktreePath }, - ) - return { success: true } + return await mergePR({ + worktreePath: chat.worktreePath, + prNumber: chat.prNumber, + method: input.method, + }) } catch (error) { console.error("[mergePr] Error:", error) - const errorMsg = error instanceof Error ? error.message : "Failed to merge PR" + const errorMsg = + error instanceof Error ? error.message : "Failed to merge PR" - // Check for conflict-related error messages from gh CLI + // Normalize non-prefixed conflict messages to the MERGE_CONFLICT: contract + // so the renderer surfaces the "Sync with Main" action. if ( - errorMsg.includes("not mergeable") || - errorMsg.includes("merge conflict") || - errorMsg.includes("cannot be cleanly created") || - errorMsg.includes("CONFLICTING") + !errorMsg.startsWith("MERGE_CONFLICT:") && + (errorMsg.includes("not mergeable") || + errorMsg.includes("merge conflict") || + errorMsg.includes("cannot be cleanly created") || + errorMsg.includes("CONFLICTING")) ) { throw new Error( "MERGE_CONFLICT: This PR has merge conflicts with the base branch. " + - "Please sync your branch with the latest changes from main to resolve conflicts." + "Please sync your branch with the latest changes from main to resolve conflicts." ) } @@ -1609,8 +1839,72 @@ export const chatsRouter = router({ }), /** - * Get file change stats for workspaces - * Parses messages from specified sub-chats and aggregates Edit/Write tool calls + * 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 fetchPRComments(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") + } + + try { + const result = await updatePRTitle({ + worktreePath: chat.worktreePath, + title: input.title, + prNumber: input.prNumber, + }) + invalidatePRCache(chat.worktreePath) + return result + } 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. + * + * 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) @@ -1629,143 +1923,30 @@ export const chatsRouter = router({ return [] } - // Query sub-chats based on input mode - let allChats: Array<{ chatId: string | null; subChatId: string; messages: string | null }> + const whereClause = input.chatIds && input.chatIds.length > 0 + ? inArray(subChats.chatId, input.chatIds) + : inArray(subChats.id, input.openSubChatIds!) - 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 - - 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 9e5eadffe..6962fd232 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" @@ -30,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, @@ -276,6 +278,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 @@ -810,7 +824,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 @@ -844,8 +860,17 @@ export const claudeRouter = router({ // Track if observable is still active (not unsubscribed) let isObservableActive = true - // Helper to safely emit (no-op if already unsubscribed) - const safeEmit = (chunk: UIMessageChunk) => { + // text-delta coalescing: the Claude SDK emits many small delta chunks + // per second during streaming. Sending each one through tRPC+IPC churns + // the renderer. We buffer consecutive same-id deltas and flush on a + // short interval or when a non-delta chunk arrives. + const TEXT_DELTA_FLUSH_MS = 24 + let pendingTextDelta: { type: "text-delta"; id: string; delta: string } | null = null + let pendingTextDeltaTimer: ReturnType | null = null + + // Raw emit that bypasses the text-delta buffer (used by the buffer itself + // and by error paths). Returns false if the observer is closed. + const rawEmit = (chunk: UIMessageChunk): boolean => { if (!isObservableActive) return false try { emit.next(chunk) @@ -856,8 +881,49 @@ export const claudeRouter = router({ } } + const flushPendingTextDelta = (): boolean => { + if (pendingTextDeltaTimer !== null) { + clearTimeout(pendingTextDeltaTimer) + pendingTextDeltaTimer = null + } + if (pendingTextDelta === null) return true + const chunk = pendingTextDelta + pendingTextDelta = null + return rawEmit(chunk as UIMessageChunk) + } + + // Helper to safely emit (no-op if already unsubscribed). + // Coalesces text-delta chunks; flushes any pending delta before + // emitting chunks of other types so ordering stays correct. + const safeEmit = (chunk: UIMessageChunk) => { + if (!isObservableActive) return false + + if (chunk.type === "text-delta") { + const { id, delta } = chunk as { id: string; delta: string } + if (pendingTextDelta && pendingTextDelta.id === id) { + pendingTextDelta.delta += delta + } else { + // Different id → flush the previous buffer first. + if (!flushPendingTextDelta()) return false + pendingTextDelta = { type: "text-delta", id, delta } + } + if (pendingTextDeltaTimer === null) { + pendingTextDeltaTimer = setTimeout( + flushPendingTextDelta, + TEXT_DELTA_FLUSH_MS, + ) + } + return isObservableActive + } + + // Any non-text-delta chunk → flush the buffer first so ordering is preserved. + if (!flushPendingTextDelta()) return false + return rawEmit(chunk) + } + // Helper to safely complete (no-op if already closed) const safeComplete = () => { + flushPendingTextDelta() try { emit.complete() } catch { @@ -895,11 +961,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 @@ -925,10 +1014,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) @@ -967,14 +1059,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 @@ -1492,7 +1588,38 @@ export const claudeRouter = router({ } } - const resolvedModel = finalCustomConfig?.model || input.model + const rawResolvedModel = finalCustomConfig?.model || input.model + // 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" + 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] 1M context enabled for ${resolvedModel} — ANTHROPIC_BETAS=${merged}`, + ) + } // DEBUG: If using Ollama, test if it's actually responding if (isUsingOllama && finalCustomConfig) { @@ -1770,7 +1897,7 @@ ${prompt} includePartialMessages: true, // Load skills from project and user directories (skip for Ollama - not supported) ...(!isUsingOllama && { - settingSources: ["project" as const, "user" as const], + settingSources: ["project" as const, "local" as const, "user" as const], }), canUseTool: async ( toolName: string, @@ -1977,26 +2104,21 @@ ${prompt} pathToClaudeCodeExecutable: claudeBinaryPath, // Session handling: For Ollama, use resume with session ID to maintain history // For Claude API, use resume with rollback/fork support - ...(resumeSessionId && { - resume: resumeSessionId, - // Fork support - resume at specific point and create new session - ...(shouldForkResume && forkResumeAtUuid && !isUsingOllama - ? { - resumeSessionAt: forkResumeAtUuid, - forkSession: true, - } - : // Rollback support - resume at specific message UUID (from DB) - resumeAtUuid && !isUsingOllama - ? { resumeSessionAt: resumeAtUuid } - : { continue: true }), - }), - // For first message in chat (no session ID yet), use continue mode - ...(!resumeSessionId && { continue: true }), + // resume is mutually exclusive with continue (SDK contract) + ...(resumeSessionId + ? { + resume: resumeSessionId, + // Fork support - resume at specific point and create new session + ...(shouldForkResume && forkResumeAtUuid && !isUsingOllama + ? { resumeSessionAt: forkResumeAtUuid, forkSession: true } + : // Rollback support - resume at specific message UUID (from DB) + resumeAtUuid && !isUsingOllama + ? { resumeSessionAt: resumeAtUuid } + : {}), + } + : { continue: true }), ...(resolvedModel && { model: resolvedModel }), - // fallbackModel: "claude-opus-4-5-20251101", - ...(input.maxThinkingTokens && { - maxThinkingTokens: input.maxThinkingTokens, - }), + ...(input.effort && { effort: input.effort }), }, } @@ -2041,6 +2163,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}`) @@ -2097,6 +2234,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( @@ -2252,6 +2390,7 @@ ${prompt} rawErrorCode, sessionId: msgAny.session_id, messageId: msgAny.message?.id, + model: rawResolvedModel, }, } as UIMessageChunk) } @@ -2426,6 +2565,8 @@ ${prompt} } } + clearTimeout(wedgeTimer) + // Warn if stream yielded no messages (offline mode issue) const streamDuration = Date.now() - streamIterationStart if (isUsingOllama) { @@ -2472,6 +2613,7 @@ ${prompt} } } catch (streamError) { // This catches errors during streaming (like process exit) + clearTimeout(wedgeTimer) const err = streamError as Error const stderrOutput = stderrLines.join("\n") @@ -2499,7 +2641,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`, @@ -2515,8 +2660,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") @@ -2567,8 +2719,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 @@ -2580,6 +2733,7 @@ ${prompt} cwd: input.cwd, mode: input.mode, stderr: stderrOutput || "(no stderr captured)", + model: rawResolvedModel, }, } as UIMessageChunk) } @@ -2599,15 +2753,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)) @@ -2680,15 +2838,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) @@ -2742,6 +2904,11 @@ ${prompt} `[SD] M:CLEANUP sub=${subId} sessionId=${currentSessionId || "none"}`, ) isObservableActive = false // Prevent emit after unsubscribe + if (pendingTextDeltaTimer !== null) { + clearTimeout(pendingTextDeltaTimer) + pendingTextDeltaTimer = null + } + pendingTextDelta = null abortController.abort() activeSessions.delete(input.subChatId) clearPendingApprovals("Session ended.", input.subChatId) diff --git a/src/main/lib/trpc/routers/codex.ts b/src/main/lib/trpc/routers/codex.ts index 0bc355eb9..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, @@ -136,7 +137,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 @@ -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/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/main/lib/trpc/routers/index.ts b/src/main/lib/trpc/routers/index.ts index b98b18264..cfc0441e7 100644 --- a/src/main/lib/trpc/routers/index.ts +++ b/src/main/lib/trpc/routers/index.ts @@ -18,6 +18,7 @@ import { sandboxImportRouter } from "./sandbox-import" import { commandsRouter } from "./commands" import { voiceRouter } from "./voice" import { pluginsRouter } from "./plugins" +import { usageRouter } from "./usage" import { createGitRouter } from "../../git" import { BrowserWindow } from "electron" @@ -46,6 +47,7 @@ export function createAppRouter(getWindow: () => BrowserWindow | null) { commands: commandsRouter, voice: voiceRouter, plugins: pluginsRouter, + usage: usageRouter, // Git operations - named "changes" to match Superset API changes: createGitRouter(), }) diff --git a/src/main/lib/trpc/routers/projects.ts b/src/main/lib/trpc/routers/projects.ts index 106c6d367..5611471c1 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 { chats, getDatabase, projects, 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" @@ -10,8 +10,10 @@ import { existsSync } from "node:fs" import { mkdir, copyFile, unlink } from "node:fs/promises" import { extname } from "node:path" import { getGitRemoteInfo } from "../../git" +import { terminalManager } from "../../terminal/manager" import { trackProjectOpened } from "../../analytics" import { getLaunchDirectory } from "../../cli" +import { abortClaudeSessionsForSubChats } from "./claude" const execAsync = promisify(exec) @@ -96,6 +98,7 @@ export const projectsRouter = router({ gitProvider: gitInfo.provider, gitOwner: gitInfo.owner, gitRepo: gitInfo.repo, + gitProject: gitInfo.project, }) .where(eq(projects.id, existing.id)) .returning() @@ -120,6 +123,7 @@ export const projectsRouter = router({ gitProvider: gitInfo.provider, gitOwner: gitInfo.owner, gitRepo: gitInfo.repo, + gitProject: gitInfo.project, }) .returning() .get() @@ -165,6 +169,7 @@ export const projectsRouter = router({ gitProvider: gitInfo.provider, gitOwner: gitInfo.owner, gitRepo: gitInfo.repo, + gitProject: gitInfo.project, }) .returning() .get() @@ -186,12 +191,44 @@ export const projectsRouter = router({ }), /** - * Delete a project and all its chats + * 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(({ input }) => { const db = getDatabase() + const project = db.select().from(projects).where(eq(projects.id, input.id)).get() + if (!project) { + return null + } + + const childChatIds = db + .select({ id: chats.id }) + .from(chats) + .where(eq(chats.projectId, input.id)) + .all() + .map((row) => row.id) + + if (childChatIds.length > 0) { + const subChatIds = db + .select({ id: subChats.id }) + .from(subChats) + .where(inArray(subChats.chatId, childChatIds)) + .all() + .map((row) => row.id) + + if (subChatIds.length > 0) { + abortClaudeSessionsForSubChats(subChatIds) + } + + for (const chatId of childChatIds) { + terminalManager.killByWorkspaceId(chatId).catch(() => {}) + } + } + return db .delete(projects) .where(eq(projects.id, input.id)) @@ -230,6 +267,7 @@ export const projectsRouter = router({ gitProvider: gitInfo.provider, gitOwner: gitInfo.owner, gitRepo: gitInfo.repo, + gitProject: gitInfo.project, }) .where(eq(projects.id, input.id)) .returning() @@ -309,6 +347,7 @@ export const projectsRouter = router({ gitProvider: gitInfo.provider, gitOwner: gitInfo.owner, gitRepo: gitInfo.repo, + gitProject: gitInfo.project, }) .returning() .get() @@ -340,6 +379,7 @@ export const projectsRouter = router({ gitProvider: gitInfo.provider, gitOwner: gitInfo.owner, gitRepo: gitInfo.repo, + gitProject: gitInfo.project, }) .returning() .get() @@ -422,6 +462,7 @@ export const projectsRouter = router({ gitProvider: gitInfo.provider, gitOwner: gitInfo.owner, gitRepo: gitInfo.repo, + gitProject: gitInfo.project, }) .where(eq(projects.id, existing.id)) .returning() @@ -439,6 +480,7 @@ export const projectsRouter = router({ gitProvider: gitInfo.provider, gitOwner: gitInfo.owner, gitRepo: gitInfo.repo, + gitProject: gitInfo.project, }) .returning() .get() diff --git a/src/main/lib/trpc/routers/sandbox-import.ts b/src/main/lib/trpc/routers/sandbox-import.ts index e8fbf4db6..737246888 100644 --- a/src/main/lib/trpc/routers/sandbox-import.ts +++ b/src/main/lib/trpc/routers/sandbox-import.ts @@ -482,6 +482,7 @@ export const sandboxImportRouter = router({ let finalRepo = gitInfo.repo; let finalRemoteUrl = gitInfo.remoteUrl; let finalProvider = gitInfo.provider; + const finalProject = gitInfo.project; if (!finalOwner || !finalRepo) { const repoFromMeta = remoteChatData.meta?.repository; @@ -544,6 +545,7 @@ export const sandboxImportRouter = router({ gitProvider: finalProvider, gitOwner: finalOwner, gitRepo: finalRepo, + gitProject: finalProject, }) .where(eq(projects.id, existingProject.id)) .returning() @@ -557,6 +559,7 @@ export const sandboxImportRouter = router({ gitProvider: finalProvider, gitOwner: finalOwner, gitRepo: finalRepo, + gitProject: finalProject, }) .returning() .get(); diff --git a/src/main/lib/trpc/routers/usage.ts b/src/main/lib/trpc/routers/usage.ts new file mode 100644 index 000000000..3dae49eaf --- /dev/null +++ b/src/main/lib/trpc/routers/usage.ts @@ -0,0 +1,66 @@ +import { z } from "zod" +import { publicProcedure, router } from "../index" +import { readClaudeUsage } from "../../usage/claude-reader" +import { readCodexUsage } from "../../usage/codex-reader" +import { aggregate } from "../../usage/aggregator" +import type { UsageEntry } from "../../usage/types" + +const periodSchema = z.enum(["7d", "30d", "90d", "all"]) +const sourceSchema = z.enum(["claude", "codex", "all"]) + +/** + * In-memory cache of raw entries keyed by source. Re-reading JSONLs every + * query is fast (<200ms) but still wasteful — a 15s cache keeps the Usage + * page responsive when the user toggles period / source, while staying + * fresh enough that a just-finished session shows up on the next focus. + */ +type Cached = { entries: UsageEntry[]; fetchedAt: number } +const CACHE_TTL_MS = 15_000 +const cache: { + claude: Cached | null + codex: Cached | null +} = { claude: null, codex: null } + +async function getEntries(source: "claude" | "codex"): Promise { + const now = Date.now() + const cached = cache[source] + if (cached && now - cached.fetchedAt < CACHE_TTL_MS) { + return cached.entries + } + const entries = source === "claude" ? await readClaudeUsage() : await readCodexUsage() + cache[source] = { entries, fetchedAt: now } + return entries +} + +function invalidate(): void { + cache.claude = null + cache.codex = null +} + +export const usageRouter = router({ + /** + * Aggregated stats for the period + source. The heavy lifting (glob, + * parse, dedup, price) happens here on each call; the client just + * re-queries when the user toggles inputs. + */ + getOverview: publicProcedure + .input(z.object({ period: periodSchema, source: sourceSchema })) + .query(async ({ input }) => { + const tasks: Promise[] = [] + if (input.source === "claude" || input.source === "all") { + tasks.push(getEntries("claude")) + } + if (input.source === "codex" || input.source === "all") { + tasks.push(getEntries("codex")) + } + const pools = await Promise.all(tasks) + const merged = pools.flat() + return aggregate(merged, input.period, input.source) + }), + + /** Force the next query to re-read JSONLs from disk. */ + refresh: publicProcedure.mutation(() => { + invalidate() + return { ok: true } + }), +}) diff --git a/src/main/lib/trpc/routers/voice.ts b/src/main/lib/trpc/routers/voice.ts index 63fc8014b..709443275 100644 --- a/src/main/lib/trpc/routers/voice.ts +++ b/src/main/lib/trpc/routers/voice.ts @@ -6,8 +6,24 @@ * For open-source users: requires OPENAI_API_KEY in environment */ -import { execSync } from "node:child_process" +import { execFileSync } from "node:child_process" import os from "node:os" + +// Allowed shell path prefixes — prevents SHELL= command-injection via interpolation. +const SAFE_SHELL_PREFIXES = [ + "/bin/", + "/usr/bin/", + "/usr/local/bin/", + "/opt/homebrew/bin/", +] + +function resolveSafeShell(): string { + const candidate = process.env.SHELL + if (candidate && SAFE_SHELL_PREFIXES.some((p) => candidate.startsWith(p))) { + return candidate + } + return "/bin/zsh" +} import { z } from "zod" import { publicProcedure, router } from "../index" import { getApiUrl } from "../../config" @@ -151,8 +167,8 @@ function getOpenAIApiKey(): string | null { // Try to get from shell environment (for production builds) try { - const shell = process.env.SHELL || "/bin/zsh" - const result = execSync(`${shell} -ilc 'echo $OPENAI_API_KEY'`, { + const shell = resolveSafeShell() + const result = execFileSync(shell, ["-ilc", "echo $OPENAI_API_KEY"], { encoding: "utf8", timeout: 5000, env: { diff --git a/src/main/lib/usage/aggregator.ts b/src/main/lib/usage/aggregator.ts new file mode 100644 index 000000000..90757dcac --- /dev/null +++ b/src/main/lib/usage/aggregator.ts @@ -0,0 +1,229 @@ +import { costForTokens, displayNameFor, priceFor } from "./pricing" +import type { UsageEntry, UsagePeriod, UsageSourceFilter } from "./types" + +export type UsageTotals = { + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + totalTokens: number + costUSD: number + /** Number of entries that couldn't be priced (unknown model). */ + unpricedEntries: number + /** Model ids seen but missing from the pricing table. */ + unpricedModels: string[] +} + +export type DailyBucket = { + /** Local-date ISO string, YYYY-MM-DD. */ + date: string + costUSD: number + totalTokens: number +} + +export type ModelBreakdown = { + /** Raw model id. */ + model: string + /** Friendly name from pricing table, or the raw id when unknown. */ + displayName: string + provider: "claude" | "codex" | "unknown" + totalTokens: number + costUSD: number + priced: boolean +} + +export type HeatmapCell = { + /** Local-date ISO string, YYYY-MM-DD. */ + date: string + /** 0 = Monday ... 6 = Sunday. Matches the layout in the screenshots. */ + dayOfWeek: number + /** Zero-based column index (0 = oldest week in range). */ + weekIndex: number + totalTokens: number +} + +export type UsageOverview = { + totals: UsageTotals + daily: DailyBucket[] + heatmap: HeatmapCell[] + models: ModelBreakdown[] + /** Range actually covered by the data (for labeling). */ + rangeStart: string + rangeEnd: string + /** Number of entries considered after dedup + filter. */ + entryCount: number +} + +function periodStart(period: UsagePeriod, now: number): number | null { + if (period === "all") return null + const days = period === "7d" ? 7 : period === "30d" ? 30 : 90 + return now - days * 24 * 60 * 60 * 1000 +} + +function filterBySource(entries: UsageEntry[], source: UsageSourceFilter): UsageEntry[] { + if (source === "all") return entries + return entries.filter((e) => e.source === source) +} + +function dedup(entries: UsageEntry[]): UsageEntry[] { + const seen = new Set() + const out: UsageEntry[] = [] + for (const e of entries) { + if (!e.dedupKey) { + out.push(e) + continue + } + if (seen.has(e.dedupKey)) continue + seen.add(e.dedupKey) + out.push(e) + } + return out +} + +/** YYYY-MM-DD in the local timezone. */ +function localDateKey(ts: number): string { + const d = new Date(ts) + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, "0") + const day = String(d.getDate()).padStart(2, "0") + return `${y}-${m}-${day}` +} + +/** Monday-indexed day of week (0=Mon ... 6=Sun) to match the screenshots. */ +function mondayDayOfWeek(ts: number): number { + const d = new Date(ts).getDay() // 0=Sun..6=Sat + return (d + 6) % 7 +} + +function costForEntry(entry: UsageEntry): { cost: number | null } { + if (typeof entry.costUSD === "number" && entry.costUSD > 0) { + return { cost: entry.costUSD } + } + return { + cost: costForTokens(entry.model, { + input: entry.inputTokens, + output: entry.outputTokens, + cacheWrite: entry.cacheCreationTokens, + cacheRead: entry.cacheReadTokens, + }), + } +} + +/** + * Reduce a list of entries into the overview payload the UI needs. + * Entries are expected post-source-filter. Dedup is applied here so callers + * can freely concatenate Claude + Codex readers without double-counting. + */ +export function aggregate( + rawEntries: UsageEntry[], + period: UsagePeriod, + source: UsageSourceFilter, + nowMs: number = Date.now(), +): UsageOverview { + const start = periodStart(period, nowMs) + const windowed = start === null ? rawEntries : rawEntries.filter((e) => e.ts >= start) + const scoped = filterBySource(windowed, source) + const deduped = dedup(scoped) + + const totals: UsageTotals = { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + costUSD: 0, + unpricedEntries: 0, + unpricedModels: [], + } + const unpriced = new Set() + const dailyMap = new Map() + const modelMap = new Map() + let earliest = Infinity + let latest = -Infinity + + for (const e of deduped) { + earliest = Math.min(earliest, e.ts) + latest = Math.max(latest, e.ts) + + totals.inputTokens += e.inputTokens + totals.outputTokens += e.outputTokens + totals.cacheReadTokens += e.cacheReadTokens + totals.cacheWriteTokens += e.cacheCreationTokens + + const entryTokens = e.inputTokens + e.outputTokens + e.cacheReadTokens + e.cacheCreationTokens + totals.totalTokens += entryTokens + + const { cost } = costForEntry(e) + if (cost === null) { + totals.unpricedEntries += 1 + unpriced.add(e.model) + } else { + totals.costUSD += cost + } + + const dayKey = localDateKey(e.ts) + const bucket = dailyMap.get(dayKey) ?? { date: dayKey, costUSD: 0, totalTokens: 0 } + bucket.costUSD += cost ?? 0 + bucket.totalTokens += entryTokens + dailyMap.set(dayKey, bucket) + + const pricing = priceFor(e.model) + const modelKey = e.model + const existing = modelMap.get(modelKey) ?? { + model: modelKey, + displayName: displayNameFor(modelKey), + provider: pricing?.provider ?? "unknown", + totalTokens: 0, + costUSD: 0, + priced: pricing !== null, + } + existing.totalTokens += entryTokens + existing.costUSD += cost ?? 0 + modelMap.set(modelKey, existing) + } + + totals.unpricedModels = Array.from(unpriced).sort() + + // Build full daily series (fill gaps with zero) over the visible range. + const rangeEndDate = new Date(nowMs) + const rangeStartDate = start !== null ? new Date(start) : new Date(earliest === Infinity ? nowMs : earliest) + const daily: DailyBucket[] = [] + const cursor = new Date(rangeStartDate) + cursor.setHours(0, 0, 0, 0) + const endCursor = new Date(rangeEndDate) + endCursor.setHours(0, 0, 0, 0) + while (cursor.getTime() <= endCursor.getTime()) { + const key = localDateKey(cursor.getTime()) + daily.push(dailyMap.get(key) ?? { date: key, costUSD: 0, totalTokens: 0 }) + cursor.setDate(cursor.getDate() + 1) + } + + // Build heatmap grid aligned to weeks. Column 0 = week containing rangeStartDate. + const heatmap: HeatmapCell[] = [] + const weekAnchor = new Date(rangeStartDate) + weekAnchor.setHours(0, 0, 0, 0) + // Align anchor to the Monday of that week. + weekAnchor.setDate(weekAnchor.getDate() - mondayDayOfWeek(weekAnchor.getTime())) + for (const bucket of daily) { + const dayTs = Date.parse(`${bucket.date}T00:00:00`) + const weekIndex = Math.floor((dayTs - weekAnchor.getTime()) / (7 * 24 * 60 * 60 * 1000)) + heatmap.push({ + date: bucket.date, + dayOfWeek: mondayDayOfWeek(dayTs), + weekIndex: Math.max(0, weekIndex), + totalTokens: bucket.totalTokens, + }) + } + + const models = Array.from(modelMap.values()).sort((a, b) => b.totalTokens - a.totalTokens) + + return { + totals, + daily, + heatmap, + models, + rangeStart: localDateKey(rangeStartDate.getTime()), + rangeEnd: localDateKey(rangeEndDate.getTime()), + entryCount: deduped.length, + } +} diff --git a/src/main/lib/usage/claude-reader.ts b/src/main/lib/usage/claude-reader.ts new file mode 100644 index 000000000..8b1f06caa --- /dev/null +++ b/src/main/lib/usage/claude-reader.ts @@ -0,0 +1,132 @@ +import type { Dirent } from "node:fs" +import { readdir, readFile, stat } from "node:fs/promises" +import { homedir } from "node:os" +import { join } from "node:path" +import type { UsageEntry } from "./types" + +/** + * Root directory Claude Code writes session JSONLs to. + * Honors CLAUDE_CONFIG_DIR (may be colon-separated for multi-root installs), + * matching ccusage's resolution order. + */ +function claudeProjectRoots(): string[] { + const envDir = process.env.CLAUDE_CONFIG_DIR + if (envDir && envDir.trim().length > 0) { + return envDir + .split(":") + .map((d) => d.trim()) + .filter(Boolean) + .map((d) => join(d, "projects")) + } + return [join(homedir(), ".claude", "projects")] +} + +async function walkJsonlFiles(dir: string, out: string[]): Promise { + let entries: Dirent[] + try { + entries = (await readdir(dir, { withFileTypes: true, encoding: "utf8" })) as Dirent[] + } catch { + return + } + for (const entry of entries) { + const name = entry.name as string + const full = join(dir, name) + if (entry.isDirectory()) { + await walkJsonlFiles(full, out) + } else if (entry.isFile() && name.endsWith(".jsonl")) { + out.push(full) + } + } +} + +type ClaudeRecord = { + type?: string + timestamp?: string + requestId?: string + message?: { + id?: string + model?: string + usage?: { + input_tokens?: number + output_tokens?: number + cache_creation_input_tokens?: number + cache_read_input_tokens?: number + } + } + costUSD?: number +} + +function parseLine(line: string): ClaudeRecord | null { + if (!line || line[0] !== "{") return null + try { + return JSON.parse(line) as ClaudeRecord + } catch { + return null + } +} + +function toEntry(rec: ClaudeRecord): UsageEntry | null { + if (rec.type !== "assistant") return null + const u = rec.message?.usage + if (!u) return null + const model = rec.message?.model + if (!model) return null + const ts = rec.timestamp ? Date.parse(rec.timestamp) : NaN + if (!Number.isFinite(ts)) return null + const messageId = rec.message?.id ?? "" + const requestId = rec.requestId ?? "" + const dedupKey = messageId && requestId ? `${messageId}:${requestId}` : null + return { + ts, + model, + source: "claude", + inputTokens: u.input_tokens ?? 0, + outputTokens: u.output_tokens ?? 0, + cacheCreationTokens: u.cache_creation_input_tokens ?? 0, + cacheReadTokens: u.cache_read_input_tokens ?? 0, + dedupKey, + costUSD: typeof rec.costUSD === "number" ? rec.costUSD : null, + } +} + +/** + * Read all Claude Code session JSONLs and return normalized entries. + * Files newer than `sinceMs` are fully scanned; older ones are skipped by + * mtime to keep the scan cheap even across many months of transcripts. + */ +export async function readClaudeUsage(sinceMs: number | null = null): Promise { + const roots = claudeProjectRoots() + const files: string[] = [] + for (const root of roots) { + await walkJsonlFiles(root, files) + } + + const entries: UsageEntry[] = [] + await Promise.all( + files.map(async (file) => { + if (sinceMs !== null) { + try { + const st = await stat(file) + if (st.mtimeMs < sinceMs) return + } catch { + return + } + } + let raw: string + try { + raw = await readFile(file, "utf8") + } catch { + return + } + for (const line of raw.split("\n")) { + const rec = parseLine(line) + if (!rec) continue + const entry = toEntry(rec) + if (!entry) continue + if (sinceMs !== null && entry.ts < sinceMs) continue + entries.push(entry) + } + }), + ) + return entries +} diff --git a/src/main/lib/usage/codex-reader.ts b/src/main/lib/usage/codex-reader.ts new file mode 100644 index 000000000..45c88bed7 --- /dev/null +++ b/src/main/lib/usage/codex-reader.ts @@ -0,0 +1,146 @@ +import type { Dirent } from "node:fs" +import { readdir, readFile, stat } from "node:fs/promises" +import { homedir } from "node:os" +import { join } from "node:path" +import type { UsageEntry } from "./types" + +function codexSessionsRoot(): string { + // Codex CLI does not advertise a CODEX_CONFIG_DIR override today; hardcode + // the default but keep it centralized so a future override is a one-liner. + return join(homedir(), ".codex", "sessions") +} + +async function walkJsonlFiles(dir: string, out: string[]): Promise { + let entries: Dirent[] + try { + entries = (await readdir(dir, { withFileTypes: true, encoding: "utf8" })) as Dirent[] + } catch { + return + } + for (const entry of entries) { + const name = entry.name as string + const full = join(dir, name) + if (entry.isDirectory()) { + await walkJsonlFiles(full, out) + } else if ( + entry.isFile() && + name.startsWith("rollout-") && + name.endsWith(".jsonl") + ) { + out.push(full) + } + } +} + +type CodexRecord = { + type?: string + timestamp?: string + payload?: { + type?: string + model?: string + info?: { + last_token_usage?: { + input_tokens?: number + cached_input_tokens?: number + output_tokens?: number + total_tokens?: number + } + } + } +} + +function parseLine(line: string): CodexRecord | null { + if (!line || line[0] !== "{") return null + try { + return JSON.parse(line) as CodexRecord + } catch { + return null + } +} + +/** + * Scan one Codex session file. + * + * Codex CLI writes a `session_meta` line at the top, then `turn_context` + * (which carries the model), then an `event_msg` of payload-type `token_count` + * after each model response. The token_count payload carries + * `info.last_token_usage` — interpreted here as the usage for the response + * that just finished, so summing across events gives the session total. + * + * `input_tokens` in Codex INCLUDES cached tokens (unlike Anthropic), so we + * subtract `cached_input_tokens` to land on a comparable "true new input" + * bucket. The cached portion goes into `cacheReadTokens`. + */ +async function readSession(file: string, sinceMs: number | null): Promise { + let raw: string + try { + raw = await readFile(file, "utf8") + } catch { + return [] + } + let currentModel: string | null = null + const out: UsageEntry[] = [] + let tokenEventIndex = 0 + + for (const line of raw.split("\n")) { + const rec = parseLine(line) + if (!rec) continue + + if (rec.type === "turn_context" && rec.payload?.model) { + currentModel = rec.payload.model + continue + } + if (rec.type === "session_meta" && rec.payload?.model && !currentModel) { + currentModel = rec.payload.model + continue + } + + if (rec.type !== "event_msg" || rec.payload?.type !== "token_count") continue + const usage = rec.payload?.info?.last_token_usage + if (!usage) continue + + const ts = rec.timestamp ? Date.parse(rec.timestamp) : NaN + if (!Number.isFinite(ts)) continue + if (sinceMs !== null && ts < sinceMs) continue + + const inputWithCached = usage.input_tokens ?? 0 + const cached = usage.cached_input_tokens ?? 0 + const inputUncached = Math.max(0, inputWithCached - cached) + const output = usage.output_tokens ?? 0 + if (inputUncached === 0 && output === 0 && cached === 0) continue + + out.push({ + ts, + model: currentModel ?? "gpt-unknown", + source: "codex", + inputTokens: inputUncached, + outputTokens: output, + cacheCreationTokens: 0, + cacheReadTokens: cached, + dedupKey: `${file}:${tokenEventIndex}`, + costUSD: null, + }) + tokenEventIndex += 1 + } + return out +} + +export async function readCodexUsage(sinceMs: number | null = null): Promise { + const files: string[] = [] + await walkJsonlFiles(codexSessionsRoot(), files) + + const results = await Promise.all( + files.map(async (file) => { + if (sinceMs !== null) { + try { + const st = await stat(file) + if (st.mtimeMs < sinceMs) return [] + } catch { + return [] + } + } + return readSession(file, sinceMs) + }), + ) + return results.flat() +} diff --git a/src/main/lib/usage/pricing.ts b/src/main/lib/usage/pricing.ts new file mode 100644 index 000000000..3e8ed91fe --- /dev/null +++ b/src/main/lib/usage/pricing.ts @@ -0,0 +1,112 @@ +/** + * Bundled model pricing snapshot (USD per 1M tokens). + * Snapshotted from https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json + * on 2026-04-17. Update this file when new models ship. + * + * Matching is prefix-based so that dated variants like "claude-opus-4-6-20250929" + * resolve to the base model entry. + */ + +export type ModelRates = { + /** USD per 1M input tokens. */ + input: number + /** USD per 1M output tokens. */ + output: number + /** USD per 1M tokens written to the 5-minute ephemeral cache (Anthropic). */ + cacheWrite?: number + /** USD per 1M tokens read from cache (both providers). */ + cacheRead?: number +} + +type PricingEntry = { + /** Display name shown in the UI. */ + displayName: string + /** Provider bucket for grouping + the source toggle. */ + provider: "claude" | "codex" + rates: ModelRates +} + +/** + * Ordered list of (prefix, entry) pairs. Longest-prefix-wins during lookup — + * the ordering here is the tie-breaker when two prefixes overlap (e.g., + * "claude-opus-4-6" should win over "claude-opus-4"). + */ +const PRICING_TABLE: ReadonlyArray = [ + // Claude — most specific first + ["claude-opus-4-7", { displayName: "Opus 4.7", provider: "claude", rates: { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 } }], + ["claude-opus-4-6", { displayName: "Opus 4.6", provider: "claude", rates: { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 } }], + ["claude-opus-4-5", { displayName: "Opus 4.5", provider: "claude", rates: { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 } }], + ["claude-opus-4-1", { displayName: "Opus 4.1", provider: "claude", rates: { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 } }], + ["claude-opus-4", { displayName: "Opus 4", provider: "claude", rates: { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 } }], + ["claude-sonnet-4-6", { displayName: "Sonnet 4.6", provider: "claude", rates: { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 } }], + ["claude-sonnet-4-5", { displayName: "Sonnet 4.5", provider: "claude", rates: { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 } }], + ["claude-sonnet-4", { displayName: "Sonnet 4", provider: "claude", rates: { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 } }], + ["claude-haiku-4-5", { displayName: "Haiku 4.5", provider: "claude", rates: { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 } }], + ["claude-haiku-4", { displayName: "Haiku 4", provider: "claude", rates: { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 } }], + ["claude-3-7-sonnet", { displayName: "Sonnet 3.7", provider: "claude", rates: { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 } }], + ["claude-3-5-sonnet", { displayName: "Sonnet 3.5", provider: "claude", rates: { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 } }], + ["claude-3-5-haiku", { displayName: "Haiku 3.5", provider: "claude", rates: { input: 0.8, output: 4, cacheWrite: 1, cacheRead: 0.08 } }], + ["claude-3-opus", { displayName: "Opus 3", provider: "claude", rates: { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 } }], + ["claude-3-haiku", { displayName: "Haiku 3", provider: "claude", rates: { input: 0.25, output: 1.25, cacheWrite: 0.3, cacheRead: 0.03 } }], + + // Codex / OpenAI — Codex CLI reports `cached_input_tokens` (no cache-write distinction) + ["gpt-5.4-mini", { displayName: "GPT-5.4 mini", provider: "codex", rates: { input: 0.75, output: 4.5, cacheRead: 0.075 } }], + ["gpt-5.4", { displayName: "GPT-5.4", provider: "codex", rates: { input: 2.5, output: 15, cacheRead: 0.25 } }], + ["gpt-5.3-codex", { displayName: "GPT-5.3 Codex",provider: "codex", rates: { input: 1.75, output: 14, cacheRead: 0.175 } }], + ["gpt-5.2-codex", { displayName: "GPT-5.2 Codex",provider: "codex", rates: { input: 1.75, output: 14, cacheRead: 0.175 } }], + ["gpt-5-codex", { displayName: "GPT-5 Codex", provider: "codex", rates: { input: 1.25, output: 10 } }], + ["gpt-5-mini", { displayName: "GPT-5 mini", provider: "codex", rates: { input: 0.25, output: 2, cacheRead: 0.025 } }], + ["gpt-5", { displayName: "GPT-5", provider: "codex", rates: { input: 1.25, output: 10, cacheRead: 0.125 } }], + ["gpt-4.1-mini", { displayName: "GPT-4.1 mini", provider: "codex", rates: { input: 0.4, output: 1.6, cacheRead: 0.1 } }], + ["gpt-4.1", { displayName: "GPT-4.1", provider: "codex", rates: { input: 2, output: 8, cacheRead: 0.5 } }], + ["o4-mini", { displayName: "o4-mini", provider: "codex", rates: { input: 1.1, output: 4.4, cacheRead: 0.275 } }], + ["o3-mini", { displayName: "o3-mini", provider: "codex", rates: { input: 1.1, output: 4.4, cacheRead: 0.55 } }], + ["o3", { displayName: "o3", provider: "codex", rates: { input: 2, output: 8, cacheRead: 0.5 } }], + ["o1-mini", { displayName: "o1-mini", provider: "codex", rates: { input: 1.1, output: 4.4, cacheRead: 0.55 } }], + ["o1", { displayName: "o1", provider: "codex", rates: { input: 15, output: 60, cacheRead: 7.5 } }], +] + +/** + * Look up rates + display info for a model name. + * Matches the longest prefix in PRICING_TABLE. Returns null for unknown models + * so callers can surface "unpriced" instead of silently charging $0. + */ +export function priceFor(model: string | undefined | null): PricingEntry | null { + if (!model) return null + const normalized = model.toLowerCase() + let best: PricingEntry | null = null + let bestLen = 0 + for (const [prefix, entry] of PRICING_TABLE) { + if (normalized.startsWith(prefix) && prefix.length > bestLen) { + best = entry + bestLen = prefix.length + } + } + return best +} + +/** + * Compute cost in USD given a token bucket and a model name. + * Returns null when the model is unpriced — caller decides how to surface. + */ +export function costForTokens( + model: string | undefined | null, + tokens: { input: number; output: number; cacheWrite: number; cacheRead: number }, +): number | null { + const entry = priceFor(model) + if (!entry) return null + const r = entry.rates + const perMillion = 1_000_000 + return ( + (tokens.input * r.input) / perMillion + + (tokens.output * r.output) / perMillion + + (tokens.cacheWrite * (r.cacheWrite ?? 0)) / perMillion + + (tokens.cacheRead * (r.cacheRead ?? 0)) / perMillion + ) +} + +/** Resolve a display name, falling back to the raw id when unknown. */ +export function displayNameFor(model: string | undefined | null): string { + const entry = priceFor(model) + return entry?.displayName ?? (model ?? "unknown") +} diff --git a/src/main/lib/usage/types.ts b/src/main/lib/usage/types.ts new file mode 100644 index 000000000..bda879df1 --- /dev/null +++ b/src/main/lib/usage/types.ts @@ -0,0 +1,31 @@ +/** + * Normalized usage entry produced by each reader. + * All token fields are absolute counts (not deltas). `source` tells the + * aggregator which provider the entry came from so the UI can filter by it. + */ +export type UsageSource = "claude" | "codex" + +export type UsageEntry = { + /** Wall-clock timestamp of the record (ms since epoch). */ + ts: number + /** Raw model id from the provider (e.g., "claude-opus-4-6", "gpt-5-codex"). */ + model: string + source: UsageSource + inputTokens: number + outputTokens: number + /** Cache-creation tokens (Anthropic only; 0 for Codex). */ + cacheCreationTokens: number + /** Cache-read tokens (both providers). */ + cacheReadTokens: number + /** Stable id used for dedup: `${messageId}:${requestId}`. Claude-only in practice. */ + dedupKey: string | null + /** + * Cost pre-computed by the provider, when available. + * Anthropic Claude Code writes this on some assistant messages. Prefer it + * when present so totals line up with Anthropic's own billing numbers. + */ + costUSD: number | null +} + +export type UsagePeriod = "7d" | "30d" | "90d" | "all" +export type UsageSourceFilter = UsageSource | "all" 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() } }) diff --git a/src/preload/index.ts b/src/preload/index.ts index 6744f93bc..03552563d 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", { @@ -29,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"), @@ -72,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/App.tsx b/src/renderer/App.tsx index 72fa8d406..102070889 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" @@ -24,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 @@ -195,31 +196,49 @@ 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() } }, []) return ( - - - - - - -
- -
- -
-
-
-
-
-
+ + + + + + + +
+ +
+ +
+
+
+
+
+
+
) } diff --git a/src/renderer/components/confirm-archive-dialog.tsx b/src/renderer/components/confirm-archive-dialog.tsx index 4f0dba731..2f00d4fb0 100644 --- a/src/renderer/components/confirm-archive-dialog.tsx +++ b/src/renderer/components/confirm-archive-dialog.tsx @@ -2,15 +2,12 @@ import { AnimatePresence, motion } from "motion/react" import { useEffect, useState, useRef, useCallback } from "react" import { createPortal } from "react-dom" import { Button } from "./ui/button" -import { Checkbox } from "./ui/checkbox" interface ConfirmArchiveDialogProps { isOpen: boolean onClose: () => void - onConfirm: (deleteWorktree: boolean) => void + onConfirm: () => void activeProcessCount: number - hasWorktree: boolean - uncommittedCount: number } const EASING_CURVE = [0.55, 0.055, 0.675, 0.19] as const @@ -21,16 +18,10 @@ export function ConfirmArchiveDialog({ onClose, onConfirm, activeProcessCount, - hasWorktree, - uncommittedCount, }: ConfirmArchiveDialogProps) { const [mounted, setMounted] = useState(false) - const [deleteWorktree, setDeleteWorktree] = useState(false) const openAtRef = useRef(0) const confirmButtonRef = useRef(null) - // Use ref to avoid re-registering keydown listener when checkbox changes - const deleteWorktreeRef = useRef(deleteWorktree) - deleteWorktreeRef.current = deleteWorktree useEffect(() => { setMounted(true) @@ -39,8 +30,6 @@ export function ConfirmArchiveDialog({ useEffect(() => { if (isOpen) { openAtRef.current = performance.now() - // Reset checkbox when dialog opens - setDeleteWorktree(false) } }, [isOpen]) @@ -59,7 +48,7 @@ export function ConfirmArchiveDialog({ const handleConfirm = useCallback(() => { const canInteract = performance.now() - openAtRef.current > INTERACTION_DELAY_MS if (!canInteract) return - onConfirm(deleteWorktreeRef.current) + onConfirm() onClose() }, [onConfirm, onClose]) @@ -87,7 +76,6 @@ export function ConfirmArchiveDialog({ if (!portalTarget) return null const hasProcesses = activeProcessCount > 0 - const showWarning = deleteWorktree && uncommittedCount > 0 return createPortal( @@ -128,35 +116,11 @@ export function ConfirmArchiveDialog({ Archive Workspace - {/* Active processes warning */} {hasProcesses && ( -

+

{activeProcessCount} running {activeProcessCount === 1 ? "process" : "processes"} will be stopped.

)} - - {/* Worktree checkbox */} - {hasWorktree && ( -
- - - {/* Uncommitted changes warning */} - {showWarning && ( -

- {uncommittedCount} uncommitted {uncommittedCount === 1 ? "change" : "changes"} will be lost -

- )} -
- )} {/* Footer with buttons */} diff --git a/src/renderer/components/confirm-delete-dialog.tsx b/src/renderer/components/confirm-delete-dialog.tsx new file mode 100644 index 000000000..75ebb296f --- /dev/null +++ b/src/renderer/components/confirm-delete-dialog.tsx @@ -0,0 +1,54 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogBody, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "./ui/alert-dialog" + +interface ConfirmDeleteDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description: React.ReactNode + confirmLabel?: string + onConfirm: () => void + isDeleting?: boolean +} + +export function ConfirmDeleteDialog({ + open, + onOpenChange, + title, + description, + confirmLabel = "Delete", + onConfirm, + isDeleting = false, +}: ConfirmDeleteDialogProps) { + return ( + + + + {title} + + + {description} + + + Cancel + + {isDeleting ? "Deleting..." : confirmLabel} + + + + + ) +} 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/dialogs/settings-tabs/agents-models-tab.tsx b/src/renderer/components/dialogs/settings-tabs/agents-models-tab.tsx index eb692afd7..5f7a7f46b 100644 --- a/src/renderer/components/dialogs/settings-tabs/agents-models-tab.tsx +++ b/src/renderer/components/dialogs/settings-tabs/agents-models-tab.tsx @@ -760,7 +760,7 @@ export function AgentsModelsTab() { onChange={(e) => setModel(e.target.value)} onBlur={handleBlurSave} className="w-full" - placeholder="claude-3-7-sonnet-20250219" + placeholder="claude-sonnet-4-6" /> 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..f66f386b0 100644 --- a/src/renderer/components/dialogs/settings-tabs/agents-preferences-tab.tsx +++ b/src/renderer/components/dialogs/settings-tabs/agents-preferences-tab.tsx @@ -1,12 +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, - extendedThinkingEnabledAtom, + hiddenModelsAtom, notifyWhenFocusedAtom, soundNotificationsEnabledAtom, preferredEditorAtom, @@ -14,6 +14,20 @@ import { type AutoAdvanceTarget, type CtrlTabTarget, } from "../../../lib/atoms" +import { + defaultAgentModeModelAtom, + defaultAgentModeThinkingAtom, + defaultPlanModeModelAtom, + defaultPlanModeThinkingAtom, + defaultReviewModeModelAtom, + defaultReviewModeThinkingAtom, +} from "../../../features/agents/atoms" +import { + CLAUDE_MODELS, + CODEX_MODELS, + formatClaudeThinkingLabel, + type ClaudeThinkingLevel, +} from "../../../features/agents/lib/models" import { APP_META, type ExternalApp } from "../../../../shared/external-apps" // Editor icon imports @@ -141,10 +155,32 @@ 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 [thinkingEnabled, setThinkingEnabled] = useAtom( - extendedThinkingEnabledAtom, - ) const [soundEnabled, setSoundEnabled] = useAtom(soundNotificationsEnabledAtom) const [desktopNotificationsEnabled, setDesktopNotificationsEnabled] = useAtom(desktopNotificationsEnabledAtom) const [notifyWhenFocused, setNotifyWhenFocused] = useAtom(notifyWhenFocusedAtom) @@ -152,6 +188,27 @@ 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 [defaultPlanThinking, setDefaultPlanThinking] = useAtom( + defaultPlanModeThinkingAtom, + ) + const [defaultAgentThinking, setDefaultAgentThinking] = useAtom( + defaultAgentModeThinkingAtom, + ) + const [defaultReviewThinking, setDefaultReviewThinking] = useAtom( + defaultReviewModeThinkingAtom, + ) + const hiddenModels = useAtomValue(hiddenModelsAtom) + const modelOptions = useMemo( + () => buildModelOptions(hiddenModels), + [hiddenModels], + ) const [preferredEditor, setPreferredEditor] = useAtom(preferredEditorAtom) const isNarrowScreen = useIsNarrowScreen() @@ -195,22 +252,6 @@ export function AgentsPreferencesTab() { {/* Agent Behavior */}
-
- - Extended Thinking - - - Enable deeper reasoning with more thinking tokens (uses more - credits).{" "} - Disables response streaming. - -
- -
-
Default Mode @@ -234,6 +275,159 @@ export function AgentsPreferencesTab() {
+
+
+ + Default Plan + + + Model and thinking effort applied when a chat starts or switches + to Plan mode + +
+
+ + +
+
+
+
+ + Default Agent + + + Model and thinking effort applied when a chat starts or switches + to Agent mode (e.g. after approving a plan) + +
+
+ + +
+
+
+
+ + Default Review + + + Model and thinking effort applied when running /review or + /security-review + +
+
+ + +
+
diff --git a/src/renderer/components/dialogs/settings-tabs/agents-project-worktree-tab.tsx b/src/renderer/components/dialogs/settings-tabs/agents-project-worktree-tab.tsx index 079b05008..48a8b2e8c 100644 --- a/src/renderer/components/dialogs/settings-tabs/agents-project-worktree-tab.tsx +++ b/src/renderer/components/dialogs/settings-tabs/agents-project-worktree-tab.tsx @@ -386,7 +386,9 @@ function ProjectDetail({ projectId }: { projectId: string }) {
Repository

- {project.gitOwner}/{project.gitRepo} + {project.gitProvider === "azure" + ? `${project.gitOwner}/${project.gitProject}/${project.gitRepo}` + : `${project.gitOwner}/${project.gitRepo}`}

{project.gitProvider === "github" && ( @@ -405,6 +407,20 @@ function ProjectDetail({ projectId }: { projectId: string }) { GitHub )} + {project.gitProvider === "azure" && project.gitRemoteUrl && ( + + )}
)}
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/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} +

+ +
+ ) + } +} 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/atoms/index.ts b/src/renderer/features/agents/atoms/index.ts index 666975a20..9497423d4 100644 --- a/src/renderer/features/agents/atoms/index.ts +++ b/src/renderer/features/agents/atoms/index.ts @@ -218,9 +218,45 @@ 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", + "sonnet[1m]", + "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", + "gpt-5.4", undefined, { getOnInit: true }, ) @@ -234,6 +270,55 @@ export const lastSelectedCodexThinkingAtom = atomWithStorage( + "agents:lastSelectedClaudeThinking", + readInitialClaudeThinking(), + undefined, + { getOnInit: true }, +) + +export const defaultPlanModeThinkingAtom = 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>( @@ -323,6 +408,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", @@ -345,10 +462,43 @@ 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", + "sonnet[1m]": "sonnet[1m]", 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", @@ -1019,9 +1169,15 @@ export const showMessageJsonAtom = atomWithStorage( // Desktop view mode - takes priority over chat-based rendering // null = default behavior (chat/new-chat/kanban) -export type DesktopView = "automations" | "automations-detail" | "inbox" | "settings" | null +export type DesktopView = "automations" | "automations-detail" | "inbox" | "settings" | "usage" | null export const desktopViewAtom = atom(null) +// Usage page — persisted user preferences +export type UsagePeriod = "7d" | "30d" | "90d" | "all" +export type UsageSourceFilter = "claude" | "codex" | "all" +export const usagePeriodAtom = atomWithStorage("usage-period", "30d") +export const usageSourceAtom = atomWithStorage("usage-source", "all") + // Which automation is being viewed/edited (ID or "new" for creation) export const automationDetailIdAtom = atom(null) 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/components/agent-model-selector.tsx b/src/renderer/features/agents/components/agent-model-selector.tsx index 56cc4333c..369d03580 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 ( + + {label} + + ) +}) + // Isolated scroll-to-bottom button - uses own scroll listener to avoid re-renders of parent const ScrollToBottomButton = memo(function ScrollToBottomButton({ containerRef, @@ -2151,6 +2188,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( @@ -2822,6 +2860,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(() => { @@ -3154,6 +3216,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 +3962,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({ @@ -4178,7 +4250,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") { @@ -4218,6 +4290,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({ @@ -4637,17 +4713,25 @@ 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) && ( -
+
{[workspaceRepoName, workspaceBranch].filter(Boolean).join(" • ")} @@ -4692,7 +4776,7 @@ const ChatViewInner = memo(function ChatViewInner({ >
-
+
-
+
{/* Queue indicator card - top card */} {queue.length > 0 && ( + useMessageQueueStore.getState().reorderQueue(subChatId, from, to) + } isStreaming={isStreaming} hasStatusCardBelow={shouldShowStatusCard} /> @@ -4882,6 +4969,7 @@ export function ChatView({ const setSubChatUnseenChanges = useSetAtom(agentsSubChatUnseenChangesAtom) const setJustCreatedIds = useSetAtom(justCreatedIdsAtom) const selectedChatId = useAtomValue(selectedAgentChatIdAtom) + const setSelectedChatId = useSetAtom(selectedAgentChatIdAtom) const setUndoStack = useSetAtom(undoStackAtom) const setSelectedFilePath = useSetAtom(selectedDiffFilePathAtom) const setFilteredDiffFiles = useSetAtom(filteredDiffFilesAtom) @@ -5301,14 +5389,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(() => { @@ -5642,6 +5729,25 @@ export function ChatView({ restoreWorkspaceMutation.mutate({ id: chatId }) }, [chatId, restoreWorkspaceMutation]) + // Delete archived workspace mutation + const [confirmDeleteWorkspaceOpen, setConfirmDeleteWorkspaceOpen] = useState(false) + const deleteWorkspaceMutation = trpc.chats.delete.useMutation({ + onSuccess: () => { + trpcUtils.chats.list.invalidate() + trpcUtils.chats.listArchived.invalidate() + setSelectedChatId(null) + }, + }) + + const handleDeleteWorkspace = useCallback(() => { + setConfirmDeleteWorkspaceOpen(true) + }, []) + + const handleConfirmDeleteWorkspace = useCallback(() => { + deleteWorkspaceMutation.mutate({ id: chatId, deleteWorktree: true }) + setConfirmDeleteWorkspaceOpen(false) + }, [chatId, deleteWorkspaceMutation]) + // Check if this workspace is archived const isArchived = !!agentChat?.archivedAt @@ -6113,6 +6219,12 @@ export function ChatView({ setFilteredSubChatId(activeSubChatId) } + // Switch the sub-chat to the configured Review-mode model + thinking + // before sending, mirroring the /review slash-command path. + if (activeSubChatId) { + applyModeDefaultModel(activeSubChatId, "review") + } + // Generate review message and set it for ChatViewInner to send const message = generateReviewMessage(context) if (activeSubChatId) { @@ -6172,7 +6284,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, @@ -6819,7 +6931,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 @@ -6829,25 +6941,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 { @@ -6866,7 +6965,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])) @@ -7038,6 +7160,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, @@ -7055,22 +7179,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() } @@ -7078,7 +7228,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. @@ -7435,6 +7585,7 @@ Make sure to preserve all functionality from both branches when resolving confli return ( + {pushDialog} {/* File Search Dialog (Cmd+P) */} {worktreePath && ( @@ -7523,6 +7675,10 @@ Make sure to preserve all functionality from both branches when resolving confli onClick={handleOpenLocally} disabled={isImporting} className="h-6 px-2 gap-1.5 text-xs font-medium ml-2" + style={{ + // @ts-expect-error - WebKit-specific property + WebkitAppRegion: "no-drag", + }} > {isImporting ? ( @@ -7554,6 +7710,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", + }} > @@ -7562,7 +7722,13 @@ Make sure to preserve all functionality from both branches when resolving confli ) : ( - + @@ -7610,6 +7780,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", + }} > @@ -7629,9 +7803,13 @@ Make sure to preserve all functionality from both branches when resolving confli + + + Delete workspace permanently + + + )}
)} @@ -7763,7 +7965,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 @@ -7821,7 +8024,8 @@ Make sure to preserve all functionality from both branches when resolving confli />
) - }) + })} + )}
) : ( @@ -7831,7 +8035,7 @@ Make sure to preserve all functionality from both branches when resolving confli {/* Disabled input while loading */}
-
+
+ {/* Delete Workspace Confirmation Dialog */} + + {/* Unified Details Sidebar - combines all right sidebars into one (rightmost) */} {/* Show for both local (worktreePath) and remote (sandboxId) chats */} {isUnifiedSidebarEnabled && !isMobileFullscreen && (worktreePath || sandboxId) && ( diff --git a/src/renderer/features/agents/main/chat-input-area.tsx b/src/renderer/features/agents/main/chat-input-area.tsx index 30ad3f25b..14fa293cb 100644 --- a/src/renderer/features/agents/main/chat-input-area.tsx +++ b/src/renderer/features/agents/main/chat-input-area.tsx @@ -45,7 +45,6 @@ import { codexApiKeyAtom, codexOnboardingCompletedAtom, customClaudeConfigAtom, - extendedThinkingEnabledAtom, hiddenModelsAtom, normalizeCodexApiKey, normalizeCustomClaudeConfig, @@ -55,9 +54,11 @@ import { import { trpc } from "../../../lib/trpc" import { cn } from "../../../lib/utils" import { + lastSelectedClaudeThinkingAtom, lastSelectedCodexModelIdAtom, lastSelectedCodexThinkingAtom, lastSelectedModelIdAtom, + subChatClaudeThinkingAtomFamily, subChatCodexModelIdAtomFamily, subChatCodexThinkingAtomFamily, subChatModelIdAtomFamily, @@ -67,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" @@ -78,8 +79,10 @@ import { import { CLAUDE_MODELS, CODEX_MODELS, + type ClaudeThinkingLevel, type CodexThinkingLevel, } from "../lib/models" +import { applyModeDefaultModel } from "../lib/model-switching" import type { DiffTextContext, SelectedTextContext } from "../lib/queue-utils" import { AgentsFileMention, @@ -116,10 +119,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) @@ -151,7 +154,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 @@ -472,6 +475,15 @@ export const ChatInputArea = memo(function ChatInputArea({ const [selectedSubChatCodexThinking, setSelectedSubChatCodexThinking] = useAtom( subChatCodexThinkingAtom, ) + const subChatClaudeThinkingAtom = useMemo( + () => subChatClaudeThinkingAtomFamily(subChatId), + [subChatId], + ) + const [selectedSubChatClaudeThinking, setSelectedSubChatClaudeThinking] = + useAtom(subChatClaudeThinkingAtom) + const setLastSelectedClaudeThinking = useSetAtom( + lastSelectedClaudeThinkingAtom, + ) const setLastSelectedModelId = useSetAtom(lastSelectedModelIdAtom) const setLastSelectedCodexModelId = useSetAtom(lastSelectedCodexModelIdAtom) const setLastSelectedCodexThinking = useSetAtom(lastSelectedCodexThinkingAtom) @@ -595,8 +607,37 @@ export const ChatInputArea = memo(function ChatInputArea({ } }, [selectedOllamaModel, currentOllamaModel, availableModels.isOffline]) - // Extended thinking (reasoning) toggle - const [thinkingEnabled, setThinkingEnabled] = useAtom(extendedThinkingEnabledAtom) + // Clamp Claude thinking to levels the selected model supports (e.g., Haiku lacks "max"). + const selectedClaudeThinking = useMemo(() => { + const supported = selectedModel?.thinkings ?? [] + if ( + supported.includes( + selectedSubChatClaudeThinking as ClaudeThinkingLevel, + ) + ) { + return selectedSubChatClaudeThinking as ClaudeThinkingLevel + } + if (supported.includes("high")) return "high" + return supported[0] ?? "off" + }, [selectedModel, selectedSubChatClaudeThinking]) + + useEffect(() => { + const supported = selectedModel?.thinkings ?? [] + if ( + supported.length === 0 || + supported.includes( + selectedSubChatClaudeThinking as ClaudeThinkingLevel, + ) + ) { + return + } + setSelectedSubChatClaudeThinking(selectedClaudeThinking) + }, [ + selectedModel, + selectedSubChatClaudeThinking, + selectedClaudeThinking, + setSelectedSubChatClaudeThinking, + ]) const selectedModelLabel = useMemo(() => { if (provider === "codex") { @@ -689,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 @@ -1071,11 +1115,21 @@ 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 + } } } // 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], @@ -1247,7 +1301,7 @@ export const ChatInputArea = memo(function ChatInputArea({ }} className="px-2 pb-2 shadow-sm shadow-background relative z-10" > -
+
{ + setSelectedSubChatClaudeThinking(thinking) + setLastSelectedClaudeThinking(thinking) + }, }} codex={{ models: codexUiModels, diff --git a/src/renderer/features/agents/main/new-chat-form.tsx b/src/renderer/features/agents/main/new-chat-form.tsx index 6f0f61381..baa598342 100644 --- a/src/renderer/features/agents/main/new-chat-form.tsx +++ b/src/renderer/features/agents/main/new-chat-form.tsx @@ -31,6 +31,7 @@ import { agentsDebugModeAtom, justCreatedIdsAtom, lastSelectedAgentIdAtom, + lastSelectedClaudeThinkingAtom, lastSelectedCodexModelIdAtom, lastSelectedCodexThinkingAtom, lastSelectedBranchesAtom, @@ -44,6 +45,11 @@ import { getNextMode, type AgentMode, } from "../atoms" +import { + getDefaultModelForMode, + getDefaultThinkingForMode, + getProviderForModelId, +} from "../lib/model-switching" import { defaultAgentModeAtom } from "../../../lib/atoms" import { ProjectSelector } from "../components/project-selector" import { WorkModeSelector } from "../components/work-mode-selector" @@ -58,7 +64,6 @@ import { codexApiKeyAtom, codexOnboardingCompletedAtom, customClaudeConfigAtom, - extendedThinkingEnabledAtom, hiddenModelsAtom, normalizeCodexApiKey, normalizeCustomClaudeConfig, @@ -121,6 +126,7 @@ import { import { CLAUDE_MODELS, CODEX_MODELS, + type ClaudeThinkingLevel, type CodexThinkingLevel, } from "../lib/models" // import type { PlanType } from "@/lib/config/subscription-plans" @@ -136,10 +142,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) @@ -327,14 +333,20 @@ export function NewChatForm({ const [lastSelectedCodexThinking, setLastSelectedCodexThinking] = useAtom( lastSelectedCodexThinkingAtom, ) - const [thinkingEnabled, setThinkingEnabled] = useAtom( - extendedThinkingEnabledAtom, + const [lastSelectedClaudeThinking, setLastSelectedClaudeThinking] = useAtom( + 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(() => { @@ -344,6 +356,42 @@ 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 + // and thinking effort to that mode's defaults. 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) + } + 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 + }, [agentMode]) + const storedCodexApiKey = useAtomValue(codexApiKeyAtom) const hasAppCodexApiKey = Boolean(normalizeCodexApiKey(storedCodexApiKey)) const hiddenModels = useAtomValue(hiddenModelsAtom) @@ -397,6 +445,34 @@ export function NewChatForm({ setLastSelectedCodexThinking, ]) + // Clamp Claude thinking to levels the selected model supports. + const selectedClaudeThinking = useMemo(() => { + const supported = selectedModel?.thinkings ?? [] + if ( + supported.includes(lastSelectedClaudeThinking as ClaudeThinkingLevel) + ) { + return lastSelectedClaudeThinking as ClaudeThinkingLevel + } + if (supported.includes("high")) return "high" + return supported[0] ?? "off" + }, [selectedModel, lastSelectedClaudeThinking]) + + useEffect(() => { + const supported = selectedModel?.thinkings ?? [] + if ( + supported.length === 0 || + supported.includes(lastSelectedClaudeThinking as ClaudeThinkingLevel) + ) { + return + } + setLastSelectedClaudeThinking(selectedClaudeThinking) + }, [ + selectedModel, + lastSelectedClaudeThinking, + selectedClaudeThinking, + setLastSelectedClaudeThinking, + ]) + const selectedChatModel = useMemo(() => { if (selectedAgent.id === "codex") { return `${selectedCodexModel.id}/${selectedCodexThinking}` @@ -1115,6 +1191,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 @@ -1122,6 +1199,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 { @@ -1211,7 +1293,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, @@ -1382,11 +1464,21 @@ 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 + } } } // 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], @@ -1620,6 +1712,10 @@ export function NewChatForm({ onClick={onBackToChats} className="h-7 w-7 p-0 hover:bg-foreground/10 transition-[background-color,transform] duration-150 ease-out active:scale-[0.97] flex-shrink-0 rounded-md" aria-label="All projects" + style={{ + // @ts-expect-error - WebKit-specific property + WebkitAppRegion: "no-drag", + }} > @@ -1634,7 +1730,7 @@ export function NewChatForm({
-
+
{/* Title - only show when project is selected */} {validatedProject && (
@@ -1905,8 +2001,8 @@ export function NewChatForm({ recommendedOllamaModel: availableModels.recommendedModel, onSelectOllamaModel: setSelectedOllamaModel, isConnected: isClaudeConnected, - thinkingEnabled, - onThinkingChange: setThinkingEnabled, + selectedThinking: selectedClaudeThinking, + onSelectThinking: setLastSelectedClaudeThinking, }} codex={{ models: codexUiModels, 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 { 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 ebf145147..b400d7ad3 100644 --- a/src/renderer/features/agents/stores/sub-chat-store.ts +++ b/src/renderer/features/agents/stores/sub-chat-store.ts @@ -6,8 +6,31 @@ 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" - -const MAX_SPLIT_PANES = 4 +import { trpcClient } from "../../../lib/trpc" + +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 @@ -41,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 @@ -242,10 +265,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) => { @@ -305,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] @@ -339,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) @@ -347,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-context-indicator.tsx b/src/renderer/features/agents/ui/agent-context-indicator.tsx index 06b837b00..1880e5072 100644 --- a/src/renderer/features/agents/ui/agent-context-indicator.tsx +++ b/src/renderer/features/agents/ui/agent-context-indicator.tsx @@ -11,7 +11,9 @@ import { cn } from "../../../lib/utils" // Claude model context windows const CONTEXT_WINDOWS = { opus: 200_000, + "opus[1m]": 1_000_000, sonnet: 200_000, + "sonnet[1m]": 1_000_000, haiku: 200_000, } as const 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 */} -
+
+ + + Delete all archived + + + )}
@@ -564,5 +615,21 @@ export const ArchivePopover = memo(function ArchivePopover({ trigger }: ArchiveP
+ + Delete all {localArchivedCount} archived workspace + {localArchivedCount === 1 ? "" : "s"}? This removes them and their + worktrees permanently and cannot be undone. + + } + confirmLabel="Delete all" + onConfirm={handleConfirmDeleteAll} + isDeleting={deleteAllArchivedMutation.isPending} + /> + ) }) 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/ui/mobile-chat-header.tsx b/src/renderer/features/agents/ui/mobile-chat-header.tsx index 1e0600a8c..454caa89e 100644 --- a/src/renderer/features/agents/ui/mobile-chat-header.tsx +++ b/src/renderer/features/agents/ui/mobile-chat-header.tsx @@ -3,7 +3,7 @@ import { useCallback, useMemo, useState } from "react" import { useAtomValue } from "jotai" import { loadingSubChatsAtom } from "../atoms" -import { Plus, ChevronDown, Play, AlignJustify, FolderDown } from "lucide-react" +import { Plus, ChevronDown, Play, AlignJustify, FolderDown, Trash2 } from "lucide-react" import { IconSpinner, PlanIcon, @@ -43,6 +43,7 @@ interface MobileChatHeaderProps { isTerminalOpen?: boolean isArchived?: boolean onRestore?: () => void + onDelete?: () => void onOpenLocally?: () => void showOpenLocally?: boolean } @@ -60,6 +61,7 @@ export function MobileChatHeader({ isTerminalOpen = false, isArchived = false, onRestore, + onDelete, onOpenLocally, showOpenLocally = false, }: MobileChatHeaderProps) { @@ -296,6 +298,19 @@ export function MobileChatHeader({ Restore )} + + {/* Delete button - only when viewing archived workspace */} + {isArchived && onDelete && ( + + )}
) diff --git a/src/renderer/features/agents/ui/split-view-container.tsx b/src/renderer/features/agents/ui/split-view-container.tsx index 652c388b4..885106ef5 100644 --- a/src/renderer/features/agents/ui/split-view-container.tsx +++ b/src/renderer/features/agents/ui/split-view-container.tsx @@ -1,11 +1,20 @@ "use client" import { Fragment, useCallback, useEffect, useRef, useState } from "react" +import { useDndContext, useDroppable } from "@dnd-kit/core" +import { cn } from "../../../lib/utils" import { getDefaultRatios } from "../atoms" -import { useAgentSubChatStore } from "../stores/sub-chat-store" +import { canAddToSplit, useAgentSubChatStore } from "../stores/sub-chat-store" const MIN_PANE_WIDTH = 350 +// Shared between `SplitViewContainer` and `SplitDropZone`, and read by the +// unified drag-end handler in `agents-content.tsx` to route drops as splits. +export const SPLIT_DROP_DATA = { type: "split" } as const + +const DROP_OVERLAY_CLASS = + "after:absolute after:inset-0 after:pointer-events-none after:border-2 after:border-dashed after:border-primary/60 after:rounded-md after:z-20" + interface SplitViewContainerProps { panes: Array<{ id: string; content: React.ReactNode }> hiddenTabs?: React.ReactNode @@ -22,6 +31,28 @@ export function SplitViewContainer({ const ratiosRef = useRef(splitRatios) ratiosRef.current = splitRatios + // Disabled when the drop would silently no-op (already a pane, max reached), + // so no hover highlight appears and the user can tell the drop won't land. + const { active } = useDndContext() + const activeSubChatId = useAgentSubChatStore((s) => s.activeSubChatId) + const splitPaneIds = useAgentSubChatStore((s) => s.splitPaneIds) + const dropDisabled = + !active || + !canAddToSplit({ activeSubChatId, splitPaneIds }, String(active.id)) + const { setNodeRef: setDroppableRef, isOver } = useDroppable({ + id: "split-view-drop-zone", + data: SPLIT_DROP_DATA, + disabled: dropDisabled, + }) + + const setRefs = useCallback( + (node: HTMLDivElement | null) => { + containerRef.current = node + setDroppableRef(node) + }, + [setDroppableRef], + ) + // Use local ratios during drag, else persisted. Auto-fix if length mismatch. const currentRatios = (() => { if (localRatios && localRatios.length === panes.length) return localRatios @@ -37,10 +68,13 @@ export function SplitViewContainer({ }, [panes.length, splitRatios.length, setSplitRatios]) return ( -
+
{panes.map((pane, i) => ( - {/* Pane */} + {/* Pane — close button is rendered inline by each pane's ChatViewInner */}
s.activeSubChatId) + const splitPaneIds = useAgentSubChatStore((s) => s.splitPaneIds) + const dropDisabled = + !active || + !canAddToSplit({ activeSubChatId, splitPaneIds }, String(active.id)) + const { setNodeRef, isOver } = useDroppable({ + id: "solo-chat-drop-zone", + data: SPLIT_DROP_DATA, + disabled: dropDisabled, + }) + return ( +
+ {children} +
+ ) +} + // --- Divider sub-component --- interface SplitDividerProps { diff --git a/src/renderer/features/agents/ui/sub-chat-selector.tsx b/src/renderer/features/agents/ui/sub-chat-selector.tsx index b890510da..86c89e6e5 100644 --- a/src/renderer/features/agents/ui/sub-chat-selector.tsx +++ b/src/renderer/features/agents/ui/sub-chat-selector.tsx @@ -251,6 +251,7 @@ export function SubChatSelector({ const toggleTerminalHotkey = useResolvedHotkeyDisplay("toggle-terminal") const archiveAgentHotkey = useResolvedHotkeyDisplay("archive-agent") const newAgentHotkey = useResolvedHotkeyDisplay("new-agent") + const newAgentSplitHotkey = useResolvedHotkeyDisplay("new-agent-split") // Pending plan approvals from DB - only for open sub-chats const { data: pendingPlanApprovalsData } = trpc.chats.getPendingPlanApprovals.useQuery( @@ -275,6 +276,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() @@ -500,7 +554,6 @@ export function SubChatSelector({ window.removeEventListener("keydown", handleHistoryHotkey, true) }, [subChatsSidebarMode]) - // Keyboard shortcut: Cmd+Shift+T / Ctrl+Shift+T for new sub-chat // Scroll to active tab when it changes useEffect(() => { if (!activeSubChatId || !tabsContainerRef.current) return @@ -679,10 +732,6 @@ export function SubChatSelector({
{/* Left gradient - visibility controlled via ref */}
@@ -730,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() @@ -760,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" @@ -772,7 +846,15 @@ 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 + WebkitAppRegion: "no-drag", + }} > {/* Icon: question icon (priority) OR loading spinner OR mode icon with badge (hide when editing) */} {editingSubChatId !== subChat.id && ( @@ -914,7 +996,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 */}
@@ -929,9 +1017,17 @@ export function SubChatSelector({ - - New chat - {newAgentHotkey && {newAgentHotkey}} + +
+ New chat + {newAgentHotkey && {newAgentHotkey}} +
+ {newAgentSplitHotkey && ( +
+ New in split + {newAgentSplitHotkey} +
+ )}
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/agents/utils/git-activity.ts b/src/renderer/features/agents/utils/git-activity.ts index 10c0aedc6..4434559b7 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 @@ -62,12 +63,20 @@ function extractCommitInfo( } /** - * Extract PR info from a gh pr create command and its output. + * Extract PR info from a `gh pr create` or `az repos pr create` command + * and its output. */ function extractPrInfo(command: string, stdout: string): GitPrInfo | null { - if (!/gh\s+pr\s+create/.test(command)) return null + if (/gh\s+pr\s+create/.test(command)) { + return extractGithubPrInfo(command, stdout) + } + if (/az\s+repos\s+pr\s+create/.test(command)) { + return extractAzurePrInfo(command, stdout) + } + return null +} - // Extract URL from stdout +function extractGithubPrInfo(command: string, stdout: string): GitPrInfo | null { const urlMatch = stdout.match( /(https:\/\/github\.com\/[^\s]+\/pull\/\d+)/, ) @@ -77,11 +86,45 @@ function extractPrInfo(command: string, stdout: string): GitPrInfo | null { const numberMatch = url.match(/\/pull\/(\d+)/) const number = numberMatch ? parseInt(numberMatch[1]!, 10) : undefined - // Extract title from --title flag in command const titleMatch = command.match(/--title\s+["']([^"']+)["']/) const title = titleMatch?.[1] || `PR #${number || ""}` - return { type: "pr", title, url, number } + 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 } +} + +function extractAzurePrInfo(command: string, stdout: string): GitPrInfo | null { + // `az repos pr create --output json` prints a single JSON object. + // If the user ran without --output json (human-readable form), bail — + // the activity badge just won't appear for that turn. + try { + const parsed = JSON.parse(stdout) + const prId = + typeof parsed?.pullRequestId === "number" + ? parsed.pullRequestId + : null + const webUrl = + typeof parsed?.repository?.webUrl === "string" + ? parsed.repository.webUrl + : null + if (prId == null || !webUrl) return null + + const url = `${webUrl}/pullrequest/${prId}` + const titleFromCmd = command.match(/--title\s+["']([^"']+)["']/)?.[1] + const title = + titleFromCmd || + (typeof parsed?.title === "string" ? parsed.title : `PR #${prId}`) + const branch = command.match(/--source-branch\s+["']?([^\s"']+)["']?/)?.[1] + + return { type: "pr", title, url, number: prId, branch } + } catch { + return null + } } /** diff --git a/src/renderer/features/agents/utils/pr-message.ts b/src/renderer/features/agents/utils/pr-message.ts index a3dd4f47f..7cff37aa1 100644 --- a/src/renderer/features/agents/utils/pr-message.ts +++ b/src/renderer/features/agents/utils/pr-message.ts @@ -3,6 +3,14 @@ export interface PrContext { baseBranch: string uncommittedCount: number hasUpstream: boolean + /** Git host provider for this workspace. Null/undefined → treat as GitHub. */ + provider?: "github" | "azure" | null + /** Populated when provider === "azure" so the agent can target the right org/project/repo. */ + azure?: { + organization: string + project: string + repository: string + } } /** @@ -38,9 +46,20 @@ export function generatePrMessage(context: PrContext): string { } steps.push(`Use git diff origin/${baseBranch}... to review the PR diff`) - steps.push( - `Use gh pr create --base ${baseBranch} to create a PR. Keep the title under 80 characters and description under five sentences.` - ) + if (context.provider === "azure" && context.azure) { + const { organization, project, repository } = context.azure + steps.push( + `Use az repos pr create --source-branch ${branch} --target-branch ${baseBranch} ` + + `--repository ${repository} --project "${project}" ` + + `--organization https://dev.azure.com/${organization} ` + + `--title "" --description "<summary>" --output json ` + + `to create a PR. Keep the title under 80 characters and description under five sentences.`, + ) + } else { + steps.push( + `Use gh pr create --base ${baseBranch} to create a PR. Keep the title under 80 characters and description under five sentences.`, + ) + } steps.push("If any of these steps fail, ask the user for help.") // Add numbered steps 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() { <button onClick={handleBack} 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] rounded-md text-muted-foreground hover:text-foreground" + style={{ + // @ts-expect-error - WebKit-specific property + WebkitAppRegion: "no-drag", + }} > <ArrowLeft className="h-4 w-4" /> </button> - <div className="flex items-center gap-2"> + <div + className="flex items-center gap-2" + style={{ + // @ts-expect-error - WebKit-specific property + WebkitAppRegion: "no-drag", + }} + > {!isCreateMode && ( <> <DropdownMenu> 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", + }} > <AlignJustify className="h-4 w-4" /> </button> @@ -150,6 +154,10 @@ export function AutomationsView() { <button onClick={handleNewAutomation} className="h-8 px-3 rounded-lg text-sm font-medium border border-border hover:bg-foreground/10 transition-[background-color,transform] duration-150 ease-out active:scale-[0.97] text-foreground flex items-center gap-1.5 flex-shrink-0" + style={{ + // @ts-expect-error - WebKit-specific property + WebkitAppRegion: "no-drag", + }} > <Plus className="h-4 w-4" /> <span className="text-sm font-medium hidden min-420:inline">New</span> diff --git a/src/renderer/features/automations/inbox-view.tsx b/src/renderer/features/automations/inbox-view.tsx index d16d8ecf4..d46810387 100644 --- a/src/renderer/features/automations/inbox-view.tsx +++ b/src/renderer/features/automations/inbox-view.tsx @@ -540,7 +540,13 @@ export function InboxView() { {/* Mobile Header */} <div className="flex-shrink-0 border-b bg-background"> <div className="h-14 flex items-center justify-between px-4"> - <div className="flex items-center gap-2"> + <div + className="flex items-center gap-2" + style={{ + // @ts-expect-error - WebKit-specific property + WebkitAppRegion: "no-drag", + }} + > <button onClick={handleMobileBackToChats} 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" @@ -550,7 +556,13 @@ export function InboxView() { </button> <h1 className="text-lg font-semibold">Inbox</h1> </div> - <div className="flex items-center gap-1"> + <div + className="flex items-center gap-1" + style={{ + // @ts-expect-error - WebKit-specific property + WebkitAppRegion: "no-drag", + }} + > <DropdownMenu> <DropdownMenuTrigger asChild> <button className="flex items-center justify-center h-8 w-8 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"> 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<PendingSwitch | null>(null); + const listRef = useRef<HTMLDivElement>(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 ( + <> + <Popover + open={open} + onOpenChange={(next) => { + if (!next) setSearch(""); + setOpen(next); + }} + > + <Tooltip> + <TooltipTrigger asChild> + <PopoverTrigger asChild> + <Button + variant="ghost" + size="sm" + disabled={checkoutMutation.isPending} + className={cn( + "h-6 px-2 gap-1.5 text-xs font-medium min-w-0", + compact && "h-5 px-1.5 gap-1 text-[10px]", + )} + > + <LuGitBranch className={cn("size-3.5 shrink-0", compact && "size-3")} /> + <span className="truncate max-w-[160px]"> + {currentBranch || "No branch"} + </span> + <HiChevronDown className={cn("size-3 shrink-0 opacity-50", compact && "size-2.5")} /> + </Button> + </PopoverTrigger> + </TooltipTrigger> + <TooltipContent side="bottom">Switch branch</TooltipContent> + </Tooltip> + <PopoverContent className="w-80 p-0" align="start"> + <div className="flex items-center gap-1.5 h-7 px-1.5 mx-1 my-1 rounded-md bg-muted/50"> + <SearchIcon className="h-4 w-4 shrink-0 text-muted-foreground" /> + <input + type="text" + placeholder="Search branches..." + value={search} + onChange={(e) => setSearch(e.target.value)} + className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + autoFocus + /> + </div> + + {branchesQuery.isLoading ? ( + <div className="py-6 text-center text-sm text-muted-foreground"> + Loading branches... + </div> + ) : filtered.length === 0 ? ( + <div className="py-6 text-center text-sm text-muted-foreground"> + No branches found. + </div> + ) : ( + <div + ref={listRef} + className="overflow-auto py-1 scrollbar-hide" + style={{ + height: Math.min(filtered.length * 32 + 8, 300), + }} + > + <div + style={{ + height: `${virtualizer.getTotalSize()}px`, + width: "100%", + position: "relative", + }} + > + {virtualizer.getVirtualItems().map((virtualItem) => { + const branch = filtered[virtualItem.index]!; + const isCurrent = branch.name === currentBranch && branch.type === "local"; + return ( + <button + key={`${branch.type}-${branch.name}`} + onClick={() => handleSelect(branch.name)} + className={cn( + "flex items-center gap-1.5 w-[calc(100%-8px)] mx-1 px-1.5 text-sm text-left absolute left-0 top-0 rounded-md cursor-default select-none outline-none transition-colors", + isCurrent + ? "dark:bg-neutral-800 bg-accent text-foreground" + : "dark:hover:bg-neutral-800 hover:bg-accent/60 hover:text-foreground", + )} + style={{ + height: `${virtualItem.size}px`, + transform: `translateY(${virtualItem.start}px)`, + }} + > + <LuGitBranch className="h-4 w-4 text-muted-foreground shrink-0" /> + <span className="truncate flex-1">{branch.name}</span> + <span + className={cn( + "text-[10px] px-1.5 py-0.5 rounded shrink-0", + branch.type === "local" + ? "bg-blue-500/10 text-blue-500" + : "bg-orange-500/10 text-orange-500", + )} + > + {branch.type} + </span> + {branch.committedAt && ( + <span className="text-xs text-muted-foreground/70 shrink-0"> + {formatTimeAgo(branch.committedAt)} + </span> + )} + {branch.isDefault && ( + <span className="text-[10px] text-muted-foreground/70 bg-muted px-1.5 py-0.5 rounded shrink-0"> + default + </span> + )} + {isCurrent && <Check className="h-4 w-4 shrink-0 ml-auto" />} + </button> + ); + })} + </div> + </div> + )} + </PopoverContent> + </Popover> + + <Dialog + open={!!pending} + onOpenChange={(open) => { + if (!open) setPending(null); + }} + > + <DialogContent className="sm:max-w-[420px]"> + <DialogHeader> + <DialogTitle>Uncommitted changes</DialogTitle> + <DialogDescription> + You have uncommitted changes in this worktree. How should they be handled when switching to{" "} + <span className="font-mono text-foreground">{pending?.branch}</span>? + </DialogDescription> + </DialogHeader> + <DialogFooter className="flex-col-reverse sm:flex-row sm:justify-end gap-2"> + <Button + variant="ghost" + onClick={() => setPending(null)} + disabled={checkoutMutation.isPending} + > + Cancel + </Button> + <Button + variant="outline" + onClick={() => runSwitch("carry")} + disabled={checkoutMutation.isPending} + > + Carry changes + </Button> + <Button + onClick={() => runSwitch("stash")} + disabled={checkoutMutation.isPending} + > + Stash & switch + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </> + ); +} 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..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"; @@ -44,7 +37,9 @@ export function ChangesPanelHeader({ const [displayTime, setDisplayTime] = useState<string>(""); const timeoutRef = useRef<NodeJS.Timeout | null>(null); - const { data: branchData, refetch: refetchBranches } = trpc.changes.getBranches.useQuery( + const utils = trpc.useUtils(); + + const { refetch: refetchBranches } = trpc.changes.getBranches.useQuery( { worktreePath }, { enabled: !!worktreePath }, ); @@ -53,12 +48,7 @@ export function ChangesPanelHeader({ onSuccess: () => { setLastFetchTime(new Date()); refetchBranches(); - }, - }); - - const checkoutMutation = trpc.changes.checkout.useMutation({ - onSuccess: () => { - refetchBranches(); + utils.changes.getStatus.invalidate({ worktreePath }); }, }); @@ -93,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 ( @@ -115,59 +99,11 @@ export function ChangesPanelHeader({ )} > {/* Branch selector */} - <DropdownMenu> - <Tooltip> - <TooltipTrigger asChild> - <DropdownMenuTrigger asChild> - <Button - variant="ghost" - size="sm" - className={cn( - "h-6 px-2 gap-1.5 text-xs font-medium min-w-0", - isCompact && "h-5 px-1.5 gap-1 text-[10px]", - )} - > - <LuGitBranch className={cn("size-3.5 shrink-0", isCompact && "size-3")} /> - <span className="truncate max-w-[120px]"> - {currentBranch || "No branch"} - </span> - <HiChevronDown className={cn("size-3 shrink-0 opacity-50", isCompact && "size-2.5")} /> - </Button> - </DropdownMenuTrigger> - </TooltipTrigger> - <TooltipContent side="bottom">Switch branch</TooltipContent> - </Tooltip> - <DropdownMenuContent align="start" className="w-48"> - {branches.map((branchInfo) => ( - <DropdownMenuItem - key={branchInfo.branch} - onClick={() => handleBranchSelect(branchInfo.branch)} - className={cn( - "text-xs", - branchInfo.branch === currentBranch && "bg-accent", - )} - > - <LuGitBranch className="mr-2 size-3.5" /> - <span className="truncate">{branchInfo.branch}</span> - {branchInfo.branch === branchData?.defaultBranch && ( - <span className="ml-auto text-[10px] text-muted-foreground"> - default - </span> - )} - </DropdownMenuItem> - ))} - {branches.length > 0 && <DropdownMenuSeparator />} - <DropdownMenuItem - onClick={() => { - // TODO: Implement create branch dialog - }} - className="text-xs" - > - <LuGitBranch className="mr-2 size-3.5" /> - Create new branch... - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> + <BranchSwitcherPopover + worktreePath={worktreePath} + currentBranch={currentBranch} + compact={isCompact} + /> {/* Right side: PR status + Fetch */} <div className="flex items-center gap-1"> 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} <div className="relative flex items-center justify-between h-10 px-2 border-b border-border/50 bg-background flex-shrink-0"> {/* Drag region for window dragging */} {isDesktop && !isFullscreen && ( @@ -961,5 +963,6 @@ export const DiffSidebarHeader = memo(function DiffSidebarHeader({ </DropdownMenu> </div> </div> + </> ); }) 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<number>( + "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<HTMLDivElement>(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<HTMLDivElement>) => { + 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 ( + <div ref={containerRef} className="flex-1 flex min-h-0 overflow-hidden"> + {/* Left: file list */} + <div + style={{ width: leftWidth, flexShrink: 0 }} + className="border-r border-border/50 overflow-y-auto" + > + {files.length === 0 ? ( + <div className="p-4 text-xs text-muted-foreground"> + No files in this commit. + </div> + ) : ( + files.map((file) => ( + <CommitFileRow + key={file.path} + file={file} + isSelected={selectedFilePath === file.path} + onClick={() => handleFileClick(file)} + /> + )) + )} + </div> + + {/* Resize handle */} + <div + role="separator" + aria-orientation="vertical" + onMouseDown={handleMouseDown} + className={cn( + "w-1 cursor-col-resize bg-transparent hover:bg-primary/30 transition-colors flex-shrink-0", + isDragging && "bg-primary/50", + )} + /> + + {/* Right: diff */} + <div className="flex-1 min-w-0 overflow-hidden flex flex-col"> + {selectedFilePath ? ( + <CommitFileDiff + worktreePath={worktreePath} + commitHash={commitHash} + filePath={selectedFilePath} + /> + ) : ( + <div className="flex-1 flex items-center justify-center text-xs text-muted-foreground"> + Select a file to view its diff. + </div> + )} + </div> + </div> + ) +}) + +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 ( + <button + type="button" + onClick={onClick} + className={cn( + "w-full flex items-center gap-2 px-2 py-1.5 text-left transition-colors", + "hover:bg-muted/60 border-b border-border/20 last:border-b-0", + isSelected && "bg-muted", + )} + > + <FileText className="size-3.5 text-muted-foreground shrink-0" /> + <div className="flex-1 min-w-0 flex items-center overflow-hidden"> + {dirPath && ( + <span className="text-xs text-muted-foreground truncate flex-shrink min-w-0"> + {dirPath}/ + </span> + )} + <span className="text-xs font-medium flex-shrink-0 whitespace-nowrap"> + {fileName} + </span> + </div> + <div className="flex items-center gap-1.5 shrink-0 text-[10px] font-mono"> + {file.additions != null && file.additions > 0 && ( + <span className="text-emerald-600 dark:text-emerald-400"> + +{file.additions} + </span> + )} + {file.deletions != null && file.deletions > 0 && ( + <span className="text-red-600 dark:text-red-400"> + −{file.deletions} + </span> + )} + {getStatusIndicator(file.status)} + </div> + </button> + ) +}) + +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 ( + <div className="flex-1 flex items-center justify-center"> + <IconSpinner className="w-4 h-4" /> + </div> + ) + } + if (error) { + return ( + <div className="flex-1 p-3 text-xs text-red-500"> + Failed to load diff: {error.message} + </div> + ) + } + if (!data || data.trim() === "") { + return ( + <div className="flex-1 flex items-center justify-center text-xs text-muted-foreground"> + No diff available for this file. + </div> + ) + } + + return ( + <div className="flex-1 overflow-auto"> + <div className="sticky top-0 z-10 bg-muted/60 border-b border-border/50 px-3 py-1.5 flex items-center gap-2"> + <FileText className="size-3.5 text-muted-foreground shrink-0" /> + <span className="text-xs font-mono truncate">{filePath}</span> + </div> + <pre className="font-mono text-[11px] leading-[1.45] px-3 py-2 whitespace-pre"> + {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 ( + <div key={i} className={toneClass || undefined}> + {line || "\u00A0"} + </div> + ) + })} + </pre> + </div> + ) +}) 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 ( - <div className="flex-1 overflow-y-auto"> + <div className="flex-1 flex flex-col min-h-0 overflow-hidden"> {/* Worktree not registered warning */} {isWorktreeRegistered === false && worktreePath && ( <div className="p-4 bg-yellow-500/10 border border-yellow-500/20 text-yellow-600 text-xs"> @@ -143,16 +143,42 @@ export const HistoryView = memo(function HistoryView({ </div> )} - {/* Commits list - only commits, files are shown in right panel */} - {commits.map((commit, index) => ( - <HistoryCommitItem - key={commit.hash} - commit={commit} - isSelected={selectedCommitHash === commit.hash} - isUnpushed={index < (pushCount || 0)} - onClick={() => handleCommitClick(commit)} - /> - ))} + {/* Commits list — fixed ~40% of the pane so the diff split has room. */} + <div + className="overflow-y-auto border-b border-border/50 flex-shrink-0" + style={{ maxHeight: "40%" }} + > + {commits.map((commit, index) => ( + <HistoryCommitItem + key={commit.hash} + commit={commit} + isSelected={selectedCommitHash === commit.hash} + isUnpushed={index < (pushCount || 0)} + onClick={() => handleCommitClick(commit)} + /> + ))} + </div> + + {/* Two-column file list + diff for the selected commit */} + {selectedCommitHash && ( + isLoadingFiles && !commitFiles ? ( + <div className="flex-1 flex items-center justify-center text-xs text-muted-foreground"> + Loading files… + </div> + ) : filesError ? ( + <div className="flex-1 flex items-center justify-center text-xs text-red-500"> + Failed to load files: {filesError.message} + </div> + ) : ( + <CommitDiffSplit + worktreePath={worktreePath} + commitHash={selectedCommitHash} + files={commitFiles ?? []} + selectedFilePath={selectedFilePath} + onFileSelect={(file) => handleFileClick(file)} + /> + ) + )} </div> ); }); @@ -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 ( - <div - className={cn( - "flex items-center gap-2 px-2 py-1 cursor-pointer transition-colors", - "hover:bg-muted/80", - isSelected && "bg-muted", - )} - onClick={onClick} - > - <FileText className="size-3.5 text-muted-foreground shrink-0 ml-5" /> - <div className="flex-1 min-w-0 flex items-center overflow-hidden"> - {dirPath && ( - <span className="text-xs text-muted-foreground truncate flex-shrink min-w-0"> - {dirPath}/ - </span> - )} - <span className="text-xs font-medium flex-shrink-0 whitespace-nowrap"> - {fileName} - </span> - </div> - <div className="shrink-0">{getStatusIndicator(file.status)}</div> - </div> - ); -}); 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 ( + <AlertDialog open={open} onOpenChange={isWorking ? undefined : onOpenChange}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Remote has new commits</AlertDialogTitle> + <AlertDialogDescription className="mt-2"> + Your push was rejected because the remote branch has commits you + don't have locally. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogBody> + <p className="text-sm text-muted-foreground"> + Pull with rebase and push in one step. Any uncommitted changes will + be auto-stashed and restored. + </p> + </AlertDialogBody> + <AlertDialogFooter> + <AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel> + <AlertDialogAction + onClick={(e) => { + e.preventDefault(); + void handlePullAndPush(); + }} + disabled={isWorking || !worktreePath} + > + {isWorking ? "Working…" : "Pull & Push"} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + ); +} 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/features/details-sidebar/atoms/index.ts b/src/renderer/features/details-sidebar/atoms/index.ts index 787e05627..f91986020 100644 --- a/src/renderer/features/details-sidebar/atoms/index.ts +++ b/src/renderer/features/details-sidebar/atoms/index.ts @@ -2,14 +2,22 @@ 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, + Activity, +} 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" | "tasks" | "todo" | "plan" | "terminal" | "diff" | "mcp" | "pr" export interface WidgetConfig { id: WidgetId @@ -21,6 +29,8 @@ 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: "tasks", label: "Tasks", icon: Activity, canExpand: false, defaultVisible: true }, { 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 6d5b8d016..8835eecf2 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, Activity } from "lucide-react" import { ResizableSidebar } from "@/components/ui/resizable-sidebar" import { Button } from "@/components/ui/button" import { @@ -35,10 +35,12 @@ import { import { WidgetSettingsPopup } from "./widget-settings-popup" import { InfoSection } from "./sections/info-section" import { TodoWidget } from "./sections/todo-widget" +import { TasksWidget } from "./sections/tasks-widget" 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" @@ -55,6 +57,8 @@ function getWidgetIcon(widgetId: WidgetId) { switch (widgetId) { case "info": return Box + case "tasks": + return Activity case "todo": return ListTodo case "plan": @@ -65,6 +69,8 @@ function getWidgetIcon(widgetId: WidgetId) { return DiffIcon case "mcp": return OriginalMCPIcon + case "pr": + return GitPullRequest default: return Box } @@ -322,7 +328,13 @@ export function DetailsSidebar({ > <div className="flex flex-col h-full min-w-0 overflow-hidden"> {/* Header with pill tabs */} - <div className="flex items-center justify-between px-2 h-10 bg-tl-background flex-shrink-0 border-b border-border/50"> + <div + className="flex items-center justify-between px-2 h-10 bg-tl-background flex-shrink-0 border-b border-border/50" + style={{ + // @ts-expect-error - WebKit-specific property + WebkitAppRegion: "no-drag", + }} + > <div className="flex items-center gap-2"> <Tooltip> <TooltipTrigger asChild> @@ -433,6 +445,11 @@ export function DetailsSidebar({ </WidgetCard> ) + case "tasks": + return ( + <TasksWidget key="tasks" subChatId={activeSubChatId || null} /> + ) + case "todo": return ( <TodoWidget key="todo" subChatId={activeSubChatId || null} /> @@ -495,6 +512,15 @@ export function DetailsSidebar({ /> ) + case "pr": + // Only show for local chats with a worktree + if (!worktreePath) return null + return ( + <WidgetCard key="pr" widgetId="pr" title="Pull Request"> + <PrWidget chatId={chatId} /> + </WidgetCard> + ) + case "mcp": return ( <WidgetCard diff --git a/src/renderer/features/details-sidebar/expanded-widget-sidebar.tsx b/src/renderer/features/details-sidebar/expanded-widget-sidebar.tsx index 298928eff..27cb3d1a4 100644 --- a/src/renderer/features/details-sidebar/expanded-widget-sidebar.tsx +++ b/src/renderer/features/details-sidebar/expanded-widget-sidebar.tsx @@ -144,7 +144,13 @@ export function ExpandedWidgetSidebar({ > <div className="flex flex-col h-full min-w-0 overflow-hidden"> {/* Header */} - <div className="flex items-center justify-between pl-3 pr-1.5 h-10 bg-tl-background flex-shrink-0 border-b border-border/50"> + <div + className="flex items-center justify-between pl-3 pr-1.5 h-10 bg-tl-background flex-shrink-0 border-b border-border/50" + style={{ + // @ts-expect-error - WebKit-specific property + WebkitAppRegion: "no-drag", + }} + > <div className="flex items-center gap-2"> {widgetConfig && ( <> diff --git a/src/renderer/features/details-sidebar/sections/changes-widget.tsx b/src/renderer/features/details-sidebar/sections/changes-widget.tsx index c42fdcbe8..aea48bb4d 100644 --- a/src/renderer/features/details-sidebar/sections/changes-widget.tsx +++ b/src/renderer/features/details-sidebar/sections/changes-widget.tsx @@ -25,6 +25,7 @@ import { trpc } from "@/lib/trpc" import { preferredEditorAtom } from "@/lib/atoms" import { APP_META } from "../../../../shared/external-apps" import type { ParsedDiffFile } from "../types" +import { BranchSwitcherPopover } from "@/features/changes/components/branch-switcher/branch-switcher-popover" interface ChangesWidgetProps { chatId: string @@ -263,14 +264,23 @@ export const ChangesWidget = memo(function ChangesWidget({ {/* Title + branch */} <div className="flex items-center gap-1 min-w-0"> <span className="text-xs font-medium text-foreground">Changes</span> - {currentBranch && ( + {currentBranch && worktreePath ? ( + <span className="text-xs text-muted-foreground flex items-center gap-1 min-w-0"> + <span className="shrink-0">on</span> + <BranchSwitcherPopover + worktreePath={worktreePath} + currentBranch={currentBranch} + compact + /> + </span> + ) : currentBranch ? ( <span className="text-xs text-muted-foreground flex items-center gap-1 min-w-0"> <span className="shrink-0">on</span> <span className="truncate max-w-[120px] text-foreground"> {currentBranch} </span> </span> - )} + ) : null} </div> {/* 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({ </span> ) + const wrappedValue = copyable ? ( + <Tooltip open={showCopied ? true : undefined}> + <TooltipTrigger asChild> + {valueEl} + </TooltipTrigger> + <TooltipContent side="top" className="text-xs"> + {showCopied ? "Copied" : "Click to copy"} + </TooltipContent> + </Tooltip> + ) : tooltip ? ( + <Tooltip delayDuration={500}> + <TooltipTrigger asChild> + {valueEl} + </TooltipTrigger> + <TooltipContent side="top" className="text-xs"> + {tooltip} + </TooltipContent> + </Tooltip> + ) : ( + valueEl + ) + return ( <div className="flex items-center min-h-[28px]"> {/* Label column - fixed width */} @@ -88,28 +116,9 @@ function PropertyRow({ <span className="text-xs text-muted-foreground truncate">{label}</span> </div> {/* Value column - flexible */} - <div className="flex-1 min-w-0 pl-2 truncate"> - {copyable ? ( - <Tooltip open={showCopied ? true : undefined}> - <TooltipTrigger asChild> - {valueEl} - </TooltipTrigger> - <TooltipContent side="top" className="text-xs"> - {showCopied ? "Copied" : "Click to copy"} - </TooltipContent> - </Tooltip> - ) : tooltip ? ( - <Tooltip delayDuration={500}> - <TooltipTrigger asChild> - {valueEl} - </TooltipTrigger> - <TooltipContent side="top" className="text-xs"> - {tooltip} - </TooltipContent> - </Tooltip> - ) : ( - valueEl - )} + <div className="flex-1 min-w-0 pl-2 flex items-center gap-1.5 min-h-0"> + <div className="min-w-0 truncate">{wrappedValue}</div> + {badge} </div> </div> ) @@ -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={ + <div className="flex items-center gap-1 min-w-0"> + {branchName && ( + <Tooltip delayDuration={500}> + <TooltipTrigger asChild> + <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-border/70 bg-muted/50 text-[10px] font-mono text-muted-foreground max-w-[140px] truncate"> + <GitBranchFilledIcon className="h-3 w-3 flex-shrink-0" /> + <span className="truncate">{branchName}</span> + </span> + </TooltipTrigger> + <TooltipContent side="top" className="text-xs"> + PR branch: {branchName} + </TooltipContent> + </Tooltip> + )} + <Tooltip delayDuration={500}> + <TooltipTrigger asChild> + <button + type="button" + onClick={(e) => { + e.stopPropagation() + setIsRenamePrOpen(true) + }} + className="h-5 w-5 inline-flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground transition-colors flex-shrink-0" + > + <Pencil className="h-3 w-3" /> + </button> + </TooltipTrigger> + <TooltipContent side="top" className="text-xs"> + Rename PR title + </TooltipContent> + </Tooltip> + </div> + } + /> + )} + {pr && ( + <RenamePrTitleDialog + chatId={chatId} + open={isRenamePrOpen} + initialTitle={pr.title} + prNumber={pr.number} + onOpenChange={setIsRenamePrOpen} /> )} {/* 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 && ( <div className="flex items-center min-h-[28px]"> <div className="flex items-center gap-1.5 w-[100px] flex-shrink-0"> <ExternalLinkIcon className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" /> 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 ( + <div className="px-3 py-3 flex items-center gap-2 text-xs text-muted-foreground border-t border-border/50"> + <IconSpinner className="h-3.5 w-3.5" /> + Loading comments… + </div> + ) + } + + if (isError) { + return ( + <div className="px-3 py-3 text-xs text-muted-foreground border-t border-border/50"> + Couldn't load comments: {error?.message} + </div> + ) + } + + const comments = data ?? [] + if (comments.length === 0) { + return ( + <div className="px-3 py-3 text-xs text-muted-foreground border-t border-border/50"> + No comments yet. + </div> + ) + } + + 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 ( + <div className="border-t border-border/50"> + <div className="px-3 py-2 flex items-center justify-between"> + <span className="text-[11px] uppercase tracking-wide text-muted-foreground"> + {comments.length} comment{comments.length === 1 ? "" : "s"} + </span> + <Button + variant="ghost" + size="sm" + onClick={copyAll} + className="h-6 px-2 text-[11px] gap-1" + > + <Copy className="h-3 w-3" /> + Copy all + </Button> + </div> + <ul className="flex flex-col divide-y divide-border/40"> + {comments.map((c) => ( + <li key={`${c.kind}-${c.id}`} className="px-3 py-2 flex flex-col gap-1"> + <div className="flex items-center justify-between gap-2"> + <div className="flex items-center gap-1.5 text-[11px] text-muted-foreground min-w-0"> + <span className="font-medium text-foreground truncate"> + {c.author} + </span> + <span>·</span> + <span>{relativeTime(c.createdAt)}</span> + {c.kind === "review" && ( + <span className="px-1 py-0.5 rounded bg-muted/60 text-[10px] font-mono flex-shrink-0"> + review + </span> + )} + </div> + <button + type="button" + onClick={() => copyOne(c)} + className="h-5 w-5 inline-flex items-center justify-center text-muted-foreground hover:text-foreground rounded hover:bg-accent flex-shrink-0" + aria-label="Copy comment" + > + <Copy className="h-3 w-3" /> + </button> + </div> + {c.path && ( + <div className="text-[11px] font-mono text-muted-foreground truncate"> + {c.path} + {c.line ? `:${c.line}` : ""} + </div> + )} + {c.diffHunk && ( + <pre className="text-[10px] font-mono bg-muted/40 rounded border border-border/40 px-2 py-1 overflow-x-auto whitespace-pre"> + {c.diffHunk} + </pre> + )} + <div className="text-xs whitespace-pre-wrap break-words"> + {c.body} + </div> + </li> + ))} + </ul> + </div> + ) +} 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..2c6f94580 --- /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 ( + <div className="px-3 py-4 flex items-center gap-2 text-xs text-muted-foreground"> + <IconSpinner className="h-3.5 w-3.5" /> + Loading PR status… + </div> + ) + } + + const pr = status?.pr + if (!pr) { + return ( + <div className="px-3 py-3 text-xs text-muted-foreground"> + No pull request for this branch yet. + </div> + ) + } + + 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 ( + <div className="flex flex-col"> + <div className="px-3 py-2.5 flex flex-col gap-2"> + {/* Title row */} + <div className="flex items-start gap-2"> + <PRIcon state={pr.state} className="h-4 w-4 mt-0.5 flex-shrink-0" /> + <div className="min-w-0 flex-1"> + <div className="flex items-center gap-2 text-xs text-muted-foreground mb-0.5"> + <span className="font-mono">#{pr.number}</span> + <span>·</span> + <span>{stateLabel(pr.state)}</span> + </div> + <button + type="button" + onClick={() => setIsRenameOpen(true)} + className="text-left text-sm font-medium text-foreground hover:underline decoration-muted-foreground/50 underline-offset-2 break-words" + title="Click to rename PR title" + > + {pr.title} + </button> + </div> + <Tooltip delayDuration={500}> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + onClick={openPr} + className="h-6 w-6 flex-shrink-0" + > + <ExternalLink className="h-3.5 w-3.5" /> + </Button> + </TooltipTrigger> + <TooltipContent side="left" className="text-xs"> + Open pull request + </TooltipContent> + </Tooltip> + </div> + + {/* Review + checks row */} + <div className="flex items-center flex-wrap gap-x-3 gap-y-1 text-[11px]"> + {reviewLabel(pr.reviewDecision) && ( + <span className={cn("inline-flex items-center gap-1", reviewTone(pr.reviewDecision))}> + {pr.reviewDecision === "approved" ? ( + <Check className="h-3 w-3" /> + ) : pr.reviewDecision === "changes_requested" ? ( + <TriangleAlert className="h-3 w-3" /> + ) : ( + <CircleDashed className="h-3 w-3" /> + )} + {reviewLabel(pr.reviewDecision)} + </span> + )} + {checks.length > 0 && ( + <span className="inline-flex items-center gap-2 text-muted-foreground"> + {successCount > 0 && ( + <span className="inline-flex items-center gap-0.5 text-emerald-600 dark:text-emerald-400"> + <Check className="h-3 w-3" /> + {successCount} + </span> + )} + {failureCount > 0 && ( + <span className="inline-flex items-center gap-0.5 text-red-600 dark:text-red-400"> + <X className="h-3 w-3" /> + {failureCount} + </span> + )} + {pendingCount > 0 && ( + <span className="inline-flex items-center gap-0.5"> + <CircleDashed className="h-3 w-3" /> + {pendingCount} + </span> + )} + </span> + )} + {(pr.additions !== undefined || pr.deletions !== undefined) && ( + <span className="text-muted-foreground"> + <span className="text-emerald-600 dark:text-emerald-400"> + +{pr.additions ?? 0} + </span>{" "} + <span className="text-red-600 dark:text-red-400"> + −{pr.deletions ?? 0} + </span> + </span> + )} + </div> + + {/* Comments toggle */} + <button + type="button" + onClick={() => setShowComments((v) => !v)} + className="self-start inline-flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors" + > + <MessageSquare className="h-3 w-3" /> + {showComments ? "Hide comments" : "Show comments"} + </button> + </div> + + {showComments && <PrCommentsList chatId={chatId} />} + + <RenamePrTitleDialog + chatId={chatId} + open={isRenameOpen} + initialTitle={pr.title} + prNumber={pr.number} + onOpenChange={setIsRenameOpen} + /> + </div> + ) +}) 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..a41ec534d --- /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 ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[480px]"> + <DialogHeader> + <DialogTitle>Rename PR #{prNumber}</DialogTitle> + <DialogDescription> + Update the title of this pull request. + </DialogDescription> + </DialogHeader> + <Input + value={title} + onChange={(e) => setTitle(e.target.value)} + placeholder="PR title" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleSave() + } + }} + disabled={mutation.isPending} + /> + <DialogFooter className="gap-2"> + <Button + variant="ghost" + onClick={() => onOpenChange(false)} + disabled={mutation.isPending} + > + Cancel + </Button> + <Button onClick={handleSave} disabled={!canSave}> + {mutation.isPending ? "Saving…" : "Save"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/src/renderer/features/details-sidebar/sections/tasks-widget.tsx b/src/renderer/features/details-sidebar/sections/tasks-widget.tsx new file mode 100644 index 000000000..170df242a --- /dev/null +++ b/src/renderer/features/details-sidebar/sections/tasks-widget.tsx @@ -0,0 +1,258 @@ +"use client" + +import { memo, useEffect, useMemo, useRef, useState } from "react" +import { atom, useAtomValue } from "jotai" +import { atomFamily } from "jotai/utils" +import { Activity, Loader2 } from "lucide-react" +import { cn } from "@/lib/utils" +import { + getPerChatMessageKey, + messageAtomFamily, + messageIdsPerChatAtom, + type Message, +} from "@/features/agents/stores/message-store" +import { useStreamingStatusStore } from "@/features/agents/stores/streaming-status-store" + +interface TasksWidgetProps { + subChatId: string | null +} + +interface RunningTask { + toolCallId: string + toolName: string + summary: string + startedAt: number + parentId: string | null + children: RunningTask[] +} + +// Tools that are tracked elsewhere (Todo widget / plan approvals) or are not real work. +const EXCLUDED_TOOL_NAMES = new Set([ + "TodoWrite", + "TaskCreate", + "TaskUpdate", + "TaskList", + "TaskGet", + "TaskOutput", + "ExitPlanMode", + "Thinking", +]) + +function summarizeInput(input: unknown): string { + if (!input || typeof input !== "object") return "" + const rec = input as Record<string, unknown> + const preferredKeys = [ + "command", + "file_path", + "path", + "pattern", + "description", + "url", + "query", + "prompt", + "subagent_type", + ] + for (const key of preferredKeys) { + const v = rec[key] + if (typeof v === "string" && v.length > 0) return v + } + for (const key in rec) { + const v = rec[key] + if (typeof v === "string" && v.length > 0) return v + } + return "" +} + +function formatElapsed(ms: number): string { + const sec = Math.max(0, Math.floor(ms / 1000)) + if (sec < 60) return `${sec}s` + const min = Math.floor(sec / 60) + const rem = sec % 60 + return `${min}m ${rem.toString().padStart(2, "0")}s` +} + +// Derived atom: the last assistant Message for a given subChatId. +// Scans from the end of messageIdsPerChatAtom; returns null if none found. +const lastAssistantMessageForSubChatAtomFamily = atomFamily((subChatId: string) => + atom<Message | null>((get) => { + const ids = get(messageIdsPerChatAtom(subChatId)) + for (let i = ids.length - 1; i >= 0; i--) { + const id = ids[i] + if (!id) continue + const msg = get(messageAtomFamily(getPerChatMessageKey(subChatId, id))) + if (msg && msg.role === "assistant") return msg + } + return null + }), +) + +export const TasksWidget = memo(function TasksWidget({ + subChatId, +}: TasksWidgetProps) { + const key = subChatId || "default" + + const isStreaming = useStreamingStatusStore((s) => s.isStreaming(key)) + + const lastAssistantAtom = useMemo( + () => lastAssistantMessageForSubChatAtomFamily(key), + [key], + ) + const lastAssistant = useAtomValue(lastAssistantAtom) + + const startedAtRef = useRef<Map<string, number>>(new Map()) + + const tasks = useMemo<RunningTask[]>(() => { + if (!isStreaming || !lastAssistant) return [] + + const parts = lastAssistant.parts || [] + const byId = new Map<string, RunningTask>() + + for (const part of parts) { + if (!part?.type || typeof part.type !== "string") continue + if (!part.type.startsWith("tool-")) continue + if (!part.toolCallId) continue + + const st = part.state + const isRunning = + st !== "output-available" && + st !== "output-error" && + st !== "result" && + st !== "input-error" + if (!isRunning) continue + + const toolName = part.type.slice(5) + if (EXCLUDED_TOOL_NAMES.has(toolName)) continue + + const colonIdx = part.toolCallId.indexOf(":") + const parentId = + colonIdx > -1 ? part.toolCallId.slice(0, colonIdx) : null + + // AI SDK exposes transform-provided startedAt on `callProviderMetadata`; + // fall back to `providerMetadata` and then to first-sighting time. + const metaStart = + (part.callProviderMetadata?.custom?.startedAt as number | undefined) ?? + (part.providerMetadata?.custom?.startedAt as number | undefined) ?? + (part.startedAt as number | undefined) + let startedAt = + typeof metaStart === "number" + ? metaStart + : startedAtRef.current.get(part.toolCallId) + if (typeof startedAt !== "number") { + startedAt = Date.now() + } + startedAtRef.current.set(part.toolCallId, startedAt) + + byId.set(part.toolCallId, { + toolCallId: part.toolCallId, + toolName, + summary: summarizeInput(part.input).slice(0, 80), + startedAt, + parentId, + children: [], + }) + } + + const roots: RunningTask[] = [] + for (const task of byId.values()) { + if (task.parentId && byId.has(task.parentId)) { + byId.get(task.parentId)!.children.push(task) + } else { + roots.push(task) + } + } + return roots + }, [isStreaming, lastAssistant]) + + // Prune startedAt entries that no longer correspond to a running tool. + useEffect(() => { + if (tasks.length === 0) { + startedAtRef.current.clear() + return + } + const live = new Set<string>() + const walk = (list: RunningTask[]) => { + for (const t of list) { + live.add(t.toolCallId) + walk(t.children) + } + } + walk(tasks) + for (const id of Array.from(startedAtRef.current.keys())) { + if (!live.has(id)) startedAtRef.current.delete(id) + } + }, [tasks]) + + // Tick once per second while the list is non-empty to update elapsed times. + const [, setTick] = useState(0) + useEffect(() => { + if (tasks.length === 0) return + const h = setInterval(() => setTick((n) => n + 1), 1000) + return () => clearInterval(h) + }, [tasks.length]) + + const total = useMemo(() => { + let n = 0 + const walk = (list: RunningTask[]) => { + for (const t of list) { + n++ + walk(t.children) + } + } + walk(tasks) + return n + }, [tasks]) + + if (tasks.length === 0) return null + + return ( + <div className="mx-2 mb-2"> + <div className="rounded-t-lg border border-b-0 border-border/50 bg-muted/30 px-2 h-8 flex items-center"> + <div className="flex items-center gap-2 flex-1 min-w-0"> + <Activity className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" /> + <span className="text-xs font-medium text-foreground">Tasks</span> + <span className="text-xs text-muted-foreground flex-1 truncate"> + Running now + </span> + <span className="text-xs text-muted-foreground tabular-nums flex-shrink-0"> + {total} + </span> + </div> + </div> + <div className="rounded-b-lg border border-border/50 border-t-0 py-0.5"> + {tasks.map((task) => ( + <TaskRow key={task.toolCallId} task={task} depth={0} /> + ))} + </div> + </div> + ) +}) + +function TaskRow({ task, depth }: { task: RunningTask; depth: number }) { + const elapsed = formatElapsed(Date.now() - task.startedAt) + return ( + <> + <div + className={cn( + "flex items-center gap-2 px-2 py-1.5 text-xs", + depth > 0 && "pl-6 ml-3 border-l border-border/30", + )} + > + <Loader2 className="h-3 w-3 animate-spin text-muted-foreground flex-shrink-0" /> + <span className="text-foreground font-medium flex-shrink-0"> + {task.toolName} + </span> + {task.summary ? ( + <span className="text-muted-foreground truncate min-w-0"> + {task.summary} + </span> + ) : null} + <span className="ml-auto text-muted-foreground tabular-nums flex-shrink-0"> + {elapsed} + </span> + </div> + {task.children.map((child) => ( + <TaskRow key={child.toolCallId} task={child} depth={depth + 1} /> + ))} + </> + ) +} diff --git a/src/renderer/features/file-viewer/components/file-viewer-sidebar.tsx b/src/renderer/features/file-viewer/components/file-viewer-sidebar.tsx index 7eeba577b..2098e4925 100644 --- a/src/renderer/features/file-viewer/components/file-viewer-sidebar.tsx +++ b/src/renderer/features/file-viewer/components/file-viewer-sidebar.tsx @@ -149,7 +149,13 @@ function UnsupportedViewer({ return ( <div className="flex flex-col h-full bg-background"> - <div className="flex items-center justify-between px-2 h-10 border-b border-border/50 bg-background flex-shrink-0"> + <div + className="flex items-center justify-between px-2 h-10 border-b border-border/50 bg-background flex-shrink-0" + style={{ + // @ts-expect-error - WebKit-specific property + WebkitAppRegion: "no-drag", + }} + > <div className="flex items-center gap-1 min-w-0 flex-1"> {/* Close + mode switcher on the left */} <Button @@ -214,7 +220,13 @@ function CodeViewerHeader({ }, [filePath, preferredEditor, openInAppMutation]) return ( - <div className="@container flex items-center justify-between px-2 h-10 border-b border-border/50 bg-background flex-shrink-0"> + <div + className="@container flex items-center justify-between px-2 h-10 border-b border-border/50 bg-background flex-shrink-0" + style={{ + // @ts-expect-error - WebKit-specific property + WebkitAppRegion: "no-drag", + }} + > {/* Left side: Close button + mode switcher + file info */} <div className="flex items-center gap-1 min-w-0 flex-1"> <Button diff --git a/src/renderer/features/file-viewer/components/image-viewer.tsx b/src/renderer/features/file-viewer/components/image-viewer.tsx index 6f2c80296..32a61d744 100644 --- a/src/renderer/features/file-viewer/components/image-viewer.tsx +++ b/src/renderer/features/file-viewer/components/image-viewer.tsx @@ -71,7 +71,13 @@ export function ImageViewer({ return ( <div className="flex flex-col h-full bg-background"> {/* Header */} - <div className="@container flex items-center justify-between px-2 h-10 border-b border-border/50 bg-background flex-shrink-0"> + <div + className="@container flex items-center justify-between px-2 h-10 border-b border-border/50 bg-background flex-shrink-0" + style={{ + // @ts-expect-error - WebKit-specific property + WebkitAppRegion: "no-drag", + }} + > {/* Left side: Close + mode switcher + file info */} <div className="flex items-center gap-1 min-w-0 flex-1"> <Button diff --git a/src/renderer/features/file-viewer/components/markdown-viewer.tsx b/src/renderer/features/file-viewer/components/markdown-viewer.tsx index 30dd01029..b29e76fdd 100644 --- a/src/renderer/features/file-viewer/components/markdown-viewer.tsx +++ b/src/renderer/features/file-viewer/components/markdown-viewer.tsx @@ -240,7 +240,13 @@ function Header({ }, [filePath, preferredEditor, openInAppMutation]) return ( - <div className="@container flex items-center justify-between px-2 h-10 border-b border-border/50 bg-background flex-shrink-0"> + <div + className="@container flex items-center justify-between px-2 h-10 border-b border-border/50 bg-background flex-shrink-0" + style={{ + // @ts-expect-error - WebKit-specific property + WebkitAppRegion: "no-drag", + }} + > {/* Left side: Close + mode switcher + file info */} <div className="flex items-center gap-1 min-w-0 flex-1"> <Button diff --git a/src/renderer/features/kanban/kanban-view.tsx b/src/renderer/features/kanban/kanban-view.tsx index 4285dd089..dcea5a1df 100644 --- a/src/renderer/features/kanban/kanban-view.tsx +++ b/src/renderer/features/kanban/kanban-view.tsx @@ -64,8 +64,6 @@ export function KanbanView() { const [confirmArchiveDialogOpen, setConfirmArchiveDialogOpen] = useState(false) const [archivingChatId, setArchivingChatId] = useState<string | null>(null) const [activeProcessCount, setActiveProcessCount] = useState(0) - const [hasWorktree, setHasWorktree] = useState(false) - const [uncommittedCount, setUncommittedCount] = useState(0) // tRPC utils const utils = trpc.useUtils() @@ -365,24 +363,16 @@ export function KanbanView() { // Archive handler with confirmation for active processes const handleArchive = useCallback(async (chatId: string) => { - // Check for active processes and worktree const chat = chats?.find((c) => c.id === chatId) const isLocalMode = !chat?.branch - const [sessionCount, worktreeStatus] = await Promise.all([ - // Local mode: terminals are shared and won't be killed on archive, so skip count - isLocalMode - ? Promise.resolve(0) - : utils.terminal.getActiveSessionCount.fetch({ workspaceId: chatId }), - utils.chats.getWorktreeStatus.fetch({ chatId }), - ]) + // Local mode: terminals are shared and won't be killed on archive, so skip count + const sessionCount = isLocalMode + ? 0 + : await utils.terminal.getActiveSessionCount.fetch({ workspaceId: chatId }) - const needsConfirmation = sessionCount > 0 || worktreeStatus.hasWorktree - - if (needsConfirmation) { + if (sessionCount > 0) { setArchivingChatId(chatId) setActiveProcessCount(sessionCount) - setHasWorktree(worktreeStatus.hasWorktree) - setUncommittedCount(worktreeStatus.uncommittedCount) setConfirmArchiveDialogOpen(true) } else { await archiveChatMutation.mutateAsync({ id: chatId }) @@ -459,8 +449,6 @@ export function KanbanView() { onClose={handleCancelArchive} onConfirm={handleConfirmArchive} activeProcessCount={activeProcessCount} - hasWorktree={hasWorktree} - uncommittedCount={uncommittedCount} /> </div> ) diff --git a/src/renderer/features/layout/agents-layout.tsx b/src/renderer/features/layout/agents-layout.tsx index c76bbdf53..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) @@ -335,13 +336,24 @@ export function AgentsLayout() { </ResizableSidebar> {/* Main Content */} - <div className="flex-1 overflow-hidden flex flex-col min-w-0"> + <div className="relative flex-1 overflow-hidden flex flex-col min-w-0"> + {/* Draggable strip for window movement (hidden in fullscreen, handled by WindowsTitleBar on Windows) */} + {isDesktop && !isFullscreen && ( + <div + className="absolute inset-x-0 top-0 h-[32px] z-0" + style={{ + // @ts-expect-error - WebKit-specific property + WebkitAppRegion: "drag", + }} + /> + )} <AgentsContent /> </div> </div> + {/* UPDATES-DISABLED: re-enable to restore update banner */} {/* Update Banner */} - <UpdateBanner /> + {/* <UpdateBanner /> */} </div> </TooltipProvider> ) diff --git a/src/renderer/features/settings/settings-content.tsx b/src/renderer/features/settings/settings-content.tsx index fa4ad6d78..149ff4975 100644 --- a/src/renderer/features/settings/settings-content.tsx +++ b/src/renderer/features/settings/settings-content.tsx @@ -83,7 +83,7 @@ export function SettingsContent() { return ( <div className="h-full overflow-y-auto"> - <div className="max-w-2xl mx-auto"> + <div className="max-w-5xl mx-auto"> {renderTabContent()} </div> </div> diff --git a/src/renderer/features/sidebar/agents-sidebar.tsx b/src/renderer/features/sidebar/agents-sidebar.tsx index feb803843..3093aaabf 100644 --- a/src/renderer/features/sidebar/agents-sidebar.tsx +++ b/src/renderer/features/sidebar/agents-sidebar.tsx @@ -40,7 +40,7 @@ import { } from "../../lib/hooks/use-remote-chats" import { usePrefetchLocalChat } from "../../lib/hooks/use-prefetch-local-chat" import { ArchivePopover } from "../agents/ui/archive-popover" -import { ChevronDown, MoreHorizontal, Columns3, ArrowUpRight } from "lucide-react" +import { ChevronDown, MoreHorizontal, Columns3, ArrowUpRight, BarChart3 } from "lucide-react" import { useQuery } from "@tanstack/react-query" import { remoteTrpc } from "../../lib/remote-trpc" // import { useRouter } from "next/navigation" // Desktop doesn't use next/navigation @@ -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({ )} <span className="truncate flex-1 min-w-0">{displayText}</span> <div className="flex items-center gap-1.5 flex-shrink-0"> + {prNumber != null && ( + <span className="inline-flex items-center gap-0.5 font-mono text-[10px] text-muted-foreground/80"> + <GitPullRequestFilledIcon className="h-2.5 w-2.5" /> + {prNumber} + </span> + )} {stats && (stats.additions > 0 || stats.deletions > 0) && ( <> <span className="text-green-600 dark:text-green-400"> @@ -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} @@ -1116,6 +1126,36 @@ const KanbanButton = memo(function KanbanButton() { ) }) +// Isolated Usage Button - navigates to the Usage statistics page +const UsageButton = memo(function UsageButton() { + const setSelectedChatId = useSetAtom(selectedAgentChatIdAtom) + const setSelectedDraftId = useSetAtom(selectedDraftIdAtom) + const setShowNewChatForm = useSetAtom(showNewChatFormAtom) + const setDesktopView = useSetAtom(desktopViewAtom) + + const handleClick = useCallback(() => { + setSelectedChatId(null) + setSelectedDraftId(null) + setShowNewChatForm(false) + setDesktopView("usage") + }, [setSelectedChatId, setSelectedDraftId, setShowNewChatForm, setDesktopView]) + + return ( + <Tooltip delayDuration={500}> + <TooltipTrigger asChild> + <button + type="button" + onClick={handleClick} + className="flex items-center justify-center h-7 w-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-[background-color,color,transform] duration-150 ease-out active:scale-[0.97] outline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70" + > + <BarChart3 className="h-4 w-4" /> + </button> + </TooltipTrigger> + <TooltipContent>Usage</TooltipContent> + </Tooltip> + ) +}) + // Custom SVG icons matching web's icons.tsx function SidebarInboxIcon(props: React.SVGProps<SVGSVGElement>) { return ( @@ -1725,8 +1765,6 @@ export function AgentsSidebar({ const [confirmArchiveDialogOpen, setConfirmArchiveDialogOpen] = useState(false) const [archivingChatId, setArchivingChatId] = useState<string | null>(null) const [activeProcessCount, setActiveProcessCount] = useState(0) - const [hasWorktree, setHasWorktree] = useState(false) - const [uncommittedCount, setUncommittedCount] = useState(0) // Import sandbox dialog state const [importDialogOpen, setImportDialogOpen] = useState(false) @@ -2733,27 +2771,19 @@ export function AgentsSidebar({ return } - // Fetch both session count and worktree status in parallel + // Only worktree-mode workspaces may have running terminals — skip count for local mode const isLocalMode = !chat?.branch - const [sessionCount, worktreeStatus] = await Promise.all([ - // Local mode: terminals are shared and won't be killed on archive, so skip count - isLocalMode - ? Promise.resolve(0) - : utils.terminal.getActiveSessionCount.fetch({ workspaceId: chatId }), - utils.chats.getWorktreeStatus.fetch({ chatId }), - ]) - - const needsConfirmation = sessionCount > 0 || worktreeStatus.hasWorktree - - if (needsConfirmation) { - // Show confirmation dialog + const sessionCount = isLocalMode + ? 0 + : await utils.terminal.getActiveSessionCount.fetch({ workspaceId: chatId }) + + if (sessionCount > 0) { + // Show confirmation dialog so user is warned about running processes setArchivingChatId(chatId) setActiveProcessCount(sessionCount) - setHasWorktree(worktreeStatus.hasWorktree) - setUncommittedCount(worktreeStatus.uncommittedCount) setConfirmArchiveDialogOpen(true) } else { - // No active processes and no worktree, archive directly + // No active processes, archive directly archiveChatMutation.mutate({ id: chatId }) } }, [ @@ -2761,7 +2791,6 @@ export function AgentsSidebar({ archiveRemoteChatMutation, archiveChatMutation, utils.terminal.getActiveSessionCount, - utils.chats.getWorktreeStatus, selectedChatId, autoAdvanceTarget, previousChatId, @@ -2771,9 +2800,9 @@ export function AgentsSidebar({ ]) // Confirm archive after user accepts dialog (optimistic - closes immediately) - const handleConfirmArchive = useCallback((deleteWorktree: boolean) => { + const handleConfirmArchive = useCallback(() => { if (archivingChatId) { - archiveChatMutation.mutate({ id: archivingChatId, deleteWorktree }) + archiveChatMutation.mutate({ id: archivingChatId }) setArchivingChatId(null) } }, [archiveChatMutation, archivingChatId]) @@ -3461,12 +3490,16 @@ export function AgentsSidebar({ {/* Archive Button - isolated component to prevent sidebar re-renders */} <ArchiveSection archivedChatsCount={archivedChatsCount} /> + + {/* Usage Button - opens the Usage statistics page */} + <UsageButton /> </div> <div className="flex-1" /> </div> - {/* Feedback Button */} + {/* UPDATES-DISABLED: re-enable to restore Feedback button */} + {/* <ButtonCustom onClick={() => window.open(FEEDBACK_URL, "_blank")} variant="outline" @@ -3478,6 +3511,7 @@ export function AgentsSidebar({ > <span className="text-sm font-medium">Feedback</span> </ButtonCustom> + */} </motion.div> )} </AnimatePresence> @@ -3523,8 +3557,6 @@ export function AgentsSidebar({ onClose={handleCloseArchiveDialog} onConfirm={handleConfirmArchive} activeProcessCount={activeProcessCount} - hasWorktree={hasWorktree} - uncommittedCount={uncommittedCount} /> {/* Open Locally Dialog */} diff --git a/src/renderer/features/sidebar/agents-subchats-sidebar.tsx b/src/renderer/features/sidebar/agents-subchats-sidebar.tsx index dc9c83f8d..f0ac20664 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,12 @@ 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 { + SortableContext, + useSortable, + verticalListSortingStrategy, +} 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 +203,63 @@ 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, + // Hide the original while the DragOverlay (rendered at document root by + // agents-content.tsx) shows the preview; the row still occupies space so + // siblings don't collapse and dnd-kit's drop indicators still animate. + opacity: isDragging ? 0 : 1, + zIndex: isDragging ? 10 : undefined, + position: "relative", + } + + return ( + <div + ref={setNodeRef} + style={style} + data-dnd-active={isDragging || undefined} + className={cn( + "group/sortable", + !disabled && (isDragging ? "cursor-grabbing" : "cursor-grab"), + )} + {...attributes} + {...listeners} + > + {!disabled && ( + <GripVertical + className={cn( + "absolute left-0 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground/50 transition-opacity duration-100 pointer-events-none z-10", + isDragging ? "opacity-100" : "opacity-0 group-hover/sortable:opacity-60", + )} + aria-hidden="true" + /> + )} + {children} + </div> + ) +} + export function AgentsSubChatsSidebar({ onClose, isMobile = false, @@ -231,6 +294,11 @@ export function AgentsSubChatsSidebar({ closeSplit: state.closeSplit, })) ) + + // DnD is managed at a shared parent (agents-content.tsx) so that sub-chats + // can be dragged out of the sidebar and into the split-view drop zone. + // The SortableContext blocks below remain to provide in-sidebar reordering. + const [loadingSubChats] = useAtom(loadingSubChatsAtom) const subChatFiles = useAtomValue(subChatFilesAtom) const selectedTeamId = useAtomValue(selectedTeamIdAtom) @@ -349,16 +417,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 +778,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 +791,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 +802,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) => { @@ -1262,6 +1330,10 @@ export function AgentsSubChatsSidebar({ </h3> </div> <div className="list-none p-0 m-0 mb-3"> + <SortableContext + items={pinnedChats.map((c) => c.id)} + strategy={verticalListSortingStrategy} + > {pinnedChats.map((subChat) => { const isSubChatLoading = loadingChatIds.has( subChat.id, @@ -1305,7 +1377,12 @@ export function AgentsSubChatsSidebar({ : null return ( - <ContextMenu key={subChat.id}> + <SortableSubChatRow + key={subChat.id} + id={subChat.id} + disabled={isSplitTab} + > + <ContextMenu> <ContextMenuTrigger asChild> <div data-subchat-index={globalIndex} @@ -1352,7 +1429,8 @@ export function AgentsSubChatsSidebar({ handleSubChatMouseLeave() }} className={cn( - "w-full text-left py-1.5 transition-colors duration-75 cursor-pointer group relative", + "w-full text-left py-1.5 transition-colors duration-75 group relative", + isSplitTab ? "cursor-pointer" : "cursor-grab active:cursor-grabbing", "outline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70", isMultiSelectMode ? "px-3" : "pl-2 pr-2", isMultiSelectMode ? "" : "rounded-md", @@ -1546,8 +1624,10 @@ export function AgentsSubChatsSidebar({ /> )} </ContextMenu> + </SortableSubChatRow> ) })} + </SortableContext> </div> </> )} @@ -1566,6 +1646,10 @@ export function AgentsSubChatsSidebar({ </h3> </div> <div className="list-none p-0 m-0"> + <SortableContext + items={unpinnedChats.map((c) => c.id)} + strategy={verticalListSortingStrategy} + > {unpinnedChats.map((subChat) => { const isSubChatLoading = loadingChatIds.has( subChat.id, @@ -1609,7 +1693,12 @@ export function AgentsSubChatsSidebar({ : null return ( - <ContextMenu key={subChat.id}> + <SortableSubChatRow + key={subChat.id} + id={subChat.id} + disabled={isSplitTab} + > + <ContextMenu> <ContextMenuTrigger asChild> <div data-subchat-index={globalIndex} @@ -1656,7 +1745,8 @@ export function AgentsSubChatsSidebar({ handleSubChatMouseLeave() }} className={cn( - "w-full text-left py-1.5 transition-colors duration-75 cursor-pointer group relative", + "w-full text-left py-1.5 transition-colors duration-75 group relative", + isSplitTab ? "cursor-pointer" : "cursor-grab active:cursor-grabbing", "outline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70", isMultiSelectMode ? "px-3" : "pl-2 pr-2", isMultiSelectMode ? "" : "rounded-md", @@ -1850,8 +1940,10 @@ export function AgentsSubChatsSidebar({ /> )} </ContextMenu> + </SortableSubChatRow> ) })} + </SortableContext> </div> </> )} diff --git a/src/renderer/features/terminal/terminal-sidebar.tsx b/src/renderer/features/terminal/terminal-sidebar.tsx index 3725beb11..5143604c5 100644 --- a/src/renderer/features/terminal/terminal-sidebar.tsx +++ b/src/renderer/features/terminal/terminal-sidebar.tsx @@ -113,6 +113,10 @@ function TerminalModeSwitcher({ variant="ghost" size="sm" className="h-6 w-6 p-0 flex-shrink-0 hover:bg-foreground/10" + style={{ + // @ts-expect-error - WebKit-specific property + WebkitAppRegion: "no-drag", + }} > <CurrentIcon className="size-4 text-muted-foreground" /> </Button> @@ -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", + }} > <IconDoubleChevronRight className="h-4 w-4" /> </Button> 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, }, { 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 ( + <div className={cn("flex flex-col gap-2", className)}> + <div className="text-xs text-muted-foreground font-medium">Activity</div> + <div className="overflow-x-auto"> + <svg width={width} height={height} role="img" aria-label="Daily activity heatmap"> + {DAY_LABELS.map((label, i) => ( + <text + key={i} + x={0} + y={i * (CELL + GAP) + CELL - 2} + className="fill-muted-foreground" + fontSize={9} + > + {label} + </text> + ))} + {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 ( + <rect + key={`${c.date}-${c.weekIndex}-${c.dayOfWeek}`} + x={x} + y={y} + width={CELL} + height={CELL} + rx={2} + className="fill-foreground" + opacity={opacity} + > + <title> + {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..3637cfad8 --- /dev/null +++ b/src/renderer/features/usage/usage-content.tsx @@ -0,0 +1,215 @@ +import { useAtom, useSetAtom } from "jotai" +import { useEffect } from "react" +import { + desktopViewAtom, + usagePeriodAtom, + usageSourceAtom, + agentsSidebarOpenAtom, + 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, 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" }, + { 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) + const [sidebarOpen, setSidebarOpen] = useAtom(agentsSidebarOpenAtom) + const isMobile = useIsMobile() + + 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 ( +
+ {/* Header bar — mirrors kanban layout */} +
+ {isMobile ? ( + + ) : ( + setSidebarOpen((prev) => !prev)} + /> + )} +
+ +
+
+
+
+

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) => ( +
+ ))} +
+
+
+
+
+
+
+ ) +} diff --git a/src/renderer/lib/atoms/index.ts b/src/renderer/lib/atoms/index.ts index e28999953..edd3c91d4 100644 --- a/src/renderer/lib/atoms/index.ts +++ b/src/renderer/lib/atoms/index.ts @@ -359,16 +359,6 @@ export const activeConfigAtom = atom((get) => { return undefined }) -// Preferences - Extended Thinking -// When enabled, Claude will use extended thinking for deeper reasoning (128K tokens) -// Note: Extended thinking disables response streaming -export const extendedThinkingEnabledAtom = atomWithStorage( - "preferences:extended-thinking-enabled", - true, - undefined, - { getOnInit: true }, -) - // Preferences - History (Rollback) // When enabled, allow rollback to previous assistant messages export const historyEnabledAtom = atomWithStorage( 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, })