From 3f8a59089840d53091d44b75b4547f3f4fc64679 Mon Sep 17 00:00:00 2001 From: d3oxy Date: Thu, 5 Feb 2026 22:13:40 +0530 Subject: [PATCH 1/6] fix: symlink settings.json to isolated config dir The Claude binary runs with CLAUDE_CONFIG_DIR set to an isolated directory per session. While skills/ and agents/ were symlinked from ~/.claude/, settings.json was not. This caused user settings (includeCoAuthoredBy, permissions, hooks, etc.) to be invisible to the Claude binary running inside 1Code. Now settings.json is symlinked alongside skills and agents, so the binary sees the user's global settings. --- src/main/lib/trpc/routers/claude.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/main/lib/trpc/routers/claude.ts b/src/main/lib/trpc/routers/claude.ts index 7be49b8b0..cf6d84df3 100644 --- a/src/main/lib/trpc/routers/claude.ts +++ b/src/main/lib/trpc/routers/claude.ts @@ -865,6 +865,8 @@ export const claudeRouter = router({ const skillsTarget = path.join(isolatedConfigDir, "skills") const agentsSource = path.join(homeClaudeDir, "agents") const agentsTarget = path.join(isolatedConfigDir, "agents") + const settingsSource = path.join(homeClaudeDir, "settings.json") + const settingsTarget = path.join(isolatedConfigDir, "settings.json") // Symlink skills directory if source exists and target doesn't try { @@ -888,6 +890,18 @@ export const claudeRouter = router({ // Ignore symlink errors (might already exist or permission issues) } + // Symlink settings.json so Claude binary sees user's global settings + // (includeCoAuthoredBy, permissions, hooks, etc.) + try { + const settingsSourceExists = await fs.stat(settingsSource).then(() => true).catch(() => false) + const settingsTargetExists = await fs.lstat(settingsTarget).then(() => true).catch(() => false) + if (settingsSourceExists && !settingsTargetExists) { + await fs.symlink(settingsSource, settingsTarget, "file") + } + } catch (symlinkErr) { + // Ignore symlink errors (might already exist or permission issues) + } + symlinksCreated.add(cacheKey) } From 909c5dba58a70914c9c9cd2eba6268acef6e8ef9 Mon Sep 17 00:00:00 2001 From: serafim Date: Thu, 5 Feb 2026 12:03:07 -0800 Subject: [PATCH 2/6] Release v0.0.55 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What's New ### Features - **Claude Code 2.1.32** — Updated to Claude Code binary 2.1.32, SDK 0.2.32, added Opus 4.6 model support - **Thinking Stream UI** — Streaming thinking content with improved thinking tool visualization and elapsed time display - **Inbox Redesign** — Redesigned inbox pages with context menus, fork locally option, and sidebar improvements - **Automation Tracking** — Automation execution tracking in Linear start-agent with auto-save support ### Improvements & Fixes - **Auto-Retry on Policy Errors** — Silent retries with 3s/6s delays on false-positive USAGE_POLICY_VIOLATION errors, friendlier error messages - **Model Names** — Added version numbers to Sonnet 4.5 and Haiku 4.5 model names - **Sub-Chat Stability** — Fixed sub-chat loading race condition, hover prefetch, and chat timestamp bug - **Chat Image Persistence** — Persist chat images across sessions and prevent duplicate messages - **Chat Name Language** — Generate chat names in the same language as user's message - **Git Fixes** — Fixed git diff view, git widget, git selection state, and relative display paths - **Context Counter** — Fixed context counter display - **MCP Timeout** — Fixed MCP timeout handling - **Onboarding Token Fix** — Disabled CLI token import in onboarding (tokens expire in ~8 hours) - **Scroll-to-Bottom Button** — Responsive scroll-to-bottom with CSS variable sizing - **Sidebar Toggle** — Unified sidebar toggle button sizes in sub-chat selector - **Build Fix** — Pinned source-map to 0.7.4 to fix electron-builder packaging error --- CLAUDE.md | 22 + bun.lock | 283 ++- bun.lockb | Bin 483005 -> 483005 bytes package.json | 8 +- src/main/lib/auto-updater.ts | 25 +- src/main/lib/claude/env.ts | 48 +- src/main/lib/claude/transform.ts | 178 +- src/main/lib/claude/types.ts | 7 +- src/main/lib/git/worktree-config.ts | 2 +- src/main/lib/git/worktree.ts | 16 +- src/main/lib/trpc/routers/chats.ts | 55 +- src/main/lib/trpc/routers/claude.ts | 2031 +++++++++++------ src/preload/index.d.ts | 9 + src/preload/index.ts | 7 + .../dialogs/settings-tabs/agents-beta-tab.tsx | 4 +- .../agents-custom-agents-tab.tsx | 12 +- .../agents-project-worktree-tab.tsx | 9 +- src/renderer/features/agents/atoms/index.ts | 2 +- .../agents/components/open-locally-dialog.tsx | 32 +- .../agents/components/queue-processor.tsx | 4 +- .../features/agents/hooks/use-auto-import.ts | 11 +- .../hooks/use-changed-files-tracking.ts | 9 +- .../features/agents/lib/ipc-chat-transport.ts | 38 +- src/renderer/features/agents/lib/models.ts | 6 +- .../features/agents/main/active-chat.tsx | 334 ++- .../agents/main/assistant-message-item.tsx | 5 +- .../features/agents/main/chat-input-area.tsx | 48 +- .../agents/main/isolated-message-group.tsx | 28 +- .../agents/main/isolated-messages-section.tsx | 4 + .../features/agents/main/messages-list.tsx | 14 + .../features/agents/main/new-chat-form.tsx | 14 +- .../features/agents/stores/message-store.ts | 42 +- .../features/agents/ui/agent-diff-view.tsx | 44 +- .../features/agents/ui/agent-task-tool.tsx | 25 +- .../agents/ui/agent-thinking-tool.tsx | 108 +- .../agents/ui/agent-tool-registry.tsx | 6 +- .../agents/ui/agent-user-message-bubble.tsx | 10 +- .../features/agents/ui/agents-content.tsx | 5 +- .../features/agents/ui/sub-chat-selector.tsx | 2 +- .../agents/ui/sub-chat-status-card.tsx | 87 +- .../automations/_components/constants.ts | 6 +- .../features/automations/inbox-view.tsx | 546 +++-- .../features/changes/changes-panel.tsx | 4 + .../features/changes/changes-view.tsx | 72 +- .../components/commit-input/commit-input.tsx | 91 +- .../changes/components/commit-input/index.ts | 1 + .../commit-input/use-commit-actions.ts | 120 + .../diff-sidebar-header.tsx | 28 +- .../features/changes/hooks/use-push-action.ts | 32 + src/renderer/features/changes/utils/index.ts | 2 + .../features/changes/utils/sync-actions.ts | 21 + .../details-sidebar/details-sidebar.tsx | 21 +- .../sections/changes-widget.tsx | 86 +- .../features/layout/agents-layout.tsx | 42 +- .../onboarding/anthropic-onboarding-page.tsx | 14 +- .../features/sidebar/agents-sidebar.tsx | 15 +- .../lib/hooks/use-file-change-listener.ts | 68 +- .../lib/hooks/use-prefetch-local-chat.ts | 13 + 58 files changed, 3182 insertions(+), 1594 deletions(-) create mode 100644 src/renderer/features/changes/components/commit-input/use-commit-actions.ts create mode 100644 src/renderer/features/changes/hooks/use-push-action.ts create mode 100644 src/renderer/features/changes/utils/sync-actions.ts create mode 100644 src/renderer/lib/hooks/use-prefetch-local-chat.ts diff --git a/CLAUDE.md b/CLAUDE.md index d41a54ea4..724c9cd9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -250,3 +250,25 @@ npm version patch --no-git-tag-version # 0.0.27 → 0.0.28 - Git worktree per chat (isolation) - Claude Code execution in worktree path - Full feature parity with web app + +## Debug Mode + +When debugging runtime issues in the renderer or main process, use the structured debug logging system. This avoids asking the user to manually copy-paste console output. + +**Start the server:** +```bash +bun packages/debug/src/server.ts & +``` + +**Instrument renderer code** (no import needed, fails silently): +```js +fetch('http://localhost:7799/log',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tag:'TAG',msg:'MESSAGE',data:{},ts:Date.now()})}).catch(()=>{}); +``` + +**Read logs:** Read `.debug/logs.ndjson` - each line is a JSON object with `tag`, `msg`, `data`, `ts`. + +**Clear logs:** `curl -X DELETE http://localhost:7799/logs` + +**Workflow:** Hypothesize → instrument → user reproduces → read logs → fix with evidence → verify → remove instrumentation. + +See `packages/debug/INSTRUCTIONS.md` for the full protocol. diff --git a/bun.lock b/bun.lock index db5831282..5cceb9ae3 100644 --- a/bun.lock +++ b/bun.lock @@ -5,11 +5,12 @@ "name": "21st-desktop", "dependencies": { "@ai-sdk/react": "^3.0.14", - "@anthropic-ai/claude-agent-sdk": "0.2.25", + "@anthropic-ai/claude-agent-sdk": "0.2.32", "@git-diff-view/react": "^0.0.35", "@git-diff-view/shiki": "^0.0.36", "@modelcontextprotocol/sdk": "^1.25.3", "@monaco-editor/react": "^4.7.0", + "@pierre/diffs": "^1.0.10", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", @@ -42,11 +43,12 @@ "@xterm/addon-webgl": "^0.19.0", "ai": "^6.0.14", "async-mutex": "^0.5.0", - "better-sqlite3": "^11.8.1", + "better-sqlite3": "^12.6.2", "chokidar": "^5.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", + "diff": "^8.0.3", "drizzle-orm": "^0.45.1", "electron-log": "^5.4.3", "electron-updater": "^6.7.3", @@ -85,7 +87,9 @@ "devDependencies": { "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^4.0.0", + "@electron/rebuild": "^4.0.3", "@types/better-sqlite3": "^7.6.13", + "@types/diff": "^8.0.0", "@types/node": "^20.17.50", "@types/react": "^19.0.7", "@types/react-dom": "^19.0.3", @@ -93,9 +97,8 @@ "@welldone-software/why-did-you-render": "^10.0.1", "autoprefixer": "^10.4.20", "drizzle-kit": "^0.31.8", - "electron": "33.4.5", + "electron": "~39.4.0", "electron-builder": "^25.1.8", - "electron-rebuild": "^3.2.9", "electron-vite": "^3.0.0", "postcss": "^8.5.1", "tailwindcss": "^3.4.17", @@ -119,7 +122,7 @@ "@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.25", "", { "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-YIP3I40+XSkC3zE1Z8KRQY02VA7UfofFamF1cFrLe7FbtCnjpslyDl9coGBh2DAi9xj2yQcKZZf751jEWpB+dQ=="], + "@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=="], "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.8.2", "", {}, "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA=="], @@ -193,7 +196,7 @@ "@electron/osx-sign": ["@electron/osx-sign@1.3.1", "", { "dependencies": { "compare-version": "^0.1.2", "debug": "^4.3.4", "fs-extra": "^10.0.0", "isbinaryfile": "^4.0.8", "minimist": "^1.2.6", "plist": "^3.0.5" }, "bin": { "electron-osx-flat": "bin/electron-osx-flat.js", "electron-osx-sign": "bin/electron-osx-sign.js" } }, "sha512-BAfviURMHpmb1Yb50YbCxnOY0wfwaLXH5KJ4+80zS0gUkzDX3ec23naTlEqKsN+PwYn+a1cCzM7BJ4Wcd3sGzw=="], - "@electron/rebuild": ["@electron/rebuild@3.6.1", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "chalk": "^4.0.0", "debug": "^4.1.1", "detect-libc": "^2.0.1", "fs-extra": "^10.0.0", "got": "^11.7.0", "node-abi": "^3.45.0", "node-api-version": "^0.2.0", "node-gyp": "^9.0.0", "ora": "^5.1.0", "read-binary-file-arch": "^1.0.6", "semver": "^7.3.5", "tar": "^6.0.5", "yargs": "^17.0.1" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-f6596ZHpEq/YskUd8emYvOUne89ij8mQgjYFA5ru25QwbrRO+t1SImofdDv7kKOuWCmVOuU5tvfkbgGxIl3E/w=="], + "@electron/rebuild": ["@electron/rebuild@4.0.3", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "detect-libc": "^2.0.1", "got": "^11.7.0", "graceful-fs": "^4.2.11", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^11.2.0", "ora": "^5.1.0", "read-binary-file-arch": "^1.0.6", "semver": "^7.3.5", "tar": "^7.5.6", "yargs": "^17.0.1" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA=="], "@electron/universal": ["@electron/universal@2.0.1", "", { "dependencies": { "@electron/asar": "^3.2.7", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "dir-compare": "^4.2.0", "fs-extra": "^11.1.1", "minimatch": "^9.0.3", "plist": "^3.1.0" } }, "sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA=="], @@ -313,6 +316,8 @@ "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -345,7 +350,9 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@npmcli/fs": ["@npmcli/fs@2.1.2", "", { "dependencies": { "@gar/promisify": "^1.1.3", "semver": "^7.3.5" } }, "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ=="], + "@npmcli/agent": ["@npmcli/agent@3.0.0", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^10.0.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q=="], + + "@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="], "@npmcli/move-file": ["@npmcli/move-file@2.0.1", "", { "dependencies": { "mkdirp": "^1.0.4", "rimraf": "^3.0.2" } }, "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ=="], @@ -423,6 +430,8 @@ "@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=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@posthog/core": ["@posthog/core@1.13.0", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-knjncrk7qRmssFRbGzBl1Tunt21GRpe0Wv+uVelyL0Rh7PdQUsgguulzXFTps8hA6wPwTU4kq85qnbAJ3eH6Wg=="], @@ -611,9 +620,9 @@ "@sentry/opentelemetry": ["@sentry/opentelemetry@10.34.0", "", { "dependencies": { "@sentry/core": "10.34.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0 || ^2.2.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0 || ^2.2.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0 || ^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0" } }, "sha512-uKuULBOmdVu3bYdD8doMLqKgN0PP3WWtI7Shu11P9PVrhSNT4U9yM9Z6v1aFlQcbrgyg3LynZuXs8lyjt90UbA=="], - "@shikijs/core": ["@shikijs/core@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/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/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-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-oniguruma": ["@shikijs/engine-oniguruma@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1" } }, "sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA=="], @@ -621,6 +630,8 @@ "@shikijs/themes": ["@shikijs/themes@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2" } }, "sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g=="], + "@shikijs/transformers": ["@shikijs/transformers@3.22.0", "", { "dependencies": { "@shikijs/core": "3.22.0", "@shikijs/types": "3.22.0" } }, "sha512-E7eRV7mwDBjueLF6852n2oYeJYxBq3NSsDk+uyruYAXONv4U8holGmIrT+mPRJQ1J1SNOH6L8G19KRzmBawrFw=="], + "@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], @@ -727,6 +738,8 @@ "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + "@types/diff": ["@types/diff@8.0.0", "", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -799,7 +812,7 @@ "@xterm/xterm": ["@xterm/xterm@5.5.0", "", {}, "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="], - "abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], + "abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -871,7 +884,7 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.9.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ=="], - "better-sqlite3": ["better-sqlite3@11.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ=="], + "better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], @@ -907,7 +920,7 @@ "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], - "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=="], + "cacache": ["cacache@19.0.1", "", { "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^12.0.0", "tar": "^7.4.3", "unique-filename": "^4.0.0" } }, "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ=="], "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], @@ -939,7 +952,7 @@ "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], - "chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], "chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="], @@ -1133,6 +1146,8 @@ "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], + "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + "dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="], "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], @@ -1159,7 +1174,7 @@ "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], - "electron": ["electron@33.4.5", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^20.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-rbDc4QOqfMT1uopUG+KcaMKzKgFAXAzN3wNIdgErnB1tUnpgTxwFv1BDN/exCl1vaVWBeM9YtbO5PgbGZeq7xw=="], + "electron": ["electron@39.4.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-NCK/FTAqgG/N+09OXFES6bubamgPZs7TEPIjWZIrbEnm8GzEwxC22ZG6SEPid2DmJnJmBurJ6M0G4EShdSc28Q=="], "electron-builder": ["electron-builder@25.1.8", "", { "dependencies": { "app-builder-lib": "25.1.8", "builder-util": "25.1.7", "builder-util-runtime": "9.2.10", "chalk": "^4.1.2", "dmg-builder": "25.1.8", "fs-extra": "^10.1.0", "is-ci": "^3.0.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-poRgAtUHHOnlzZnc9PK4nzG53xh74wj2Jy7jkTrqZ0MWPoHGh1M2+C//hGeYdA+4K8w4yiVCNYoLXF7ySj2Wig=="], @@ -1169,8 +1184,6 @@ "electron-publish": ["electron-publish@25.1.7", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "25.1.7", "builder-util-runtime": "9.2.10", "chalk": "^4.1.2", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-+jbTkR9m39eDBMP4gfbqglDd6UvBC7RLh5Y0MhFSsc6UkGHj9Vj9TWobxevHYMMqmoujL11ZLjfPpMX+Pt6YEg=="], - "electron-rebuild": ["electron-rebuild@3.2.9", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "chalk": "^4.0.0", "debug": "^4.1.1", "detect-libc": "^2.0.1", "fs-extra": "^10.0.0", "got": "^11.7.0", "lzma-native": "^8.0.5", "node-abi": "^3.0.0", "node-api-version": "^0.1.4", "node-gyp": "^9.0.0", "ora": "^5.1.0", "semver": "^7.3.5", "tar": "^6.0.5", "yargs": "^17.0.1" }, "bin": { "electron-rebuild": "lib/src/cli.js" } }, "sha512-FkEZNFViUem3P0RLYbZkUjC8LUFIK+wKq09GHoOITSJjfDAVQv964hwaNseTTWt58sITQX3/5fHNYcTefqaCWw=="], - "electron-to-chromium": ["electron-to-chromium@1.5.277", "", {}, "sha512-wKXFZw4erWmmOz5N/grBoJ2XrNJGDFMu2+W5ACHza5rHtvsqrK4gb6rnLC7XxKB9WlJ+RmyQatuEXmtm86xbnw=="], "electron-updater": ["electron-updater@6.7.3", "", { "dependencies": { "builder-util-runtime": "9.5.1", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-EgkT8Z9noqXKbwc3u5FkJA+r48jwZ5DTUiOkJMOTEEH//n5Am6wfQGz7nvSFEA2oIAMv9jRzn5JKTyWeSKOPgg=="], @@ -1309,7 +1322,7 @@ "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], - "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=="], + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -1447,7 +1460,7 @@ "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="], "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], @@ -1531,13 +1544,13 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "lucide-react": ["lucide-react@0.468.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA=="], + "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], - "lzma-native": ["lzma-native@8.0.6", "", { "dependencies": { "node-addon-api": "^3.1.0", "node-gyp-build": "^4.2.1", "readable-stream": "^3.6.0" }, "bin": { "lzmajs": "bin/lzmajs" } }, "sha512-09xfg67mkL2Lz20PrrDeNYZxzeW7ADtpYFbwSQh9U8+76RIzx5QsJBMy8qikv3hbUPfpy6hqwxt6FcGK81g9AA=="], + "lucide-react": ["lucide-react@0.468.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "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=="], + "make-fetch-happen": ["make-fetch-happen@14.0.3", "", { "dependencies": { "@npmcli/agent": "^3.0.0", "cacache": "^19.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^4.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "ssri": "^12.0.0" } }, "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], @@ -1659,11 +1672,11 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], - "minipass-collect": ["minipass-collect@1.0.2", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA=="], + "minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="], - "minipass-fetch": ["minipass-fetch@2.1.2", "", { "dependencies": { "minipass": "^3.1.6", "minipass-sized": "^1.0.3", "minizlib": "^2.1.2" }, "optionalDependencies": { "encoding": "^0.1.13" } }, "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA=="], + "minipass-fetch": ["minipass-fetch@4.0.1", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", "minizlib": "^3.0.1" }, "optionalDependencies": { "encoding": "^0.1.13" } }, "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ=="], "minipass-flush": ["minipass-flush@1.0.5", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw=="], @@ -1671,7 +1684,7 @@ "minipass-sized": ["minipass-sized@1.0.3", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="], - "minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], @@ -1697,25 +1710,23 @@ "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], - "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], - "node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="], + "node-abi": ["node-abi@4.26.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-8QwIZqikRvDIkXS2S93LjzhsSPJuIbfaMETWH+Bx8oOT9Sa9UsUtBFQlc3gBNd1+QINjaTloitXr1W3dQLi9Iw=="], "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], - "node-api-version": ["node-api-version@0.1.4", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-KGXihXdUChwJAOHO53bv9/vXcLmdUsZ6jIptbvYvkpKfth+r7jw44JkVxQFA3kX5nQjzjmGu1uAu/xNNLNlI5g=="], + "node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="], - "node-gyp": ["node-gyp@9.4.1", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^7.1.4", "graceful-fs": "^4.2.6", "make-fetch-happen": "^10.0.3", "nopt": "^6.0.0", "npmlog": "^6.0.0", "rimraf": "^3.0.2", "semver": "^7.3.5", "tar": "^6.1.2", "which": "^2.0.2" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ=="], - - "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "node-gyp": ["node-gyp@11.5.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "make-fetch-happen": "^14.0.3", "nopt": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "tar": "^7.4.3", "tinyglobby": "^0.2.12", "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ=="], "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], - "nopt": ["nopt@6.0.0", "", { "dependencies": { "abbrev": "^1.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g=="], + "nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], @@ -1739,7 +1750,7 @@ "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], - "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=="], + "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=="], "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=="], @@ -1747,7 +1758,7 @@ "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], - "p-map": ["p-map@4.0.0", "", { "dependencies": { "aggregate-error": "^3.0.0" } }, "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ=="], + "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], @@ -1833,6 +1844,8 @@ "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + "proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], @@ -1895,9 +1908,9 @@ "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], - "regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="], + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], - "regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="], + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], @@ -2013,7 +2026,7 @@ "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], - "socks-proxy-agent": ["socks-proxy-agent@7.0.0", "", { "dependencies": { "agent-base": "^6.0.2", "debug": "^4.3.3", "socks": "^2.6.2" } }, "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww=="], + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], "sonner": ["sonner@1.7.4", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw=="], @@ -2027,7 +2040,7 @@ "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], - "ssri": ["ssri@9.0.1", "", { "dependencies": { "minipass": "^3.1.1" } }, "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q=="], + "ssri": ["ssri@12.0.0", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ=="], "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], @@ -2077,7 +2090,7 @@ "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="], - "tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], + "tar": ["tar@7.5.7", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ=="], "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], @@ -2133,11 +2146,11 @@ "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], - "unique-filename": ["unique-filename@2.0.1", "", { "dependencies": { "unique-slug": "^3.0.0" } }, "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A=="], + "unique-filename": ["unique-filename@4.0.0", "", { "dependencies": { "unique-slug": "^5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="], "unique-names-generator": ["unique-names-generator@4.7.1", "", {}, "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow=="], - "unique-slug": ["unique-slug@3.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w=="], + "unique-slug": ["unique-slug@5.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg=="], "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], @@ -2199,7 +2212,7 @@ "web-vitals": ["web-vitals@4.2.4", "", {}, "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], "wide-align": ["wide-align@1.1.5", "", { "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } }, "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg=="], @@ -2217,7 +2230,7 @@ "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -2249,6 +2262,8 @@ "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], + "@electron/asar/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=="], + "@electron/asar/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], @@ -2259,8 +2274,6 @@ "@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="], - "@electron/rebuild/node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="], - "@electron/universal/fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], "@electron/universal/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], @@ -2277,6 +2290,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=="], + "@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=="], "@opentelemetry/instrumentation-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=="], @@ -2297,6 +2312,8 @@ "@opentelemetry/sdk-metrics/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], + "@pierre/diffs/shiki": ["shiki@3.21.0", "", { "dependencies": { "@shikijs/core": "3.21.0", "@shikijs/engine-javascript": "3.21.0", "@shikijs/engine-oniguruma": "3.21.0", "@shikijs/langs": "3.21.0", "@shikijs/themes": "3.21.0", "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w=="], + "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -2321,29 +2338,39 @@ "@sentry/node/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "@tailwindcss/typography/postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], + "@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=="], - "accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "@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=="], + + "@tailwindcss/typography/postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], "ajv-keywords/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "app-builder-lib/@electron/rebuild": ["@electron/rebuild@3.6.1", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "chalk": "^4.0.0", "debug": "^4.1.1", "detect-libc": "^2.0.1", "fs-extra": "^10.0.0", "got": "^11.7.0", "node-abi": "^3.45.0", "node-api-version": "^0.2.0", "node-gyp": "^9.0.0", "ora": "^5.1.0", "read-binary-file-arch": "^1.0.6", "semver": "^7.3.5", "tar": "^6.0.5", "yargs": "^17.0.1" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-f6596ZHpEq/YskUd8emYvOUne89ij8mQgjYFA5ru25QwbrRO+t1SImofdDv7kKOuWCmVOuU5tvfkbgGxIl3E/w=="], + + "app-builder-lib/tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], + + "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=="], + "archiver-utils/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "cacache/glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^5.0.1", "once": "^1.3.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="], + "cacache/fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], - "cacache/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], - - "cacache/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "chevrotain/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], - "config-file-ts/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], @@ -2357,6 +2384,8 @@ "dmg-license/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "electron/@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="], + "electron-updater/builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -2369,7 +2398,7 @@ "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], @@ -2383,32 +2412,16 @@ "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "lzma-native/node-addon-api": ["node-addon-api@3.2.1", "", {}, "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A=="], - - "make-fetch-happen/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], - - "make-fetch-happen/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], - - "make-fetch-happen/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], - - "make-fetch-happen/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "minipass-collect/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "minipass-fetch/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "monaco-editor/dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="], "monaco-editor/marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="], @@ -2417,17 +2430,19 @@ "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "path-scurry/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "prebuild-install/node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="], "raw-body/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "readdir-glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], + "rimraf/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=="], + "roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], - "socks-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "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=="], - "ssri/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "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=="], @@ -2491,10 +2506,6 @@ "@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=="], @@ -2509,17 +2520,35 @@ "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "@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=="], + + "@pierre/diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0" } }, "sha512-BAE4cr9EDiZyYzwIHEk7JTBJ9CzlPuM4PchfcA5ao1dWXb25nv6hYsoDiBq2aZK9E3dlt3WB78uI96UESD+8Mw=="], + + "@pierre/diffs/shiki/@shikijs/types": ["@shikijs/types@3.21.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA=="], + "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - "archiver-utils/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "app-builder-lib/@electron/rebuild/node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="], - "archiver-utils/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "app-builder-lib/@electron/rebuild/node-gyp": ["node-gyp@9.4.1", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^7.1.4", "graceful-fs": "^4.2.6", "make-fetch-happen": "^10.0.3", "nopt": "^6.0.0", "npmlog": "^6.0.0", "rimraf": "^3.0.2", "semver": "^7.3.5", "tar": "^6.1.2", "which": "^2.0.2" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ=="], + + "app-builder-lib/tar/chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], - "cacache/glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], + "app-builder-lib/tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], - "config-file-ts/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "app-builder-lib/tar/minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], - "config-file-ts/glob/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "app-builder-lib/tar/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "archiver-utils/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "archiver-utils/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "archiver-utils/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], @@ -2531,28 +2560,108 @@ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "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=="], + "fs-minipass/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], - "make-fetch-happen/http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - "make-fetch-happen/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "minipass-sized/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "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=="], - "@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=="], + "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=="], + + "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=="], + + "app-builder-lib/@electron/rebuild/node-gyp/nopt": ["nopt@6.0.0", "", { "dependencies": { "abbrev": "^1.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g=="], + + "app-builder-lib/@electron/rebuild/node-gyp/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "app-builder-lib/tar/minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "archiver-utils/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=="], + + "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=="], - "@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=="], + "zip-stream/archiver-utils/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "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=="], + + "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], + + "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + + "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + + "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen/minipass-collect": ["minipass-collect@1.0.2", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA=="], + + "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen/minipass-fetch": ["minipass-fetch@2.1.2", "", { "dependencies": { "minipass": "^3.1.6", "minipass-sized": "^1.0.3", "minizlib": "^2.1.2" }, "optionalDependencies": { "encoding": "^0.1.13" } }, "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA=="], + + "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen/negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], + + "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen/socks-proxy-agent": ["socks-proxy-agent@7.0.0", "", { "dependencies": { "agent-base": "^6.0.2", "debug": "^4.3.3", "socks": "^2.6.2" } }, "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww=="], + + "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen/ssri": ["ssri@9.0.1", "", { "dependencies": { "minipass": "^3.1.1" } }, "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q=="], + + "app-builder-lib/@electron/rebuild/node-gyp/nopt/abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], + + "app-builder-lib/@electron/rebuild/node-gyp/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "zip-stream/archiver-utils/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=="], + + "app-builder-lib/@electron/rebuild/node-gyp/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=="], + + "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen/cacache/@npmcli/fs": ["@npmcli/fs@2.1.2", "", { "dependencies": { "@gar/promisify": "^1.1.3", "semver": "^7.3.5" } }, "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ=="], + + "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen/cacache/chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], + + "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^5.0.1", "once": "^1.3.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="], + + "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen/cacache/p-map": ["p-map@4.0.0", "", { "dependencies": { "aggregate-error": "^3.0.0" } }, "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ=="], + + "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen/cacache/unique-filename": ["unique-filename@2.0.1", "", { "dependencies": { "unique-slug": "^3.0.0" } }, "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A=="], + + "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen/http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + + "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + + "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen/minipass-fetch/minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], + + "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen/socks-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + + "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], + + "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen/cacache/unique-filename/unique-slug": ["unique-slug@3.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w=="], - "@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=="], + "app-builder-lib/@electron/rebuild/node-gyp/make-fetch-happen/minipass-fetch/minizlib/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], } } diff --git a/bun.lockb b/bun.lockb index 6601c57c89350cbf3ed855a809ebd553dfbc7cfc..5afc379f19a11a70457a8557244dc5a1dff1148c 100755 GIT binary patch delta 178 zcmV;j08RhBy&b*19gr>{yS9}iLKhF(-4~~bo>@GAyK)Ds(wAx_$`@bH4@FU9fljqf z0SXEMAd`VgCzHTR2#1IY0k?<>0=2n7@C$59AI$bVUD7C0)5of7wPLO0#JdDC(k*`J zl2`X~#0fr{_u>4xy2l*i@AlVEGCjtCfljqf z0SXEM8IyrZCzHTR2#1IY0k?<>0=2n7V1xG~jXqr|0^cz;XTp&|jW$#c)P9G>YGHM7 zTFZN}TeK | null = null const DELIMITER = "_CLAUDE_ENV_DELIMITER_" // Keys to strip (prevent interference from unrelated providers) -// NOTE: We intentionally keep ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL +// NOTE: We intentionally keep ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL in production // so users can use their existing Claude Code CLI configuration (API proxy, etc.) // Based on PR #29 by @sa4hnd -const STRIPPED_ENV_KEYS = [ +const STRIPPED_ENV_KEYS_BASE = [ "OPENAI_API_KEY", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", ] +// In dev mode, also strip ANTHROPIC_API_KEY so OAuth token is used instead +// This allows devs to test OAuth flow without unsetting their shell env +// Added by Sergey Bunas for dev purposes +const STRIPPED_ENV_KEYS = !app.isPackaged + ? [...STRIPPED_ENV_KEYS_BASE, "ANTHROPIC_API_KEY"] + : STRIPPED_ENV_KEYS_BASE + // Cache the bundled binary path (only compute once) let cachedBinaryPath: string | null = null let binaryPathComputed = false @@ -45,14 +52,12 @@ export function getBundledClaudeBinaryPath(): string { const currentPlatform = process.platform const arch = process.arch - // Only log verbose info on first call - if (process.env.DEBUG_CLAUDE_BINARY) { - console.log("[claude-binary] ========== BUNDLED BINARY PATH ==========") - console.log("[claude-binary] isDev:", isDev) - console.log("[claude-binary] platform:", currentPlatform) - console.log("[claude-binary] arch:", arch) - console.log("[claude-binary] appPath:", app.getAppPath()) - } + // Always log on first call to help debug + console.log("[claude-binary] ========== BUNDLED BINARY DEBUG ==========") + console.log("[claude-binary] isDev:", isDev) + console.log("[claude-binary] platform:", currentPlatform) + console.log("[claude-binary] arch:", arch) + console.log("[claude-binary] appPath:", app.getAppPath()) // In dev: apps/desktop/resources/bin/{platform}-{arch}/claude // In production: {resourcesPath}/bin/claude @@ -64,21 +69,16 @@ export function getBundledClaudeBinaryPath(): string { ) : path.join(process.resourcesPath, "bin") - if (process.env.DEBUG_CLAUDE_BINARY) { - console.log("[claude-binary] resourcesPath:", resourcesPath) - } + console.log("[claude-binary] resourcesPath:", resourcesPath) const binaryName = currentPlatform === "win32" ? "claude.exe" : "claude" const binaryPath = path.join(resourcesPath, binaryName) - if (process.env.DEBUG_CLAUDE_BINARY) { - console.log("[claude-binary] binaryPath:", binaryPath) - } + console.log("[claude-binary] binaryPath:", binaryPath) // Check if binary exists const exists = fs.existsSync(binaryPath) - // Always log if binary doesn't exist (critical error) if (!exists) { console.error( "[claude-binary] WARNING: Binary not found at path:", @@ -87,15 +87,15 @@ export function getBundledClaudeBinaryPath(): string { console.error( "[claude-binary] Run 'bun run claude:download' to download it" ) - } else if (process.env.DEBUG_CLAUDE_BINARY) { + } else { const stats = fs.statSync(binaryPath) const sizeMB = (stats.size / 1024 / 1024).toFixed(1) const isExecutable = (stats.mode & fs.constants.X_OK) !== 0 console.log("[claude-binary] exists:", exists) console.log("[claude-binary] size:", sizeMB, "MB") console.log("[claude-binary] isExecutable:", isExecutable) - console.log("[claude-binary] ===========================================") } + console.log("[claude-binary] ============================================") // Cache the result cachedBinaryPath = binaryPath @@ -237,6 +237,16 @@ export function buildClaudeEnv(options?: { env.PATH = shellPath } + // 2b. Strip sensitive keys again (process.env may have re-added them) + // This ensures ANTHROPIC_API_KEY from dev's shell doesn't override OAuth in dev mode + // Added by Sergey Bunas for dev purposes + for (const key of STRIPPED_ENV_KEYS) { + if (key in env) { + console.log(`[claude-env] Stripped ${key} from final environment`) + delete env[key] + } + } + // 3. Ensure critical vars are present using platform provider const platformEnv = platform.buildEnvironment() if (!env.HOME) env.HOME = platformEnv.HOME diff --git a/src/main/lib/claude/transform.ts b/src/main/lib/claude/transform.ts index 93e0a3537..3d4362243 100644 --- a/src/main/lib/claude/transform.ts +++ b/src/main/lib/claude/transform.ts @@ -1,7 +1,6 @@ -import type { MCPServer, MCPServerStatus, MessageMetadata, UIMessageChunk } from "./types" +import type { MCPServer, MCPServerStatus, MessageMetadata, UIMessageChunk } from "./types"; -export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUsingOllama?: boolean }) { - const emitSdkMessageUuid = options?.emitSdkMessageUuid === true +export function createTransformer(options?: { isUsingOllama?: boolean }) { const isUsingOllama = options?.isUsingOllama === true let textId: string | null = null let textStarted = false @@ -35,6 +34,7 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs let currentThinkingId: string | null = null let accumulatedThinking = "" let inThinkingBlock = false // Track if we're currently in a thinking block + let thinkingJsonStarted = false // Track if we've sent the JSON prefix for thinking deltas // Helper to create composite toolCallId: "parentId:childId" or just "childId" const makeCompositeId = (originalId: string, parentId: string | null): string => { @@ -79,6 +79,7 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs toolCallId: currentToolCallId, toolName: currentToolName || "unknown", input: parsedInput, + providerMetadata: { custom: { startedAt: Date.now() } }, } currentToolCallId = null currentToolName = null @@ -88,25 +89,6 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs return function* transform(msg: any): Generator { - // Debug: log ALL message types to understand what SDK sends - if (isUsingOllama) { - console.log("[Ollama Transform] MSG:", msg.type, msg.subtype || "", msg.event?.type || "") - if (msg.type === "system") { - console.log("[Ollama Transform] SYSTEM message full:", JSON.stringify(msg, null, 2)) - } - if (msg.type === "stream_event") { - console.log("[Ollama Transform] STREAM_EVENT:", msg.event?.type, "content_block:", msg.event?.content_block?.type) - } - if (msg.type === "assistant") { - console.log("[Ollama Transform] ASSISTANT message, content blocks:", msg.message?.content?.length || 0) - } - } else { - console.log("[transform] MSG:", msg.type, msg.subtype || "", msg.event?.type || "") - if (msg.type === "system") { - console.log("[transform] SYSTEM message:", msg.subtype, msg) - } - } - // Track parent_tool_use_id for nested tools // Only update when explicitly present (don't reset on messages without it) if (msg.parent_tool_use_id !== undefined) { @@ -131,39 +113,19 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs // ===== STREAMING EVENTS (token-by-token) ===== if (msg.type === "stream_event") { const event = msg.event - console.log("[transform] stream_event:", event?.type, "delta:", event?.delta?.type, "content_block_type:", event?.content_block?.type) - // Debug: log full event when content_block_start but no type - if (event?.type === "content_block_start" && !event?.content_block?.type) { - console.log("[transform] WARNING: content_block_start with no type, full event:", JSON.stringify(event)) - } if (!event) return // Text block start if (event.type === "content_block_start" && event.content_block?.type === "text") { - if (isUsingOllama) { - console.log("[Ollama Transform] ✓ TEXT BLOCK START - Model is generating text!") - } else { - console.log("[transform] TEXT BLOCK START") - } yield* endTextBlock() yield* endToolInput() textId = genId() yield { type: "text-start", id: textId } textStarted = true - if (isUsingOllama) { - console.log("[Ollama Transform] textStarted set to TRUE, textId:", textId) - } else { - console.log("[transform] textStarted set to TRUE, textId:", textId) - } } // Text delta if (event.type === "content_block_delta" && event.delta?.type === "text_delta") { - if (isUsingOllama) { - console.log("[Ollama Transform] ✓ TEXT DELTA received, length:", event.delta.text?.length, "preview:", event.delta.text?.slice(0, 50)) - } else { - console.log("[transform] TEXT DELTA, textStarted:", textStarted, "delta:", event.delta.text?.slice(0, 20)) - } if (!textStarted) { yield* endToolInput() textId = genId() @@ -175,18 +137,8 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs // Content block stop if (event.type === "content_block_stop") { - if (isUsingOllama) { - console.log("[Ollama Transform] CONTENT BLOCK STOP, textStarted:", textStarted) - } else { - console.log("[transform] CONTENT BLOCK STOP, textStarted:", textStarted) - } if (textStarted) { yield* endTextBlock() - if (isUsingOllama) { - console.log("[Ollama Transform] Text block ended, textStarted now:", textStarted) - } else { - console.log("[transform] after endTextBlock, textStarted:", textStarted) - } } if (currentToolCallId) { yield* endToolInput() @@ -232,6 +184,7 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs currentThinkingId = `thinking-${Date.now()}` accumulatedThinking = "" inThinkingBlock = true + thinkingJsonStarted = false yield { type: "tool-input-start", toolCallId: currentThinkingId, @@ -242,19 +195,24 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs // Thinking/reasoning streaming - emit as tool-like chunks for UI if (event.delta?.type === "thinking_delta" && currentThinkingId && inThinkingBlock) { const thinkingText = String(event.delta.thinking || "") - - // Accumulate and emit delta accumulatedThinking += thinkingText + + // Emit as JSON fragment so AI SDK's parsePartialJson can parse it incrementally. + // AI SDK accumulates all deltas and runs fixJson() to repair incomplete JSON, + // so we start with '{"text":"' and send JSON-escaped text chunks. + const escaped = JSON.stringify(thinkingText).slice(1, -1) + const prefix = !thinkingJsonStarted ? '{"text":"' : "" + thinkingJsonStarted = true + yield { type: "tool-input-delta", toolCallId: currentThinkingId, - inputTextDelta: thinkingText, + inputTextDelta: prefix + escaped, } } // Thinking complete (content_block_stop while in thinking block) if (event.type === "content_block_stop" && inThinkingBlock && currentThinkingId) { - // Emit the complete thinking tool yield { type: "tool-input-available", toolCallId: currentThinkingId, @@ -268,7 +226,7 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs } // Track as emitted to skip duplicate from assistant message emittedToolIds.add(currentThinkingId) - emittedToolIds.add("thinking-streamed") // Flag to skip complete block + emittedToolIds.add("thinking-streamed") currentThinkingId = null accumulatedThinking = "" inThinkingBlock = false @@ -282,16 +240,15 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs // Handle thinking blocks from Extended Thinking // Skip if already emitted via streaming (thinking_delta) if (block.type === "thinking" && block.thinking) { - // Check if we already streamed this thinking block - // We compare by checking if accumulated thinking matches + // Check if we already streamed OR are currently streaming this thinking block + // The assistant message can arrive BEFORE content_block_stop, so we also check inThinkingBlock const wasStreamed = emittedToolIds.has("thinking-streamed") - - if (wasStreamed) { + const isCurrentlyStreaming = inThinkingBlock + + if (wasStreamed || isCurrentlyStreaming) { continue } - - // Emit as tool-input-available with special "Thinking" tool name - // This allows the UI to render it like other tools + const thinkingId = genId() yield { type: "tool-input-available", @@ -308,24 +265,18 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs } if (block.type === "text") { - console.log("[transform] ASSISTANT TEXT block, textStarted:", textStarted, "text length:", block.text?.length) yield* endToolInput() // Only emit text if we're NOT already streaming (textStarted = false) // When includePartialMessages is true, text comes via stream_event if (!textStarted) { - console.log("[transform] EMITTING assistant text (textStarted was false)") textId = genId() yield { type: "text-start", id: textId } yield { type: "text-delta", id: textId, delta: block.text } yield { type: "text-end", id: textId } - // Track the last text ID for final response marking lastTextId = textId textId = null - } else { - console.log("[transform] SKIPPING assistant text (textStarted is true)") } - // If textStarted is true, we're mid-stream - skip this duplicate } if (block.type === "tool_use") { @@ -334,7 +285,6 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs // Skip if already emitted via streaming if (emittedToolIds.has(block.id)) { - console.log("[transform] SKIPPING duplicate tool_use (already emitted via streaming):", block.id) continue } @@ -350,6 +300,7 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs toolCallId: compositeId, toolName: block.name, input: block.input, + providerMetadata: { custom: { startedAt: Date.now() } }, } } } @@ -357,18 +308,6 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs // ===== USER MESSAGE (tool results) ===== if (msg.type === "user" && msg.message?.content && Array.isArray(msg.message.content)) { - // DEBUG: Log the message structure to understand tool_use_result - console.log("[Transform DEBUG] User message:", { - tool_use_result: msg.tool_use_result, - tool_use_result_type: typeof msg.tool_use_result, - content_length: msg.message.content.length, - blocks: msg.message.content.map((b: any) => ({ - type: b.type, - tool_use_id: b.tool_use_id, - content_preview: typeof b.content === 'string' ? b.content.slice(0, 100) : typeof b.content, - })), - }) - for (const block of msg.message.content) { if (block.type === "tool_result") { // Lookup composite ID from mapping, fallback to original @@ -396,14 +335,6 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs } output = output || block.content - console.log("[Transform DEBUG] Tool output:", { - tool_use_id: block.tool_use_id, - compositeId, - output_type: typeof output, - output_keys: output && typeof output === 'object' ? Object.keys(output) : null, - numFiles: output?.numFiles, - }) - yield { type: "tool-output-available", toolCallId: compositeId, @@ -418,12 +349,6 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs if (msg.type === "system") { // Session init - extract MCP servers, plugins, tools if (msg.subtype === "init") { - console.log("[MCP Transform] Received SDK init message:", { - tools: msg.tools?.length, - mcp_servers: msg.mcp_servers, - plugins: msg.plugins, - skills: msg.skills?.length, - }) // Map MCP servers with validated status type and additional info const mcpServers: MCPServer[] = (msg.mcp_servers || []).map( (s: { name: string; status: string; serverInfo?: { name: string; version: string; icons?: { src: string; mimeType?: string; sizes?: string[]; theme?: "light" | "dark" }[] }; error?: string }) => ({ @@ -446,23 +371,35 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs } } - // Compacting status - show as a tool + // Compacting status - expose as a tool so it becomes a UI message part if (msg.subtype === "status" && msg.status === "compacting") { // Create unique ID and save for matching with boundary event lastCompactId = `compact-${Date.now()}-${compactCounter++}` yield { - type: "system-Compact", + type: "tool-input-available", toolCallId: lastCompactId, - state: "input-streaming", + toolName: "Compact", + input: { status: "compacting" }, } } // Compact boundary - mark the compacting tool as complete - if (msg.subtype === "compact_boundary" && lastCompactId) { + if (msg.subtype === "compact_boundary") { + let compactId = lastCompactId + // If we didn't receive a compacting status, create a tool invocation now + if (!compactId) { + compactId = `compact-${Date.now()}-${compactCounter++}` + yield { + type: "tool-input-available", + toolCallId: compactId, + toolName: "Compact", + input: { status: "compacting" }, + } + } yield { - type: "system-Compact", - toolCallId: lastCompactId, - state: "output-available", + type: "tool-output-available", + toolCallId: compactId, + output: { status: "compacted" }, } lastCompactId = null // Clear for next compacting cycle } @@ -492,12 +429,37 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs ) : undefined + // Fallback: if SDK didn't populate msg.usage, derive totals from modelUsage + const fallbackInputTokens = msg.modelUsage + ? Object.values(msg.modelUsage).reduce( + (sum: number, usage: any) => sum + (usage?.inputTokens || 0), + 0, + ) + : undefined + const fallbackOutputTokens = msg.modelUsage + ? Object.values(msg.modelUsage).reduce( + (sum: number, usage: any) => sum + (usage?.outputTokens || 0), + 0, + ) + : undefined + + const resolvedInputTokens = + inputTokens == null || (inputTokens === 0 && (fallbackInputTokens || 0) > 0) + ? fallbackInputTokens + : inputTokens + const resolvedOutputTokens = + outputTokens == null || (outputTokens === 0 && (fallbackOutputTokens || 0) > 0) + ? fallbackOutputTokens + : outputTokens + const metadata: MessageMetadata = { sessionId: msg.session_id, - sdkMessageUuid: emitSdkMessageUuid ? msg.uuid : undefined, - inputTokens, - outputTokens, - totalTokens: inputTokens && outputTokens ? inputTokens + outputTokens : undefined, + inputTokens: resolvedInputTokens, + outputTokens: resolvedOutputTokens, + totalTokens: + resolvedInputTokens != null && resolvedOutputTokens != null + ? resolvedInputTokens + resolvedOutputTokens + : undefined, totalCostUsd: msg.total_cost_usd, durationMs: startTime ? Date.now() - startTime : undefined, resultSubtype: msg.subtype || "success", diff --git a/src/main/lib/claude/types.ts b/src/main/lib/claude/types.ts index 89ab312cf..0b6d27bb9 100644 --- a/src/main/lib/claude/types.ts +++ b/src/main/lib/claude/types.ts @@ -26,6 +26,7 @@ export type UIMessageChunk = // Error & metadata | { type: "error"; errorText: string } | { type: "auth-error"; errorText: string } + | { type: "retry-notification"; message: string } | { type: "ask-user-question" toolUseId: string @@ -38,12 +39,6 @@ export type UIMessageChunk = } | { type: "ask-user-question-timeout"; toolUseId: string } | { type: "message-metadata"; messageMetadata: MessageMetadata } - // System tools (rendered like regular tools) - | { - type: "system-Compact" - toolCallId: string - state: "input-streaming" | "output-available" - } // Session initialization (MCP servers, plugins, tools) | { type: "session-init" diff --git a/src/main/lib/git/worktree-config.ts b/src/main/lib/git/worktree-config.ts index 30529a26b..40112cc2b 100644 --- a/src/main/lib/git/worktree-config.ts +++ b/src/main/lib/git/worktree-config.ts @@ -233,7 +233,7 @@ export async function executeWorktreeSetup( console.log(`[worktree-setup] ✓ ${cmd}`) } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) - result.errors.push(`Command failed: ${cmd}\n${errorMsg}`) + result.errors.push(errorMsg) result.output.push(`[error] ${errorMsg}`) console.error(`[worktree-setup] ✗ ${cmd}: ${errorMsg}`) // Continue with next command, don't fail entirely diff --git a/src/main/lib/git/worktree.ts b/src/main/lib/git/worktree.ts index c8963c2a7..298c3de28 100644 --- a/src/main/lib/git/worktree.ts +++ b/src/main/lib/git/worktree.ts @@ -12,6 +12,7 @@ import { } from "unique-names-generator"; import { checkGitLfsAvailable, getShellEnvironment } from "./shell-env"; import { executeWorktreeSetup } from "./worktree-config"; +import type { WorktreeSetupResult } from "./worktree-config"; import { generateWorktreeFolderName } from "./worktree-naming"; const execFileAsync = promisify(execFile); @@ -895,6 +896,10 @@ export interface WorktreeResult { error?: string; } +export interface CreateWorktreeForChatOptions { + onSetupComplete?: (result: WorktreeSetupResult) => void; +} + /** * Create a git worktree for a chat (wrapper for chats.ts) * @param projectPath - Path to the main repository @@ -908,6 +913,7 @@ export async function createWorktreeForChat( chatId: string, selectedBaseBranch?: string, branchType?: "local" | "remote", + options?: CreateWorktreeForChatOptions, ): Promise { try { const git = simpleGit(projectPath); @@ -937,6 +943,7 @@ export async function createWorktreeForChat( // This allows the user to start chatting immediately while deps install executeWorktreeSetup(worktreePath, projectPath) .then((setupResult) => { + options?.onSetupComplete?.(setupResult); if (!setupResult.success) { console.warn(`[worktree] Setup completed with errors: ${setupResult.errors.join(", ")}`); } else { @@ -944,7 +951,14 @@ export async function createWorktreeForChat( } }) .catch((setupError) => { - console.warn(`[worktree] Setup failed: ${setupError}`); + const errorMsg = setupError instanceof Error ? setupError.message : String(setupError); + options?.onSetupComplete?.({ + success: false, + commandsRun: 0, + output: [`[error] ${errorMsg}`], + errors: [errorMsg], + }); + console.warn(`[worktree] Setup failed: ${errorMsg}`); }); return { success: true, worktreePath, branch, baseBranch }; diff --git a/src/main/lib/trpc/routers/chats.ts b/src/main/lib/trpc/routers/chats.ts index c21c793e8..184545fd0 100644 --- a/src/main/lib/trpc/routers/chats.ts +++ b/src/main/lib/trpc/routers/chats.ts @@ -1,4 +1,5 @@ import { and, desc, eq, inArray, isNotNull, isNull } from "drizzle-orm" +import { BrowserWindow } from "electron" import * as fs from "fs/promises" import * as path from "path" import simpleGit from "simple-git" @@ -18,6 +19,7 @@ import { removeWorktree, sanitizeProjectName, } from "../../git" +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" @@ -26,6 +28,35 @@ import { checkInternetConnection, checkOllamaStatus } from "../../ollama" import { terminalManager } from "../../terminal/manager" import { publicProcedure, router } from "../index" +type WorktreeSetupFailurePayload = { + kind: "create-failed" | "setup-failed" + message: string + projectId: string +} + +function sendWorktreeSetupFailure( + windowId: number | null, + payload: WorktreeSetupFailurePayload, +): void { + const targets: BrowserWindow[] = [] + + if (windowId !== null) { + const window = BrowserWindow.fromId(windowId) + if (window && !window.isDestroyed()) { + targets.push(window) + } + } + + if (targets.length === 0) { + targets.push(...BrowserWindow.getAllWindows()) + } + + for (const window of targets) { + if (window.isDestroyed()) continue + window.webContents.send("worktree:setup-failed", payload) + } +} + // Fallback to truncated user message if AI generation fails function getFallbackName(userMessage: string): string { const trimmed = userMessage.trim() @@ -58,7 +89,7 @@ async function generateChatNameWithOllama( return null } - const prompt = `Generate a very short (2-5 words) title for a coding chat that starts with this message. Only output the title, nothing else. No quotes, no explanations. + const prompt = `Generate a very short (2-5 words) title for a coding chat that starts with this message. The title MUST be in the same language as the user's message. Only output the title, nothing else. No quotes, no explanations. User message: "${userMessage.slice(0, 500)}" @@ -280,9 +311,10 @@ export const chatsRouter = router({ mode: z.enum(["plan", "agent"]).default("agent"), }), ) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { console.log("[chats.create] called with:", input) const db = getDatabase() + const requestingWindowId = ctx.getWindow?.()?.id ?? null // Get project path const project = db @@ -355,6 +387,19 @@ export const chatsRouter = router({ chat.id, input.baseBranch, input.branchType, + { + onSetupComplete: (setupResult: WorktreeSetupResult) => { + if (setupResult.success) return + const message = + setupResult.errors[0] || + "Worktree setup failed. Check your setup commands." + sendWorktreeSetupFailure(requestingWindowId, { + kind: "setup-failed", + message, + projectId: project.id, + }) + }, + }, ) console.log("[chats.create] worktree result:", result) @@ -374,6 +419,11 @@ export const chatsRouter = router({ } } else { console.warn(`[Worktree] Failed: ${result.error}`) + sendWorktreeSetupFailure(requestingWindowId, { + kind: "create-failed", + message: result.error || "Worktree creation failed.", + projectId: project.id, + }) // Fallback to project path db.update(chats) .set({ worktreePath: project.path }) @@ -1277,7 +1327,6 @@ export const chatsRouter = router({ .set({ prUrl: input.prUrl, prNumber: input.prNumber, - updatedAt: new Date(), }) .where(eq(chats.id, input.chatId)) .returning() diff --git a/src/main/lib/trpc/routers/claude.ts b/src/main/lib/trpc/routers/claude.ts index cf6d84df3..f3535d24a 100644 --- a/src/main/lib/trpc/routers/claude.ts +++ b/src/main/lib/trpc/routers/claude.ts @@ -15,15 +15,34 @@ import { logRawClaudeMessage, type UIMessageChunk, } from "../../claude" -import { getProjectMcpServers, GLOBAL_MCP_PATH, readClaudeConfig, removeMcpServerConfig, resolveProjectPathFromWorktree, updateClaudeConfigAtomic, updateMcpServerConfig, writeClaudeConfig, type McpServerConfig } from "../../claude-config" -import { discoverPluginMcpServers } from "../../plugins" -import { getEnabledPlugins, getApprovedPluginMcpServers } from "./claude-settings" +import { + getProjectMcpServers, + GLOBAL_MCP_PATH, + readClaudeConfig, + removeMcpServerConfig, + resolveProjectPathFromWorktree, + updateMcpServerConfig, + writeClaudeConfig, + type McpServerConfig, +} from "../../claude-config" import { chats, claudeCodeCredentials, getDatabase, subChats } from "../../db" import { createRollbackStash } from "../../git/stash" -import { ensureMcpTokensFresh, fetchMcpTools, fetchMcpToolsStdio, getMcpAuthStatus, startMcpOAuth, type McpToolInfo } from "../../mcp-auth" +import { + ensureMcpTokensFresh, + fetchMcpTools, + fetchMcpToolsStdio, + getMcpAuthStatus, + startMcpOAuth, + type McpToolInfo, +} from "../../mcp-auth" import { fetchOAuthMetadata, getMcpBaseUrl } from "../../oauth" +import { discoverPluginMcpServers } from "../../plugins" import { publicProcedure, router } from "../index" import { buildAgentsOption } from "./agent-utils" +import { + getApprovedPluginMcpServers, + getEnabledPlugins, +} from "./claude-settings" /** * Parse @[agent:name], @[skill:name], and @[tool:servername] mentions from prompt text @@ -70,7 +89,10 @@ function parseMentions(prompt: string): { break case "tool": // Validate: server name (alphanumeric, underscore, hyphen) or full tool id (mcp__server__tool) - if (/^[a-zA-Z0-9_-]+$/.test(name) || /^mcp__[a-zA-Z0-9_-]+__[a-zA-Z0-9_-]+$/.test(name)) { + if ( + /^[a-zA-Z0-9_-]+$/.test(name) || + /^mcp__[a-zA-Z0-9_-]+__[a-zA-Z0-9_-]+$/.test(name) + ) { toolMentions.push(name) } break @@ -110,7 +132,14 @@ function parseMentions(prompt: string): { cleanedPrompt = `${toolHints}\n\n${cleanedPrompt}` } - return { cleanedPrompt, agentMentions, skillMentions, fileMentions, folderMentions, toolMentions } + return { + cleanedPrompt, + agentMentions, + skillMentions, + fileMentions, + folderMentions, + toolMentions, + } } /** @@ -137,20 +166,46 @@ function getClaudeCodeToken(): string | null { .where(eq(claudeCodeCredentials.id, "default")) .get() + console.log("[claude-auth] ========== CLAUDE CODE AUTH DEBUG ==========") + console.log( + "[claude-auth] Credential record:", + cred + ? { + id: cred.id, + hasOauthToken: !!cred.oauthToken, + encryptedTokenLength: cred.oauthToken?.length ?? 0, + connectedAt: cred.connectedAt, + userId: cred.userId, + } + : null, + ) + if (!cred?.oauthToken) { - console.log("[claude] No Claude Code credentials found") + console.log("[claude-auth] No Claude Code credentials found") + console.log("[claude-auth] ============================================") return null } - return decryptToken(cred.oauthToken) + const decrypted = decryptToken(cred.oauthToken) + console.log("[claude-auth] Token decrypted successfully") + console.log( + "[claude-auth] Token preview:", + decrypted.slice(0, 20) + "..." + decrypted.slice(-10), + ) + console.log("[claude-auth] Token total length:", decrypted.length) + console.log("[claude-auth] ============================================") + + return decrypted } catch (error) { - console.error("[claude] Error getting Claude Code token:", error) + console.error("[claude-auth] Error getting Claude Code token:", error) return null } } // Dynamic import for ESM module - CACHED to avoid re-importing on every message -let cachedClaudeQuery: typeof import("@anthropic-ai/claude-agent-sdk").query | null = null +let cachedClaudeQuery: + | typeof import("@anthropic-ai/claude-agent-sdk").query + | null = null const getClaudeQuery = async () => { if (cachedClaudeQuery) { return cachedClaudeQuery @@ -179,10 +234,13 @@ function mcpCacheKey(scope: string | null, serverName: string): string { const symlinksCreated = new Set() // Cache for MCP config (avoid re-reading ~/.claude.json on every message) -const mcpConfigCache = new Map | undefined - mtime: number -}>() +const mcpConfigCache = new Map< + string, + { + config: Record | undefined + mtime: number + } +>() const pendingToolApprovals = new Map< string, @@ -196,10 +254,7 @@ const pendingToolApprovals = new Map< } >() -const PLAN_MODE_BLOCKED_TOOLS = new Set([ - "Bash", - "NotebookEdit", -]) +const PLAN_MODE_BLOCKED_TOOLS = new Set(["Bash", "NotebookEdit"]) const clearPendingApprovals = (message: string, subChatId?: string) => { for (const [toolUseId, pending] of pendingToolApprovals) { @@ -258,7 +313,7 @@ function getServerStatusFromConfig(serverConfig: McpServerConfig): string { } // If HTTP server with explicit authType (oauth/bearer), needs auth - if (serverConfig.url && (["oauth", "bearer"].includes(authType ?? ""))) { + if (serverConfig.url && ["oauth", "bearer"].includes(authType ?? "")) { return "needs-auth" } @@ -267,15 +322,17 @@ function getServerStatusFromConfig(serverConfig: McpServerConfig): string { return "connected" } -const MCP_FETCH_TIMEOUT_MS = 10_000 +const MCP_FETCH_TIMEOUT_MS = 40_000 /** * Fetch tools from an MCP server (HTTP or stdio transport) - * Times out after 10 seconds to prevent slow MCPs from blocking the cache update + * Times out after MCP_FETCH_TIMEOUT_MS seconds to prevent slow MCPs from blocking the cache update */ -async function fetchToolsForServer(serverConfig: McpServerConfig): Promise { +async function fetchToolsForServer( + serverConfig: McpServerConfig, +): Promise { const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error('Timeout')), MCP_FETCH_TIMEOUT_MS) + setTimeout(() => reject(new Error("Timeout")), MCP_FETCH_TIMEOUT_MS), ) const fetchPromise = (async () => { @@ -325,14 +382,19 @@ export async function getAllMcpConfigHandler() { const config = await readClaudeConfig() - const convertServers = async (servers: Record | undefined, scope: string | null) => { + const convertServers = async ( + servers: Record | undefined, + scope: string | null, + ) => { if (!servers) return [] const results = await Promise.all( Object.entries(servers).map(async ([name, serverConfig]) => { const configObj = serverConfig as Record let status = getServerStatusFromConfig(serverConfig) - const headers = serverConfig.headers as Record | undefined + const headers = serverConfig.headers as + | Record + | undefined let tools: McpToolInfo[] = [] let needsAuth = false @@ -357,7 +419,10 @@ export async function getAllMcpConfigHandler() { } catch { // If probe fails, assume no auth needed } - } else if (serverConfig.authType === "oauth" || serverConfig.authType === "bearer") { + } else if ( + serverConfig.authType === "oauth" || + serverConfig.authType === "bearer" + ) { needsAuth = true } @@ -370,7 +435,7 @@ export async function getAllMcpConfigHandler() { } return { name, status, tools, needsAuth, config: configObj } - }) + }), ) return results @@ -381,7 +446,13 @@ export async function getAllMcpConfigHandler() { groupName: string projectPath: string | null promise: Promise<{ - mcpServers: Array<{ name: string; status: string; tools: McpToolInfo[]; needsAuth: boolean; config: Record }> + mcpServers: Array<{ + name: string + status: string + tools: McpToolInfo[] + needsAuth: boolean + config: Record + }> duration: number }> }> = [] @@ -393,132 +464,181 @@ export async function getAllMcpConfigHandler() { projectPath: null, promise: (async () => { const start = Date.now() - const freshServers = await ensureMcpTokensFresh(config.mcpServers!, GLOBAL_MCP_PATH) + const freshServers = await ensureMcpTokensFresh( + config.mcpServers!, + GLOBAL_MCP_PATH, + ) const mcpServers = await convertServers(freshServers, null) // null = global scope return { mcpServers, duration: Date.now() - start } - })() + })(), }) } else { groupTasks.push({ groupName: "Global", projectPath: null, - promise: Promise.resolve({ mcpServers: [], duration: 0 }) + promise: Promise.resolve({ mcpServers: [], duration: 0 }), }) } // Project MCPs if (config.projects) { - for (const [projectPath, projectConfig] of Object.entries(config.projects)) { - if (projectConfig.mcpServers && Object.keys(projectConfig.mcpServers).length > 0) { + for (const [projectPath, projectConfig] of Object.entries( + config.projects, + )) { + if ( + projectConfig.mcpServers && + Object.keys(projectConfig.mcpServers).length > 0 + ) { const groupName = path.basename(projectPath) || projectPath groupTasks.push({ groupName, projectPath, promise: (async () => { const start = Date.now() - const freshServers = await ensureMcpTokensFresh(projectConfig.mcpServers!, projectPath) + const freshServers = await ensureMcpTokensFresh( + projectConfig.mcpServers!, + projectPath, + ) const mcpServers = await convertServers(freshServers, projectPath) // projectPath = scope return { mcpServers, duration: Date.now() - start } - })() + })(), }) } } } // Process all groups in parallel - const results = await Promise.all(groupTasks.map(t => t.promise)) + const results = await Promise.all(groupTasks.map((t) => t.promise)) // Build groups with timing info const groupsWithTiming = groupTasks.map((task, i) => ({ groupName: task.groupName, projectPath: task.projectPath, mcpServers: results[i].mcpServers, - duration: results[i].duration + duration: results[i].duration, })) // Log performance (sorted by duration DESC) const totalDuration = Date.now() - totalStart - const workingCount = [...workingMcpServers.values()].filter(v => v).length - const sortedByDuration = [...groupsWithTiming].sort((a, b) => b.duration - a.duration) + const workingCount = [...workingMcpServers.values()].filter((v) => v).length + const sortedByDuration = [...groupsWithTiming].sort( + (a, b) => b.duration - a.duration, + ) - console.log(`[MCP] Cache updated in ${totalDuration}ms. Working: ${workingCount}/${workingMcpServers.size}`) + console.log( + `[MCP] Cache updated in ${totalDuration}ms. Working: ${workingCount}/${workingMcpServers.size}`, + ) for (const g of sortedByDuration) { if (g.mcpServers.length > 0) { - console.log(`[MCP] ${g.groupName}: ${g.duration}ms (${g.mcpServers.length} servers)`) + console.log( + `[MCP] ${g.groupName}: ${g.duration}ms (${g.mcpServers.length} servers)`, + ) } } // Return groups without timing info - const groups = groupsWithTiming.map(({ groupName, projectPath, mcpServers }) => ({ - groupName, - projectPath, - mcpServers - })) + const groups = groupsWithTiming.map( + ({ groupName, projectPath, mcpServers }) => ({ + groupName, + projectPath, + mcpServers, + }), + ) // Plugin MCPs (from installed plugins) - const [enabledPluginSources, pluginMcpConfigs, approvedServers] = await Promise.all([ - getEnabledPlugins(), - discoverPluginMcpServers(), - getApprovedPluginMcpServers(), - ]) + const [enabledPluginSources, pluginMcpConfigs, approvedServers] = + await Promise.all([ + getEnabledPlugins(), + discoverPluginMcpServers(), + getApprovedPluginMcpServers(), + ]) for (const pluginConfig of pluginMcpConfigs) { // Only show MCP servers from enabled plugins if (!enabledPluginSources.includes(pluginConfig.pluginSource)) continue - const globalServerNames = config.mcpServers ? Object.keys(config.mcpServers) : [] + const globalServerNames = config.mcpServers + ? Object.keys(config.mcpServers) + : [] if (Object.keys(pluginConfig.mcpServers).length > 0) { - const pluginMcpServers = (await Promise.all( - Object.entries(pluginConfig.mcpServers).map(async ([name, serverConfig]) => { - // Skip servers that have been promoted to ~/.claude.json (e.g., after OAuth) - if (globalServerNames.includes(name)) return null - - const configObj = serverConfig as Record - const identifier = `${pluginConfig.pluginSource}:${name}` - const isApproved = approvedServers.includes(identifier) - - if (!isApproved) { - return { name, status: "pending-approval", tools: [] as McpToolInfo[], needsAuth: false, config: configObj, isApproved } - } - - // Try to get status and tools for approved servers - let status = getServerStatusFromConfig(serverConfig) - const headers = serverConfig.headers as Record | undefined - let tools: McpToolInfo[] = [] - let needsAuth = false + const pluginMcpServers = ( + await Promise.all( + Object.entries(pluginConfig.mcpServers).map( + async ([name, serverConfig]) => { + // Skip servers that have been promoted to ~/.claude.json (e.g., after OAuth) + if (globalServerNames.includes(name)) return null + + const configObj = serverConfig as Record + const identifier = `${pluginConfig.pluginSource}:${name}` + const isApproved = approvedServers.includes(identifier) + + if (!isApproved) { + return { + name, + status: "pending-approval", + tools: [] as McpToolInfo[], + needsAuth: false, + config: configObj, + isApproved, + } + } - try { - tools = await fetchToolsForServer(serverConfig) - } catch (error) { - console.error(`[MCP] Failed to fetch tools for plugin ${name}:`, error) - } + // Try to get status and tools for approved servers + let status = getServerStatusFromConfig(serverConfig) + const headers = serverConfig.headers as + | Record + | undefined + let tools: McpToolInfo[] = [] + let needsAuth = false - if (tools.length > 0) { - status = "connected" - } else { - // Same OAuth detection logic as regular MCP servers - if (serverConfig.url) { try { - const baseUrl = getMcpBaseUrl(serverConfig.url) - const metadata = await fetchOAuthMetadata(baseUrl) - needsAuth = !!metadata && !!metadata.authorization_endpoint - } catch { - // If probe fails, assume no auth needed + tools = await fetchToolsForServer(serverConfig) + } catch (error) { + console.error( + `[MCP] Failed to fetch tools for plugin ${name}:`, + error, + ) } - } else if (serverConfig.authType === "oauth" || serverConfig.authType === "bearer") { - needsAuth = true - } - if (needsAuth && !headers?.Authorization) { - status = "needs-auth" - } else { - status = "failed" - } - } + if (tools.length > 0) { + status = "connected" + } else { + // Same OAuth detection logic as regular MCP servers + if (serverConfig.url) { + try { + const baseUrl = getMcpBaseUrl(serverConfig.url) + const metadata = await fetchOAuthMetadata(baseUrl) + needsAuth = + !!metadata && !!metadata.authorization_endpoint + } catch { + // If probe fails, assume no auth needed + } + } else if ( + serverConfig.authType === "oauth" || + serverConfig.authType === "bearer" + ) { + needsAuth = true + } - return { name, status, tools, needsAuth, config: configObj, isApproved } - }) - )).filter((s): s is NonNullable => s !== null) + if (needsAuth && !headers?.Authorization) { + status = "needs-auth" + } else { + status = "failed" + } + } + + return { + name, + status, + tools, + needsAuth, + config: configObj, + isApproved, + } + }, + ), + ) + ).filter((s): s is NonNullable => s !== null) groups.push({ groupName: `Plugin: ${pluginConfig.pluginSource}`, @@ -584,7 +704,9 @@ export const claudeRouter = router({ let lastChunkType = "" // Shared sessionId for cleanup to save on abort let currentSessionId: string | null = null - console.log(`[SD] M:START sub=${subId} stream=${streamId.slice(-8)} mode=${input.mode}`) + console.log( + `[SD] M:START sub=${subId} stream=${streamId.slice(-8)} mode=${input.mode}`, + ) // Track if observable is still active (not unsubscribed) let isObservableActive = true @@ -649,19 +771,21 @@ export const claudeRouter = router({ const existingSessionId = existing?.sessionId || null // Get resumeSessionAt UUID only if shouldResume flag was set (by rollbackToMessage) - const lastAssistantMsg = [...existingMessages].reverse().find( - (m: any) => m.role === "assistant" - ) + const lastAssistantMsg = [...existingMessages] + .reverse() + .find((m: any) => m.role === "assistant") const resumeAtUuid = lastAssistantMsg?.metadata?.shouldResume - ? (lastAssistantMsg?.metadata?.sdkMessageUuid || null) + ? lastAssistantMsg?.metadata?.sdkMessageUuid || null : null const historyEnabled = input.historyEnabled === true // Check if last message is already this user message (avoid duplicate) const lastMsg = existingMessages[existingMessages.length - 1] + const lastMsgText = lastMsg?.parts?.find( + (p: any) => p.type === "text", + )?.text const isDuplicate = - lastMsg?.role === "user" && - lastMsg?.parts?.[0]?.text === input.prompt + lastMsg?.role === "user" && lastMsgText === input.prompt // 2. Create user message and save BEFORE streaming (skip if duplicate) let userMessage: any @@ -671,10 +795,23 @@ export const claudeRouter = router({ userMessage = lastMsg messagesToSave = existingMessages } else { + const parts: any[] = [{ type: "text", text: input.prompt }] + if (input.images && input.images.length > 0) { + for (const img of input.images) { + parts.push({ + type: "data-image", + data: { + base64Data: img.base64Data, + mediaType: img.mediaType, + filename: img.filename, + }, + }) + } + } userMessage = { id: crypto.randomUUID(), role: "user", - parts: [{ type: "text", text: input.prompt }], + parts, } messagesToSave = [...existingMessages, userMessage] @@ -699,8 +836,11 @@ export const claudeRouter = router({ ) if (offlineResult.error) { - emitError(new Error(offlineResult.error), 'Offline mode unavailable') - safeEmit({ type: 'finish' } as UIMessageChunk) + emitError( + new Error(offlineResult.error), + "Offline mode unavailable", + ) + safeEmit({ type: "finish" } as UIMessageChunk) safeComplete() return } @@ -715,9 +855,12 @@ export const claudeRouter = router({ connectionMethod = "offline-ollama" } else if (finalCustomConfig) { // Has custom config = either API key or custom model - const isDefaultAnthropicUrl = !finalCustomConfig.baseUrl || + const isDefaultAnthropicUrl = + !finalCustomConfig.baseUrl || finalCustomConfig.baseUrl.includes("anthropic.com") - connectionMethod = isDefaultAnthropicUrl ? "api-key" : "custom-model" + connectionMethod = isDefaultAnthropicUrl + ? "api-key" + : "custom-model" } setConnectionMethod(connectionMethod) @@ -730,7 +873,9 @@ export const claudeRouter = router({ claudeQuery = await getClaudeQuery() } catch (sdkError) { emitError(sdkError, "Failed to load Claude SDK") - console.log(`[SD] M:END sub=${subId} reason=sdk_load_error n=${chunkCount}`) + console.log( + `[SD] M:END sub=${subId} reason=sdk_load_error n=${chunkCount}`, + ) safeEmit({ type: "finish" } as UIMessageChunk) safeComplete() return @@ -750,14 +895,21 @@ export const claudeRouter = router({ const stderrLines: string[] = [] // Parse mentions from prompt (agents, skills, files, folders) - const { cleanedPrompt, agentMentions, skillMentions } = parseMentions(input.prompt) + const { cleanedPrompt, agentMentions, skillMentions } = + parseMentions(input.prompt) // Build agents option for SDK (proper registration via options.agents) - const agentsOption = await buildAgentsOption(agentMentions, input.cwd) + const agentsOption = await buildAgentsOption( + agentMentions, + input.cwd, + ) // Log if agents were mentioned if (agentMentions.length > 0) { - console.log(`[claude] Registering agents via SDK:`, Object.keys(agentsOption)) + console.log( + `[claude] Registering agents via SDK:`, + Object.keys(agentsOption), + ) } // Log if skills were mentioned @@ -845,7 +997,7 @@ export const claudeRouter = router({ const isolatedConfigDir = path.join( app.getPath("userData"), "claude-sessions", - isUsingOllama ? input.chatId : input.subChatId + isUsingOllama ? input.chatId : input.subChatId, ) // MCP servers to pass to SDK (read from ~/.claude.json) @@ -870,8 +1022,14 @@ export const claudeRouter = router({ // Symlink skills directory if source exists and target doesn't try { - const skillsSourceExists = await fs.stat(skillsSource).then(() => true).catch(() => false) - const skillsTargetExists = await fs.lstat(skillsTarget).then(() => true).catch(() => false) + const skillsSourceExists = await fs + .stat(skillsSource) + .then(() => true) + .catch(() => false) + const skillsTargetExists = await fs + .lstat(skillsTarget) + .then(() => true) + .catch(() => false) if (skillsSourceExists && !skillsTargetExists) { await fs.symlink(skillsSource, skillsTarget, "dir") } @@ -881,8 +1039,14 @@ export const claudeRouter = router({ // Symlink agents directory if source exists and target doesn't try { - const agentsSourceExists = await fs.stat(agentsSource).then(() => true).catch(() => false) - const agentsTargetExists = await fs.lstat(agentsTarget).then(() => true).catch(() => false) + const agentsSourceExists = await fs + .stat(agentsSource) + .then(() => true) + .catch(() => false) + const agentsTargetExists = await fs + .lstat(agentsTarget) + .then(() => true) + .catch(() => false) if (agentsSourceExists && !agentsTargetExists) { await fs.symlink(agentsSource, agentsTarget, "dir") } @@ -922,17 +1086,27 @@ export const claudeRouter = router({ if (cached && cached.mtime === currentMtime) { claudeConfig = cached.config } else { - claudeConfig = JSON.parse(await fs.readFile(claudeJsonSource, "utf-8")) - mcpConfigCache.set(claudeJsonSource, { config: claudeConfig, mtime: currentMtime }) + claudeConfig = JSON.parse( + await fs.readFile(claudeJsonSource, "utf-8"), + ) + mcpConfigCache.set(claudeJsonSource, { + config: claudeConfig, + mtime: currentMtime, + }) } // Merge global + project servers (project overrides global) // getProjectMcpServers resolves worktree paths internally const globalServers = claudeConfig.mcpServers || {} - const projectServers = getProjectMcpServers(claudeConfig, lookupPath) || {} + const projectServers = + getProjectMcpServers(claudeConfig, lookupPath) || {} // Load plugin MCP servers (filtered by enabled plugins and approval) - const [enabledPluginSources, pluginMcpConfigs, approvedServers] = await Promise.all([ + const [ + enabledPluginSources, + pluginMcpConfigs, + approvedServers, + ] = await Promise.all([ getEnabledPlugins(), discoverPluginMcpServers(), getApprovedPluginMcpServers(), @@ -941,7 +1115,9 @@ export const claudeRouter = router({ const pluginServers: Record = {} for (const config of pluginMcpConfigs) { if (enabledPluginSources.includes(config.pluginSource)) { - for (const [name, serverConfig] of Object.entries(config.mcpServers)) { + for (const [name, serverConfig] of Object.entries( + config.mcpServers, + )) { if (!globalServers[name] && !projectServers[name]) { const identifier = `${config.pluginSource}:${name}` if (approvedServers.includes(identifier)) { @@ -953,27 +1129,40 @@ export const claudeRouter = router({ } // Priority: project > global > plugin - const allServers = { ...pluginServers, ...globalServers, ...projectServers } + const allServers = { + ...pluginServers, + ...globalServers, + ...projectServers, + } // Filter to only working MCPs using scoped cache keys if (workingMcpServers.size > 0) { const filtered: Record = {} // Resolve worktree path to original project path to match cache keys - const resolvedProjectPath = resolveProjectPathFromWorktree(lookupPath) || lookupPath + const resolvedProjectPath = + resolveProjectPathFromWorktree(lookupPath) || lookupPath for (const [name, config] of Object.entries(allServers)) { // Use resolved project scope if server is from project, otherwise global - const scope = name in projectServers ? resolvedProjectPath : null + const scope = + name in projectServers ? resolvedProjectPath : null const cacheKey = mcpCacheKey(scope, name) // Include server if it's marked working, or if it's not in cache at all // (plugin servers won't be in the cache yet) - if (workingMcpServers.get(cacheKey) === true || !workingMcpServers.has(cacheKey)) { + if ( + workingMcpServers.get(cacheKey) === true || + !workingMcpServers.has(cacheKey) + ) { filtered[name] = config } } mcpServersForSdk = filtered - const skipped = Object.keys(allServers).length - Object.keys(filtered).length + const skipped = + Object.keys(allServers).length - + Object.keys(filtered).length if (skipped > 0) { - console.log(`[claude] Filtered out ${skipped} non-working MCP(s)`) + console.log( + `[claude] Filtered out ${skipped} non-working MCP(s)`, + ) } } else { mcpServersForSdk = allServers @@ -983,58 +1172,114 @@ export const claudeRouter = router({ console.error(`[claude] Failed to read MCP config:`, configErr) } } catch (mkdirErr) { - console.error(`[claude] Failed to setup isolated config dir:`, mkdirErr) + console.error( + `[claude] Failed to setup isolated config dir:`, + mkdirErr, + ) } // Check if user has existing API key or proxy configured in their shell environment // If so, use that instead of OAuth (allows using custom API proxies) // Based on PR #29 by @sa4hnd - const hasExistingApiConfig = !!(claudeEnv.ANTHROPIC_API_KEY || claudeEnv.ANTHROPIC_BASE_URL) + const hasExistingApiConfig = !!( + claudeEnv.ANTHROPIC_API_KEY || claudeEnv.ANTHROPIC_BASE_URL + ) if (hasExistingApiConfig) { - console.log(`[claude] Using existing CLI config - API_KEY: ${claudeEnv.ANTHROPIC_API_KEY ? "set" : "not set"}, BASE_URL: ${claudeEnv.ANTHROPIC_BASE_URL || "default"}`) + console.log( + `[claude] Using existing CLI config - API_KEY: ${claudeEnv.ANTHROPIC_API_KEY ? "set" : "not set"}, BASE_URL: ${claudeEnv.ANTHROPIC_BASE_URL || "default"}`, + ) } // Build final env - only add OAuth token if we have one AND no existing API config // Existing CLI config takes precedence over OAuth const finalEnv = { ...claudeEnv, - ...(claudeCodeToken && !hasExistingApiConfig && { - CLAUDE_CODE_OAUTH_TOKEN: claudeCodeToken, - }), + // ...(claudeCodeToken && + // !hasExistingApiConfig && { + // CLAUDE_CODE_OAUTH_TOKEN: claudeCodeToken, + // }), // Re-enable CLAUDE_CONFIG_DIR now that we properly map MCP configs CLAUDE_CONFIG_DIR: isolatedConfigDir, } + // Log auth method being used + console.log("[claude-auth] ========== AUTH METHOD USED ==========") + console.log( + "[claude-auth] hasExistingApiConfig:", + hasExistingApiConfig, + ) + console.log( + "[claude-auth] claudeCodeToken available:", + !!claudeCodeToken, + ) + console.log( + "[claude-auth] Using CLAUDE_CODE_OAUTH_TOKEN:", + !!finalEnv.CLAUDE_CODE_OAUTH_TOKEN, + ) + console.log( + "[claude-auth] Using ANTHROPIC_API_KEY:", + !!finalEnv.ANTHROPIC_API_KEY, + ) + console.log( + "[claude-auth] Using ANTHROPIC_BASE_URL:", + finalEnv.ANTHROPIC_BASE_URL || "(default)", + ) + console.log( + "[claude-auth] Using ANTHROPIC_AUTH_TOKEN:", + !!finalEnv.ANTHROPIC_AUTH_TOKEN, + ) + console.log( + "[claude-auth] ============================================", + ) + // Get bundled Claude binary path const claudeBinaryPath = getBundledClaudeBinaryPath() - const resumeSessionId = input.sessionId || existingSessionId || undefined + const resumeSessionId = + input.sessionId || existingSessionId || undefined // DEBUG: Session resume path tracing const expectedSanitizedCwd = input.cwd.replace(/[/.]/g, "-") - const expectedSessionPath = path.join(isolatedConfigDir, "projects", expectedSanitizedCwd, `${resumeSessionId}.jsonl`) + const expectedSessionPath = path.join( + isolatedConfigDir, + "projects", + expectedSanitizedCwd, + `${resumeSessionId}.jsonl`, + ) console.log(`[claude] ========== SESSION DEBUG ==========`) console.log(`[claude] subChatId: ${input.subChatId}`) console.log(`[claude] cwd: ${input.cwd}`) - console.log(`[claude] sanitized cwd (expected): ${expectedSanitizedCwd}`) + console.log( + `[claude] sanitized cwd (expected): ${expectedSanitizedCwd}`, + ) console.log(`[claude] CLAUDE_CONFIG_DIR: ${isolatedConfigDir}`) - console.log(`[claude] Expected session path: ${expectedSessionPath}`) + console.log( + `[claude] Expected session path: ${expectedSessionPath}`, + ) console.log(`[claude] Session ID to resume: ${resumeSessionId}`) - console.log(`[claude] Existing sessionId from DB: ${existingSessionId}`) + console.log( + `[claude] Existing sessionId from DB: ${existingSessionId}`, + ) console.log(`[claude] Resume at UUID: ${resumeAtUuid}`) console.log(`[claude] ========== END SESSION DEBUG ==========`) - console.log(`[SD] Query options - cwd: ${input.cwd}, projectPath: ${input.projectPath || "(not set)"}, mcpServers: ${mcpServersForSdk ? Object.keys(mcpServersForSdk).join(", ") : "(none)"}`) + console.log( + `[SD] Query options - cwd: ${input.cwd}, projectPath: ${input.projectPath || "(not set)"}, mcpServers: ${mcpServersForSdk ? Object.keys(mcpServersForSdk).join(", ") : "(none)"}`, + ) if (finalCustomConfig) { const redactedConfig = { ...finalCustomConfig, token: `${finalCustomConfig.token.slice(0, 6)}...`, } if (isUsingOllama) { - console.log(`[Ollama] Using offline mode - Model: ${finalCustomConfig.model}, Base URL: ${finalCustomConfig.baseUrl}`) + console.log( + `[Ollama] Using offline mode - Model: ${finalCustomConfig.model}, Base URL: ${finalCustomConfig.baseUrl}`, + ) } else { - console.log(`[claude] Custom config: ${JSON.stringify(redactedConfig)}`) + console.log( + `[claude] Custom config: ${JSON.stringify(redactedConfig)}`, + ) } } @@ -1042,28 +1287,46 @@ export const claudeRouter = router({ // DEBUG: If using Ollama, test if it's actually responding if (isUsingOllama && finalCustomConfig) { - console.log('[Ollama Debug] Testing Ollama connectivity...') + console.log("[Ollama Debug] Testing Ollama connectivity...") try { - const testResponse = await fetch(`${finalCustomConfig.baseUrl}/api/tags`, { - signal: AbortSignal.timeout(2000) - }) + const testResponse = await fetch( + `${finalCustomConfig.baseUrl}/api/tags`, + { + signal: AbortSignal.timeout(2000), + }, + ) if (testResponse.ok) { const data = await testResponse.json() const models = data.models?.map((m: any) => m.name) || [] - console.log('[Ollama Debug] Ollama is responding. Available models:', models) + console.log( + "[Ollama Debug] Ollama is responding. Available models:", + models, + ) if (!models.includes(finalCustomConfig.model)) { - console.error(`[Ollama Debug] WARNING: Model "${finalCustomConfig.model}" not found in Ollama!`) + console.error( + `[Ollama Debug] WARNING: Model "${finalCustomConfig.model}" not found in Ollama!`, + ) console.error(`[Ollama Debug] Available models:`, models) - console.error(`[Ollama Debug] This will likely cause the stream to hang or fail silently.`) + console.error( + `[Ollama Debug] This will likely cause the stream to hang or fail silently.`, + ) } else { - console.log(`[Ollama Debug] ✓ Model "${finalCustomConfig.model}" is available`) + console.log( + `[Ollama Debug] ✓ Model "${finalCustomConfig.model}" is available`, + ) } } else { - console.error('[Ollama Debug] Ollama returned error:', testResponse.status) + console.error( + "[Ollama Debug] Ollama returned error:", + testResponse.status, + ) } } catch (err) { - console.error('[Ollama Debug] Failed to connect to Ollama:', err) + console.error( + "[Ollama Debug] Failed to connect to Ollama:", + err, + ) } } @@ -1072,13 +1335,21 @@ export const claudeRouter = router({ let mcpServersFiltered: Record | undefined if (isUsingOllama) { - console.log('[Ollama] Skipping MCP servers to speed up initialization') + console.log( + "[Ollama] Skipping MCP servers to speed up initialization", + ) mcpServersFiltered = undefined } else { // Ensure MCP tokens are fresh (refresh if within 5 min of expiry) - if (mcpServersForSdk && Object.keys(mcpServersForSdk).length > 0) { + if ( + mcpServersForSdk && + Object.keys(mcpServersForSdk).length > 0 + ) { const lookupPath = input.projectPath || input.cwd - mcpServersFiltered = await ensureMcpTokensFresh(mcpServersForSdk, lookupPath) + mcpServersFiltered = await ensureMcpTokensFresh( + mcpServersForSdk, + lookupPath, + ) } else { mcpServersFiltered = mcpServersForSdk } @@ -1086,20 +1357,21 @@ export const claudeRouter = router({ // Log SDK configuration for debugging if (isUsingOllama) { - console.log('[Ollama Debug] SDK Configuration:', { + console.log("[Ollama Debug] SDK Configuration:", { model: resolvedModel, baseUrl: finalEnv.ANTHROPIC_BASE_URL, cwd: input.cwd, configDir: isolatedConfigDir, hasAuthToken: !!finalEnv.ANTHROPIC_AUTH_TOKEN, - tokenPreview: finalEnv.ANTHROPIC_AUTH_TOKEN?.slice(0, 10) + '...', + tokenPreview: + finalEnv.ANTHROPIC_AUTH_TOKEN?.slice(0, 10) + "...", }) - console.log('[Ollama Debug] Session settings:', { - resumeSessionId: resumeSessionId || 'none (first message)', - mode: resumeSessionId ? 'resume' : 'continue', + console.log("[Ollama Debug] Session settings:", { + resumeSessionId: resumeSessionId || "none (first message)", + mode: resumeSessionId ? "resume" : "continue", note: resumeSessionId - ? 'Resuming existing session to maintain chat history' - : 'Starting new session with continue mode' + ? "Resuming existing session to maintain chat history" + : "Starting new session with continue mode", }) } @@ -1109,7 +1381,9 @@ export const claudeRouter = router({ const agentsMdPath = path.join(input.cwd, "AGENTS.md") agentsMdContent = await fs.readFile(agentsMdPath, "utf-8") if (agentsMdContent.trim()) { - console.log(`[claude] Found AGENTS.md at ${agentsMdPath} (${agentsMdContent.length} chars)`) + console.log( + `[claude] Found AGENTS.md at ${agentsMdPath} (${agentsMdContent.length} chars)`, + ) } else { agentsMdContent = undefined } @@ -1120,63 +1394,75 @@ export const claudeRouter = router({ // For Ollama: embed context AND history directly in prompt // Ollama doesn't have server-side sessions, so we must include full history let finalQueryPrompt: string | AsyncIterable = prompt - if (isUsingOllama && typeof prompt === 'string') { + if (isUsingOllama && typeof prompt === "string") { // Format conversation history from existingMessages (excluding current message) // IMPORTANT: Include tool calls info so model knows what files were read/edited - let historyText = '' + let historyText = "" if (existingMessages.length > 0) { const historyParts: string[] = [] for (const msg of existingMessages) { - if (msg.role === 'user') { + if (msg.role === "user") { // Extract text from user message parts - const textParts = msg.parts?.filter((p: any) => p.type === 'text').map((p: any) => p.text) || [] + const textParts = + msg.parts + ?.filter((p: any) => p.type === "text") + .map((p: any) => p.text) || [] if (textParts.length > 0) { - historyParts.push(`User: ${textParts.join('\n')}`) + historyParts.push(`User: ${textParts.join("\n")}`) } - } else if (msg.role === 'assistant') { + } else if (msg.role === "assistant") { // Extract text AND tool calls from assistant message parts const parts = msg.parts || [] const textParts: string[] = [] const toolSummaries: string[] = [] for (const p of parts) { - if (p.type === 'text' && p.text) { + if (p.type === "text" && p.text) { textParts.push(p.text) - } else if (p.type === 'tool_use' || p.type === 'tool-use') { + } else if ( + p.type === "tool_use" || + p.type === "tool-use" + ) { // Include brief tool call info - this is critical for context! - const toolName = p.name || p.tool || 'unknown' + const toolName = p.name || p.tool || "unknown" const toolInput = p.input || {} // Extract key info based on tool type let toolInfo = `[Used ${toolName}` - if (toolName === 'Read' && (toolInput.file_path || toolInput.file)) { + if ( + toolName === "Read" && + (toolInput.file_path || toolInput.file) + ) { toolInfo += `: ${toolInput.file_path || toolInput.file}` - } else if (toolName === 'Edit' && toolInput.file_path) { + } else if (toolName === "Edit" && toolInput.file_path) { toolInfo += `: ${toolInput.file_path}` - } else if (toolName === 'Write' && toolInput.file_path) { + } else if ( + toolName === "Write" && + toolInput.file_path + ) { toolInfo += `: ${toolInput.file_path}` - } else if (toolName === 'Glob' && toolInput.pattern) { + } else if (toolName === "Glob" && toolInput.pattern) { toolInfo += `: ${toolInput.pattern}` - } else if (toolName === 'Grep' && toolInput.pattern) { + } else if (toolName === "Grep" && toolInput.pattern) { toolInfo += `: "${toolInput.pattern}"` - } else if (toolName === 'Bash' && toolInput.command) { + } else if (toolName === "Bash" && toolInput.command) { const cmd = String(toolInput.command).slice(0, 50) - toolInfo += `: ${cmd}${toolInput.command.length > 50 ? '...' : ''}` + toolInfo += `: ${cmd}${toolInput.command.length > 50 ? "..." : ""}` } - toolInfo += ']' + toolInfo += "]" toolSummaries.push(toolInfo) } } // Combine text and tool summaries - let assistantContent = '' + let assistantContent = "" if (textParts.length > 0) { - assistantContent = textParts.join('\n') + assistantContent = textParts.join("\n") } if (toolSummaries.length > 0) { if (assistantContent) { - assistantContent += '\n' + toolSummaries.join(' ') + assistantContent += "\n" + toolSummaries.join(" ") } else { - assistantContent = toolSummaries.join(' ') + assistantContent = toolSummaries.join(" ") } } if (assistantContent) { @@ -1186,21 +1472,25 @@ export const claudeRouter = router({ } if (historyParts.length > 0) { // Limit history to last ~10000 chars to avoid context overflow - let history = historyParts.join('\n\n') + let history = historyParts.join("\n\n") if (history.length > 10000) { - history = '...(earlier messages truncated)...\n\n' + history.slice(-10000) + history = + "...(earlier messages truncated)...\n\n" + + history.slice(-10000) } historyText = `[CONVERSATION HISTORY] ${history} [/CONVERSATION HISTORY] ` - console.log(`[Ollama] Added ${historyParts.length} messages to history (${history.length} chars)`) + console.log( + `[Ollama] Added ${historyParts.length} messages to history (${history.length} chars)`, + ) } } const ollamaContext = `[CONTEXT] -You are a coding assistant in OFFLINE mode (Ollama model: ${resolvedModel || 'unknown'}). +You are a coding assistant in OFFLINE mode (Ollama model: ${resolvedModel || "unknown"}). Project: ${input.projectPath || input.cwd} Working directory: ${input.cwd} @@ -1214,17 +1504,21 @@ IMPORTANT: When using tools, use these EXACT parameter names: When asked about the project, use Glob to find files and Read to examine them. Be concise and helpful. -[/CONTEXT]${agentsMdContent ? ` +[/CONTEXT]${ + agentsMdContent + ? ` [AGENTS.MD] ${agentsMdContent} -[/AGENTS.MD]` : ''} +[/AGENTS.MD]` + : "" + } ${historyText}[CURRENT REQUEST] ${prompt} [/CURRENT REQUEST]` finalQueryPrompt = ollamaContext - console.log('[Ollama] Context prefix added to prompt') + console.log("[Ollama] Context prefix added to prompt") } // System prompt config - use preset for both Claude and Ollama @@ -1247,10 +1541,18 @@ ${prompt} cwd: input.cwd, systemPrompt: systemPromptConfig, // Register mentioned agents with SDK via options.agents (skip for Ollama - not supported) - ...(!isUsingOllama && Object.keys(agentsOption).length > 0 && { agents: agentsOption }), + ...(!isUsingOllama && + Object.keys(agentsOption).length > 0 && { + agents: agentsOption, + }), // Pass filtered MCP servers (only working/unknown ones, skip failed/needs-auth) - ...(mcpServersFiltered && Object.keys(mcpServersFiltered).length > 0 && { mcpServers: mcpServersFiltered }), - env: finalEnv, + ...(mcpServersFiltered && + Object.keys(mcpServersFiltered).length > 0 && { + mcpServers: mcpServersFiltered, + }), + env: { + ...finalEnv, + }, permissionMode: input.mode === "plan" ? ("plan" as const) @@ -1260,7 +1562,9 @@ ${prompt} }), includePartialMessages: true, // Load skills from project and user directories (skip for Ollama - not supported) - ...(!isUsingOllama && { settingSources: ["project" as const, "user" as const] }), + ...(!isUsingOllama && { + settingSources: ["project" as const, "user" as const], + }), canUseTool: async ( toolName: string, toolInput: Record, @@ -1270,34 +1574,50 @@ ${prompt} // Local models often use slightly wrong parameter names if (isUsingOllama) { // Read: "file" -> "file_path" - if (toolName === "Read" && toolInput.file && !toolInput.file_path) { + if ( + toolName === "Read" && + toolInput.file && + !toolInput.file_path + ) { toolInput.file_path = toolInput.file delete toolInput.file - console.log('[Ollama] Fixed Read tool: file -> file_path') + console.log("[Ollama] Fixed Read tool: file -> file_path") } // Write: "file" -> "file_path", "content" is usually correct - if (toolName === "Write" && toolInput.file && !toolInput.file_path) { + if ( + toolName === "Write" && + toolInput.file && + !toolInput.file_path + ) { toolInput.file_path = toolInput.file delete toolInput.file - console.log('[Ollama] Fixed Write tool: file -> file_path') + console.log( + "[Ollama] Fixed Write tool: file -> file_path", + ) } // Edit: "file" -> "file_path" - if (toolName === "Edit" && toolInput.file && !toolInput.file_path) { + if ( + toolName === "Edit" && + toolInput.file && + !toolInput.file_path + ) { toolInput.file_path = toolInput.file delete toolInput.file - console.log('[Ollama] Fixed Edit tool: file -> file_path') + console.log("[Ollama] Fixed Edit tool: file -> file_path") } // Glob: "path" might be passed as "directory" or "dir" if (toolName === "Glob") { if (toolInput.directory && !toolInput.path) { toolInput.path = toolInput.directory delete toolInput.directory - console.log('[Ollama] Fixed Glob tool: directory -> path') + console.log( + "[Ollama] Fixed Glob tool: directory -> path", + ) } if (toolInput.dir && !toolInput.path) { toolInput.path = toolInput.dir delete toolInput.dir - console.log('[Ollama] Fixed Glob tool: dir -> path') + console.log("[Ollama] Fixed Glob tool: dir -> path") } } // Grep: "query" -> "pattern", "directory" -> "path" @@ -1305,19 +1625,27 @@ ${prompt} if (toolInput.query && !toolInput.pattern) { toolInput.pattern = toolInput.query delete toolInput.query - console.log('[Ollama] Fixed Grep tool: query -> pattern') + console.log( + "[Ollama] Fixed Grep tool: query -> pattern", + ) } if (toolInput.directory && !toolInput.path) { toolInput.path = toolInput.directory delete toolInput.directory - console.log('[Ollama] Fixed Grep tool: directory -> path') + console.log( + "[Ollama] Fixed Grep tool: directory -> path", + ) } } // Bash: "cmd" -> "command" - if (toolName === "Bash" && toolInput.cmd && !toolInput.command) { + if ( + toolName === "Bash" && + toolInput.cmd && + !toolInput.command + ) { toolInput.command = toolInput.cmd delete toolInput.cmd - console.log('[Ollama] Fixed Bash tool: cmd -> command') + console.log("[Ollama] Fixed Bash tool: cmd -> command") } } @@ -1334,6 +1662,11 @@ ${prompt} 'Only ".md" files can be modified in plan mode.', } } + } else if (toolName == "ExitPlanMode") { + return { + behavior: "deny", + message: `IMPORTANT: DONT IMPLEMENT THE PLAN UNTIL THE EXPLIT COMMAND. THE PLAN WAS **ONLY** PRESENTED TO USER, FINISH CURRENT MESSAGE AS SOON AS POSSIBLE`, + } } else if (PLAN_MODE_BLOCKED_TOOLS.has(toolName)) { return { behavior: "deny", @@ -1378,7 +1711,9 @@ ${prompt} // Find the tool part in accumulated parts const askToolPart = parts.find( - (p) => p.toolCallId === toolUseID && p.type === "tool-AskUserQuestion" + (p) => + p.toolCallId === toolUseID && + p.type === "tool-AskUserQuestion", ) if (!response.approved) { @@ -1452,512 +1787,627 @@ ${prompt} }, } - // 5. Run Claude SDK - let stream - try { - stream = claudeQuery(queryOptions) - } catch (queryError) { - console.error( - "[CLAUDE] ✗ Failed to create SDK query:", - queryError, - ) - emitError(queryError, "Failed to start Claude query") - console.log(`[SD] M:END sub=${subId} reason=query_error n=${chunkCount}`) - safeEmit({ type: "finish" } as UIMessageChunk) - safeComplete() - return - } - + // Auto-retry for transient API errors (e.g., false-positive USAGE_POLICY_VIOLATION) + const MAX_POLICY_RETRIES = 2 + let policyRetryCount = 0 + let policyRetryNeeded = false let messageCount = 0 - let lastError: Error | null = null - let firstMessageReceived = false - // Track last assistant message UUID for rollback support - // Only assigned to metadata AFTER the stream completes (not during generation) - let lastAssistantUuid: string | null = null - const streamIterationStart = Date.now() - // Plan mode: track ExitPlanMode to stop after plan is complete - let planCompleted = false - let exitPlanModeToolCallId: string | null = null + // eslint-disable-next-line no-constant-condition + while (true) { + policyRetryNeeded = false + messageCount = 0 - if (isUsingOllama) { - console.log(`[Ollama] ===== STARTING STREAM ITERATION =====`) - console.log(`[Ollama] Model: ${finalCustomConfig?.model}`) - console.log(`[Ollama] Base URL: ${finalCustomConfig?.baseUrl}`) - console.log(`[Ollama] Prompt: "${typeof input.prompt === 'string' ? input.prompt.slice(0, 100) : 'N/A'}..."`) - console.log(`[Ollama] CWD: ${input.cwd}`) - } + // 5. Run Claude SDK + let stream + try { + stream = claudeQuery(queryOptions) + } catch (queryError) { + console.error( + "[CLAUDE] ✗ Failed to create SDK query:", + queryError, + ) + emitError(queryError, "Failed to start Claude query") + console.log( + `[SD] M:END sub=${subId} reason=query_error n=${chunkCount}`, + ) + safeEmit({ type: "finish" } as UIMessageChunk) + safeComplete() + return + } - try { - for await (const msg of stream) { - if (abortController.signal.aborted) { - if (isUsingOllama) console.log(`[Ollama] Stream aborted by user`) - break - } + let lastError: Error | null = null + let firstMessageReceived = false + // Track last assistant message UUID for rollback support + // Only assigned to metadata AFTER the stream completes (not during generation) + let lastAssistantUuid: string | null = null + const streamIterationStart = Date.now() - messageCount++ + // Plan mode: track ExitPlanMode to stop after plan is complete + let exitPlanModeToolCallId: string | null = null - // Extra logging for Ollama to diagnose issues - if (isUsingOllama) { - const msgAnyPreview = msg as any - console.log(`[Ollama] ===== MESSAGE #${messageCount} =====`) - console.log(`[Ollama] Type: ${msgAnyPreview.type}`) - console.log(`[Ollama] Subtype: ${msgAnyPreview.subtype || 'none'}`) - if (msgAnyPreview.event) { - console.log(`[Ollama] Event: ${msgAnyPreview.event.type}`, { - delta_type: msgAnyPreview.event.delta?.type, - content_block_type: msgAnyPreview.event.content_block?.type - }) - } - if (msgAnyPreview.message?.content) { - console.log(`[Ollama] Message content blocks:`, msgAnyPreview.message.content.length) - msgAnyPreview.message.content.forEach((block: any, idx: number) => { - console.log(`[Ollama] Block ${idx}: type=${block.type}, text_length=${block.text?.length || 0}`) - }) - } - } + if (isUsingOllama) { + console.log(`[Ollama] ===== STARTING STREAM ITERATION =====`) + console.log(`[Ollama] Model: ${finalCustomConfig?.model}`) + console.log(`[Ollama] Base URL: ${finalCustomConfig?.baseUrl}`) + console.log( + `[Ollama] Prompt: "${typeof input.prompt === "string" ? input.prompt.slice(0, 100) : "N/A"}..."`, + ) + console.log(`[Ollama] CWD: ${input.cwd}`) + } - // Warn if SDK initialization is slow (MCP delay) - if (!firstMessageReceived) { - firstMessageReceived = true - const timeToFirstMessage = Date.now() - streamIterationStart - if (isUsingOllama) { - console.log(`[Ollama] Time to first message: ${timeToFirstMessage}ms`) - } - if (timeToFirstMessage > 5000) { - console.warn(`[claude] SDK initialization took ${(timeToFirstMessage / 1000).toFixed(1)}s (MCP servers loading?)`) + try { + for await (const msg of stream) { + if (abortController.signal.aborted) { + if (isUsingOllama) + console.log(`[Ollama] Stream aborted by user`) + break } - } - // Log raw message for debugging - logRawClaudeMessage(input.chatId, msg) - - // Check for error messages from SDK (error can be embedded in message payload!) - const msgAny = msg as any - if (msgAny.type === "error" || msgAny.error) { - // Extract detailed error text from message content if available - // This is where the actual error description lives (e.g., "API Error: Claude Code is unable to respond...") - const messageText = msgAny.message?.content?.[0]?.text - const sdkError = messageText || msgAny.error || msgAny.message || "Unknown SDK error" - lastError = new Error(sdkError) - - // Detailed SDK error logging in main process - console.error(`[CLAUDE SDK ERROR] ========================================`) - console.error(`[CLAUDE SDK ERROR] Raw error: ${sdkError}`) - console.error(`[CLAUDE SDK ERROR] Message type: ${msgAny.type}`) - console.error(`[CLAUDE SDK ERROR] SubChat ID: ${input.subChatId}`) - console.error(`[CLAUDE SDK ERROR] Chat ID: ${input.chatId}`) - console.error(`[CLAUDE SDK ERROR] CWD: ${input.cwd}`) - console.error(`[CLAUDE SDK ERROR] Mode: ${input.mode}`) - console.error(`[CLAUDE SDK ERROR] Session ID: ${msgAny.session_id || 'none'}`) - console.error(`[CLAUDE SDK ERROR] Has custom config: ${!!finalCustomConfig}`) - console.error(`[CLAUDE SDK ERROR] Is using Ollama: ${isUsingOllama}`) - console.error(`[CLAUDE SDK ERROR] Model: ${resolvedModel || 'default'}`) - console.error(`[CLAUDE SDK ERROR] Has OAuth token: ${!!claudeCodeToken}`) - console.error(`[CLAUDE SDK ERROR] MCP servers: ${mcpServersFiltered ? Object.keys(mcpServersFiltered).join(', ') : 'none'}`) - console.error(`[CLAUDE SDK ERROR] Full message:`, JSON.stringify(msgAny, null, 2)) - console.error(`[CLAUDE SDK ERROR] ========================================`) - - // Categorize SDK-level errors - // Use the raw error code (e.g., "invalid_request") for category matching - const rawErrorCode = msgAny.error || "" - let errorCategory = "SDK_ERROR" - // Default errorContext to the full error text (which may include detailed message) - let errorContext = sdkError + messageCount++ - if ( - rawErrorCode === "authentication_failed" || - sdkError.includes("authentication") - ) { - errorCategory = "AUTH_FAILED_SDK" - errorContext = - "Authentication failed - not logged into Claude Code CLI" - } else if ( - String(sdkError).includes("invalid_token") || - String(sdkError).includes("Invalid access token") - ) { - errorCategory = "MCP_INVALID_TOKEN" - errorContext = "Invalid access token. Update MCP settings" - } else if ( - rawErrorCode === "invalid_api_key" || - sdkError.includes("api_key") - ) { - errorCategory = "INVALID_API_KEY_SDK" - errorContext = "Invalid API key in Claude Code CLI" - } else if ( - rawErrorCode === "rate_limit_exceeded" || - sdkError.includes("rate") - ) { - errorCategory = "RATE_LIMIT_SDK" - errorContext = "Session limit reached" - } else if ( - rawErrorCode === "overloaded" || - sdkError.includes("overload") - ) { - errorCategory = "OVERLOADED_SDK" - errorContext = "Claude is overloaded, try again later" - } else if ( - rawErrorCode === "invalid_request" || - sdkError.includes("Usage Policy") || - sdkError.includes("violate") - ) { - // Usage Policy violation - keep the full detailed error text - errorCategory = "USAGE_POLICY_VIOLATION" - // errorContext already contains the full message from sdkError + // Extra logging for Ollama to diagnose issues + if (isUsingOllama) { + const msgAnyPreview = msg as any + console.log(`[Ollama] ===== MESSAGE #${messageCount} =====`) + console.log(`[Ollama] Type: ${msgAnyPreview.type}`) + console.log( + `[Ollama] Subtype: ${msgAnyPreview.subtype || "none"}`, + ) + if (msgAnyPreview.event) { + console.log( + `[Ollama] Event: ${msgAnyPreview.event.type}`, + { + delta_type: msgAnyPreview.event.delta?.type, + content_block_type: + msgAnyPreview.event.content_block?.type, + }, + ) + } + if (msgAnyPreview.message?.content) { + console.log( + `[Ollama] Message content blocks:`, + msgAnyPreview.message.content.length, + ) + msgAnyPreview.message.content.forEach( + (block: any, idx: number) => { + console.log( + `[Ollama] Block ${idx}: type=${block.type}, text_length=${block.text?.length || 0}`, + ) + }, + ) + } } - // Emit auth-error for authentication failures, regular error otherwise - if (errorCategory === "AUTH_FAILED_SDK") { - safeEmit({ - type: "auth-error", - errorText: errorContext, - } as UIMessageChunk) - } else { - safeEmit({ - type: "error", - errorText: errorContext, - debugInfo: { - category: errorCategory, - rawErrorCode, - sessionId: msgAny.session_id, - messageId: msgAny.message?.id, - }, - } as UIMessageChunk) + // Warn if SDK initialization is slow (MCP delay) + if (!firstMessageReceived) { + firstMessageReceived = true + const timeToFirstMessage = Date.now() - streamIterationStart + if (isUsingOllama) { + console.log( + `[Ollama] Time to first message: ${timeToFirstMessage}ms`, + ) + } + if (timeToFirstMessage > 5000) { + console.warn( + `[claude] SDK initialization took ${(timeToFirstMessage / 1000).toFixed(1)}s (MCP servers loading?)`, + ) + } } - console.log(`[SD] M:END sub=${subId} reason=sdk_error cat=${errorCategory} n=${chunkCount}`) - console.error(`[SD] SDK Error details:`, { - errorCategory, - errorContext: errorContext.slice(0, 200), // Truncate for log readability - rawErrorCode, - sessionId: msgAny.session_id, - messageId: msgAny.message?.id, - fullMessage: JSON.stringify(msgAny, null, 2), - }) - safeEmit({ type: "finish" } as UIMessageChunk) - safeComplete() - return - } + // Log raw message for debugging + logRawClaudeMessage(input.chatId, msg) + + // Check for error messages from SDK (error can be embedded in message payload!) + const msgAny = msg as any + if (msgAny.type === "error" || msgAny.error) { + // Extract detailed error text from message content if available + // This is where the actual error description lives (e.g., "API Error: Claude Code is unable to respond...") + const messageText = msgAny.message?.content?.[0]?.text + const sdkError = + messageText || + msgAny.error || + msgAny.message || + "Unknown SDK error" + lastError = new Error(sdkError) + + // Detailed SDK error logging in main process + console.error( + `[CLAUDE SDK ERROR] ========================================`, + ) + console.error(`[CLAUDE SDK ERROR] Raw error: ${sdkError}`) + console.error( + `[CLAUDE SDK ERROR] Message type: ${msgAny.type}`, + ) + console.error( + `[CLAUDE SDK ERROR] SubChat ID: ${input.subChatId}`, + ) + console.error(`[CLAUDE SDK ERROR] Chat ID: ${input.chatId}`) + console.error(`[CLAUDE SDK ERROR] CWD: ${input.cwd}`) + console.error(`[CLAUDE SDK ERROR] Mode: ${input.mode}`) + console.error( + `[CLAUDE SDK ERROR] Session ID: ${msgAny.session_id || "none"}`, + ) + console.error( + `[CLAUDE SDK ERROR] Has custom config: ${!!finalCustomConfig}`, + ) + console.error( + `[CLAUDE SDK ERROR] Is using Ollama: ${isUsingOllama}`, + ) + console.error( + `[CLAUDE SDK ERROR] Model: ${resolvedModel || "default"}`, + ) + console.error( + `[CLAUDE SDK ERROR] Has OAuth token: ${!!claudeCodeToken}`, + ) + console.error( + `[CLAUDE SDK ERROR] MCP servers: ${mcpServersFiltered ? Object.keys(mcpServersFiltered).join(", ") : "none"}`, + ) + console.error( + `[CLAUDE SDK ERROR] Full message:`, + JSON.stringify(msgAny, null, 2), + ) + console.error( + `[CLAUDE SDK ERROR] ========================================`, + ) - // Track sessionId for rollback support (available on all messages) - if (msgAny.session_id) { - metadata.sessionId = msgAny.session_id - currentSessionId = msgAny.session_id // Share with cleanup - } + // Categorize SDK-level errors + // Use the raw error code (e.g., "invalid_request") for category matching + const rawErrorCode = msgAny.error || "" + let errorCategory = "SDK_ERROR" + // Default errorContext to the full error text (which may include detailed message) + let errorContext = sdkError + + if ( + rawErrorCode === "authentication_failed" || + sdkError.includes("authentication") + ) { + errorCategory = "AUTH_FAILED_SDK" + errorContext = + "Authentication failed - not logged into Claude Code CLI" + } else if ( + String(sdkError).includes("invalid_token") || + String(sdkError).includes("Invalid access token") + ) { + errorCategory = "MCP_INVALID_TOKEN" + errorContext = "Invalid access token. Update MCP settings" + } else if ( + rawErrorCode === "invalid_api_key" || + sdkError.includes("api_key") + ) { + errorCategory = "INVALID_API_KEY_SDK" + errorContext = "Invalid API key in Claude Code CLI" + } else if ( + rawErrorCode === "rate_limit_exceeded" || + sdkError.includes("rate") + ) { + errorCategory = "RATE_LIMIT_SDK" + errorContext = "Session limit reached" + } else if ( + rawErrorCode === "overloaded" || + sdkError.includes("overload") + ) { + errorCategory = "OVERLOADED_SDK" + errorContext = "Claude is overloaded, try again later" + } else if ( + rawErrorCode === "invalid_request" || + sdkError.includes("Usage Policy") || + sdkError.includes("violate") + ) { + errorCategory = "USAGE_POLICY_VIOLATION" + } - // Track UUID from assistant messages for resumeSessionAt - if (msgAny.type === "assistant" && msgAny.uuid) { - lastAssistantUuid = msgAny.uuid - } + // Auto-retry on false-positive policy violations (gateway-level rejections) + if ( + errorCategory === "USAGE_POLICY_VIOLATION" && + policyRetryCount < MAX_POLICY_RETRIES && + !abortController.signal.aborted + ) { + policyRetryCount++ + policyRetryNeeded = true + console.log( + `[claude] USAGE_POLICY_VIOLATION - silent retry (attempt ${policyRetryCount}/${MAX_POLICY_RETRIES})`, + ) + break // break for-await loop to retry + } - // When result arrives, assign the last assistant UUID to metadata - // It will be emitted as part of the merged message-metadata chunk below - if (msgAny.type === "result" && historyEnabled && lastAssistantUuid && !abortController.signal.aborted) { - metadata.sdkMessageUuid = lastAssistantUuid - } + // Emit auth-error for authentication failures, regular error otherwise + if (errorCategory === "AUTH_FAILED_SDK") { + safeEmit({ + type: "auth-error", + errorText: errorContext, + } as UIMessageChunk) + } else { + safeEmit({ + type: "error", + errorText: errorContext, + debugInfo: { + category: errorCategory, + rawErrorCode, + sessionId: msgAny.session_id, + messageId: msgAny.message?.id, + }, + } as UIMessageChunk) + } - // Debug: Log system messages from SDK - if (msgAny.type === "system") { - // Full log to see all fields including MCP errors - console.log(`[SD] SYSTEM message: subtype=${msgAny.subtype}`, JSON.stringify({ - cwd: msgAny.cwd, - mcp_servers: msgAny.mcp_servers, - tools: msgAny.tools, - plugins: msgAny.plugins, - permissionMode: msgAny.permissionMode, - }, null, 2)) - } + console.log( + `[SD] M:END sub=${subId} reason=sdk_error cat=${errorCategory} n=${chunkCount}`, + ) + console.error(`[SD] SDK Error details:`, { + errorCategory, + errorContext: errorContext.slice(0, 200), // Truncate for log readability + rawErrorCode, + sessionId: msgAny.session_id, + messageId: msgAny.message?.id, + fullMessage: JSON.stringify(msgAny, null, 2), + }) + safeEmit({ type: "finish" } as UIMessageChunk) + safeComplete() + return + } - // Transform and emit + accumulate - for (const chunk of transform(msg)) { - chunkCount++ - lastChunkType = chunk.type + // Track sessionId for rollback support (available on all messages) + if (msgAny.session_id) { + metadata.sessionId = msgAny.session_id + currentSessionId = msgAny.session_id // Share with cleanup + } - // For message-metadata, inject sdkMessageUuid before emitting - // so the frontend receives the full merged metadata in one chunk - if (chunk.type === "message-metadata" && metadata.sdkMessageUuid) { - chunk.messageMetadata = { ...chunk.messageMetadata, sdkMessageUuid: metadata.sdkMessageUuid } + // Track UUID from assistant messages for resumeSessionAt + if (msgAny.type === "assistant" && msgAny.uuid) { + lastAssistantUuid = msgAny.uuid } - // Use safeEmit to prevent throws when observer is closed - if (!safeEmit(chunk)) { - // Observer closed (user clicked Stop), break out of loop - console.log(`[SD] M:EMIT_CLOSED sub=${subId} type=${chunk.type} n=${chunkCount}`) - break + // When result arrives, assign the last assistant UUID to metadata + // It will be emitted as part of the merged message-metadata chunk below + if ( + msgAny.type === "result" && + historyEnabled && + lastAssistantUuid && + !abortController.signal.aborted + ) { + metadata.sdkMessageUuid = lastAssistantUuid } - // Accumulate based on chunk type - switch (chunk.type) { - case "text-delta": - currentText += chunk.delta - break - case "text-end": - if (currentText.trim()) { - parts.push({ type: "text", text: currentText }) - currentText = "" - } - break - case "tool-input-available": - // DEBUG: Log tool calls - console.log(`[SD] M:TOOL_CALL sub=${subId} toolName="${chunk.toolName}" mode=${input.mode} callId=${chunk.toolCallId}`) - - // Track ExitPlanMode toolCallId so we can stop when it completes - if (input.mode === "plan" && chunk.toolName === "ExitPlanMode") { - console.log(`[SD] M:PLAN_TOOL_DETECTED sub=${subId} callId=${chunk.toolCallId}`) - exitPlanModeToolCallId = chunk.toolCallId + // Debug: Log system messages from SDK + if (msgAny.type === "system") { + // Full log to see all fields including MCP errors + console.log( + `[SD] SYSTEM message: subtype=${msgAny.subtype}`, + JSON.stringify( + { + cwd: msgAny.cwd, + mcp_servers: msgAny.mcp_servers, + tools: msgAny.tools, + plugins: msgAny.plugins, + permissionMode: msgAny.permissionMode, + }, + null, + 2, + ), + ) + } + + // Transform and emit + accumulate + for (const chunk of transform(msg)) { + chunkCount++ + lastChunkType = chunk.type + + // For message-metadata, inject sdkMessageUuid before emitting + // so the frontend receives the full merged metadata in one chunk + if ( + chunk.type === "message-metadata" && + metadata.sdkMessageUuid + ) { + chunk.messageMetadata = { + ...chunk.messageMetadata, + sdkMessageUuid: metadata.sdkMessageUuid, } + } - parts.push({ - type: `tool-${chunk.toolName}`, - toolCallId: chunk.toolCallId, - toolName: chunk.toolName, - input: chunk.input, - state: "call", - startedAt: Date.now(), - }) - break - case "tool-output-available": - const toolPart = parts.find( - (p) => - p.type?.startsWith("tool-") && - p.toolCallId === chunk.toolCallId, + // Use safeEmit to prevent throws when observer is closed + if (!safeEmit(chunk)) { + // Observer closed (user clicked Stop), break out of loop + console.log( + `[SD] M:EMIT_CLOSED sub=${subId} type=${chunk.type} n=${chunkCount}`, ) - if (toolPart) { - toolPart.result = chunk.output - toolPart.output = chunk.output // Backwards compatibility for the UI that relies on output field - toolPart.state = "result" - - // Notify renderer about file changes for Write/Edit tools - if (toolPart.type === "tool-Write" || toolPart.type === "tool-Edit") { - const filePath = toolPart.input?.file_path - if (filePath) { - const windows = BrowserWindow.getAllWindows() - for (const win of windows) { - win.webContents.send("file-changed", { - filePath, - type: toolPart.type, - subChatId: input.subChatId - }) - } - } - } + break + } - // Check if ExitPlanMode just completed - stop the stream - if (exitPlanModeToolCallId && chunk.toolCallId === exitPlanModeToolCallId) { - console.log(`[SD] M:PLAN_FINISH sub=${subId} - ExitPlanMode completed, emitting finish`) - planCompleted = true - safeEmit({ type: "finish" } as UIMessageChunk) + // Accumulate based on chunk type + switch (chunk.type) { + case "text-delta": + currentText += chunk.delta + break + case "text-end": + if (currentText.trim()) { + parts.push({ type: "text", text: currentText }) + currentText = "" } - } - break - case "message-metadata": - metadata = { ...metadata, ...chunk.messageMetadata } - break - case "system-Compact": - // Add system-Compact to parts so it renders in the chat - // Find existing part by toolCallId or add new one - const existingCompact = parts.find( - (p) => p.type === "system-Compact" && p.toolCallId === chunk.toolCallId - ) - if (existingCompact) { - existingCompact.state = chunk.state - } else { + break + case "tool-input-available": + // DEBUG: Log tool calls + console.log( + `[SD] M:TOOL_CALL sub=${subId} toolName="${chunk.toolName}" mode=${input.mode} callId=${chunk.toolCallId}`, + ) + + // Track ExitPlanMode toolCallId so we can stop when it completes + if ( + input.mode === "plan" && + chunk.toolName === "ExitPlanMode" + ) { + console.log( + `[SD] M:PLAN_TOOL_DETECTED sub=${subId} callId=${chunk.toolCallId}`, + ) + exitPlanModeToolCallId = chunk.toolCallId + } + parts.push({ - type: "system-Compact", + type: `tool-${chunk.toolName}`, toolCallId: chunk.toolCallId, - state: chunk.state, + toolName: chunk.toolName, + input: chunk.input, + state: "call", + startedAt: Date.now(), }) - } - break + break + case "tool-output-available": + const toolPart = parts.find( + (p) => + p.type?.startsWith("tool-") && + p.toolCallId === chunk.toolCallId, + ) + if (toolPart) { + toolPart.result = chunk.output + toolPart.output = chunk.output // Backwards compatibility for the UI that relies on output field + toolPart.state = "result" + + // Notify renderer about file changes for Write/Edit tools + if ( + toolPart.type === "tool-Write" || + toolPart.type === "tool-Edit" + ) { + const filePath = toolPart.input?.file_path + if (filePath) { + const windows = BrowserWindow.getAllWindows() + for (const win of windows) { + win.webContents.send("file-changed", { + filePath, + type: toolPart.type, + subChatId: input.subChatId, + }) + } + } + } + } + break + case "message-metadata": + metadata = { ...metadata, ...chunk.messageMetadata } + break + } } - - // Break from chunk loop if plan is done - if (planCompleted) { - console.log(`[SD] M:PLAN_BREAK_CHUNK sub=${subId}`) + // Break from stream loop if observer closed (user clicked Stop) + if (!isObservableActive) { + console.log(`[SD] M:OBSERVER_CLOSED_STREAM sub=${subId}`) break } } - // Break from stream loop if observer closed (user clicked Stop) - if (!isObservableActive) { - console.log(`[SD] M:OBSERVER_CLOSED_STREAM sub=${subId}`) - break - } - // Break from stream loop if plan completed - if (planCompleted) { - console.log(`[SD] M:PLAN_BREAK_STREAM sub=${subId}`) - break + + // Warn if stream yielded no messages (offline mode issue) + const streamDuration = Date.now() - streamIterationStart + if (isUsingOllama) { + console.log(`[Ollama] ===== STREAM COMPLETED =====`) + console.log(`[Ollama] Total messages: ${messageCount}`) + console.log(`[Ollama] Duration: ${streamDuration}ms`) + console.log(`[Ollama] Chunks emitted: ${chunkCount}`) } - } - // Warn if stream yielded no messages (offline mode issue) - const streamDuration = Date.now() - streamIterationStart - if (isUsingOllama) { - console.log(`[Ollama] ===== STREAM COMPLETED =====`) - console.log(`[Ollama] Total messages: ${messageCount}`) - console.log(`[Ollama] Duration: ${streamDuration}ms`) - console.log(`[Ollama] Chunks emitted: ${chunkCount}`) - } + if (messageCount === 0) { + console.error( + `[claude] Stream yielded no messages - model not responding`, + ) + if (isUsingOllama) { + console.error(`[Ollama] ===== DIAGNOSIS =====`) + console.error( + `[Ollama] Problem: Stream completed but NO messages received from SDK`, + ) + console.error(`[Ollama] This usually means:`) + console.error( + `[Ollama] 1. Ollama doesn't support Anthropic Messages API format (/v1/messages)`, + ) + console.error( + `[Ollama] 2. Model failed to start generating (check Ollama logs: ollama logs)`, + ) + console.error( + `[Ollama] 3. Network issue between Claude SDK and Ollama`, + ) + console.error(`[Ollama] ===== NEXT STEPS =====`) + console.error( + `[Ollama] 1. Check if model works: curl http://localhost:11434/api/generate -d '{"model":"${finalCustomConfig?.model}","prompt":"test"}'`, + ) + console.error( + `[Ollama] 2. Check Ollama version supports Messages API`, + ) + console.error( + `[Ollama] 3. Try using a proxy that converts Anthropic API → Ollama format`, + ) + } + } else if (messageCount === 1 && isUsingOllama) { + console.warn( + `[Ollama] Only received 1 message (likely just init). No actual content generated.`, + ) + } + } catch (streamError) { + // This catches errors during streaming (like process exit) + const err = streamError as Error + const stderrOutput = stderrLines.join("\n") - if (messageCount === 0) { - console.error(`[claude] Stream yielded no messages - model not responding`) if (isUsingOllama) { - console.error(`[Ollama] ===== DIAGNOSIS =====`) - console.error(`[Ollama] Problem: Stream completed but NO messages received from SDK`) - console.error(`[Ollama] This usually means:`) - console.error(`[Ollama] 1. Ollama doesn't support Anthropic Messages API format (/v1/messages)`) - console.error(`[Ollama] 2. Model failed to start generating (check Ollama logs: ollama logs)`) - console.error(`[Ollama] 3. Network issue between Claude SDK and Ollama`) - console.error(`[Ollama] ===== NEXT STEPS =====`) - console.error(`[Ollama] 1. Check if model works: curl http://localhost:11434/api/generate -d '{"model":"${finalCustomConfig?.model}","prompt":"test"}'`) - console.error(`[Ollama] 2. Check Ollama version supports Messages API`) - console.error(`[Ollama] 3. Try using a proxy that converts Anthropic API → Ollama format`) + console.error(`[Ollama] ===== STREAM ERROR =====`) + console.error(`[Ollama] Error message: ${err.message}`) + console.error(`[Ollama] Error stack:`, err.stack) + console.error( + `[Ollama] Messages received before error: ${messageCount}`, + ) + if (stderrOutput) { + console.error( + `[Ollama] Claude binary stderr:`, + stderrOutput, + ) + } } - } else if (messageCount === 1 && isUsingOllama) { - console.warn(`[Ollama] Only received 1 message (likely just init). No actual content generated.`) - } - } catch (streamError) { - // This catches errors during streaming (like process exit) - const err = streamError as Error - const stderrOutput = stderrLines.join("\n") - if (isUsingOllama) { - console.error(`[Ollama] ===== STREAM ERROR =====`) - console.error(`[Ollama] Error message: ${err.message}`) - console.error(`[Ollama] Error stack:`, err.stack) - console.error(`[Ollama] Messages received before error: ${messageCount}`) - if (stderrOutput) { - console.error(`[Ollama] Claude binary stderr:`, stderrOutput) + // Build detailed error message with category + let errorContext = "Claude streaming error" + let errorCategory = "UNKNOWN" + + // Check for session-not-found error in stderr + const isSessionNotFound = stderrOutput?.includes( + "No conversation found with session ID", + ) + + 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`, + ) + db.update(subChats) + .set({ sessionId: null }) + .where(eq(subChats.id, input.subChatId)) + .run() + + errorContext = "Previous session expired. Please try again." + errorCategory = "SESSION_EXPIRED" + } else if (err.message?.includes("exited with code")) { + 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" + } else if ( + err.message?.includes("authentication") || + err.message?.includes("401") + ) { + errorContext = "Authentication failed - check your API key" + errorCategory = "AUTH_FAILURE" + } else if ( + err.message?.includes("invalid_api_key") || + err.message?.includes("Invalid API Key") || + stderrOutput?.includes("invalid_api_key") + ) { + errorContext = "Invalid API key" + errorCategory = "INVALID_API_KEY" + } else if ( + err.message?.includes("rate_limit") || + err.message?.includes("429") + ) { + errorContext = "Session limit reached" + errorCategory = "RATE_LIMIT" + } else if ( + err.message?.includes("network") || + err.message?.includes("ECONNREFUSED") || + err.message?.includes("fetch failed") + ) { + errorContext = "Network error - check your connection" + errorCategory = "NETWORK_ERROR" } - } - // Build detailed error message with category - let errorContext = "Claude streaming error" - let errorCategory = "UNKNOWN" - - // Check for session-not-found error in stderr - const isSessionNotFound = stderrOutput?.includes("No conversation found with session ID") - - 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`) - db.update(subChats) - .set({ sessionId: null }) - .where(eq(subChats.id, input.subChatId)) - .run() - - errorContext = "Previous session expired. Please try again." - errorCategory = "SESSION_EXPIRED" - } else if (err.message?.includes("exited with code")) { - 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" - } else if ( - err.message?.includes("authentication") || - err.message?.includes("401") - ) { - errorContext = "Authentication failed - check your API key" - errorCategory = "AUTH_FAILURE" - } else if ( - err.message?.includes("invalid_api_key") || - err.message?.includes("Invalid API Key") || - stderrOutput?.includes("invalid_api_key") - ) { - errorContext = "Invalid API key" - errorCategory = "INVALID_API_KEY" - } else if ( - err.message?.includes("rate_limit") || - err.message?.includes("429") - ) { - errorContext = "Session limit reached" - errorCategory = "RATE_LIMIT" - } else if ( - err.message?.includes("network") || - err.message?.includes("ECONNREFUSED") || - err.message?.includes("fetch failed") - ) { - errorContext = "Network error - check your connection" - errorCategory = "NETWORK_ERROR" - } + // Track error in Sentry (only if app is ready and Sentry is available) + if (app.isReady() && app.isPackaged) { + try { + const Sentry = await import("@sentry/electron/main") + Sentry.captureException(err, { + tags: { + errorCategory, + mode: input.mode, + }, + extra: { + context: errorContext, + cwd: input.cwd, + stderr: stderrOutput || "(no stderr captured)", + chatId: input.chatId, + subChatId: input.subChatId, + }, + }) + } catch { + // Sentry not available or failed to import - ignore + } + } - // Track error in Sentry (only if app is ready and Sentry is available) - if (app.isReady() && app.isPackaged) { - try { - const Sentry = await import("@sentry/electron/main") - Sentry.captureException(err, { - tags: { - errorCategory, - mode: input.mode, - }, - extra: { + // Send error with stderr output to frontend (only if not aborted by user) + if (!abortController.signal.aborted) { + safeEmit({ + type: "error", + errorText: stderrOutput + ? `${errorContext}: ${err.message}\n\nProcess output:\n${stderrOutput}` + : `${errorContext}: ${err.message}`, + debugInfo: { context: errorContext, + category: errorCategory, cwd: input.cwd, + mode: input.mode, stderr: stderrOutput || "(no stderr captured)", - chatId: input.chatId, - subChatId: input.subChatId, }, - }) - } catch { - // Sentry not available or failed to import - ignore + } as UIMessageChunk) } - } - // Send error with stderr output to frontend (only if not aborted by user) - if (!abortController.signal.aborted) { - safeEmit({ - type: "error", - errorText: stderrOutput - ? `${errorContext}: ${err.message}\n\nProcess output:\n${stderrOutput}` - : `${errorContext}: ${err.message}`, - debugInfo: { - context: errorContext, - category: errorCategory, - cwd: input.cwd, - mode: input.mode, - stderr: stderrOutput || "(no stderr captured)", - }, - } as UIMessageChunk) - } - - // ALWAYS save accumulated parts before returning (even on abort/error) - console.log(`[SD] M:CATCH_SAVE sub=${subId} aborted=${abortController.signal.aborted} parts=${parts.length}`) - if (currentText.trim()) { - parts.push({ type: "text", text: currentText }) - } - if (parts.length > 0) { - const assistantMessage = { - id: crypto.randomUUID(), - role: "assistant", - parts, - metadata, + // ALWAYS save accumulated parts before returning (even on abort/error) + console.log( + `[SD] M:CATCH_SAVE sub=${subId} aborted=${abortController.signal.aborted} parts=${parts.length}`, + ) + if (currentText.trim()) { + parts.push({ type: "text", text: currentText }) } - 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() - db.update(chats) - .set({ updatedAt: new Date() }) - .where(eq(chats.id, input.chatId)) - .run() - - // Create snapshot stash for rollback support (on error) - if (historyEnabled && metadata.sdkMessageUuid && input.cwd) { - await createRollbackStash(input.cwd, metadata.sdkMessageUuid) + if (parts.length > 0) { + const assistantMessage = { + id: crypto.randomUUID(), + role: "assistant", + parts, + 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() + db.update(chats) + .set({ updatedAt: new Date() }) + .where(eq(chats.id, input.chatId)) + .run() + + // Create snapshot stash for rollback support (on error) + if (historyEnabled && metadata.sdkMessageUuid && input.cwd) { + await createRollbackStash( + input.cwd, + metadata.sdkMessageUuid, + ) + } } + + console.log( + `[SD] M:END sub=${subId} reason=stream_error cat=${errorCategory} n=${chunkCount} last=${lastChunkType}`, + ) + safeEmit({ type: "finish" } as UIMessageChunk) + safeComplete() + return } - console.log(`[SD] M:END sub=${subId} reason=stream_error cat=${errorCategory} n=${chunkCount} last=${lastChunkType}`) - safeEmit({ type: "finish" } as UIMessageChunk) - safeComplete() - return - } + // Retry if policy violation detected (transient false positive) + // Escalating delay: 3s first retry, 6s second retry + if (policyRetryNeeded) { + const delayMs = policyRetryCount <= 1 ? 3000 : 6000 + console.log( + `[claude] Policy retry ${policyRetryCount}/${MAX_POLICY_RETRIES} - waiting ${delayMs / 1000}s`, + ) + await new Promise((resolve) => setTimeout(resolve, delayMs)) + continue + } + break + } // end policyRetryLoop // 6. Check if we got any response if (messageCount === 0 && !abortController.signal.aborted) { @@ -1965,7 +2415,9 @@ ${prompt} new Error("No response received from Claude"), "Empty response", ) - console.log(`[SD] M:END sub=${subId} reason=no_response n=${chunkCount}`) + console.log( + `[SD] M:END sub=${subId} reason=no_response n=${chunkCount}`, + ) safeEmit({ type: "finish" } as UIMessageChunk) safeComplete() return @@ -1973,7 +2425,9 @@ ${prompt} // 7. Save final messages to DB // ALWAYS save accumulated parts, even on abort (so user sees partial responses after reload) - console.log(`[SD] M:SAVE sub=${subId} aborted=${abortController.signal.aborted} parts=${parts.length}`) + console.log( + `[SD] M:SAVE sub=${subId} aborted=${abortController.signal.aborted} parts=${parts.length}`, + ) // Flush any remaining text if (currentText.trim()) { @@ -2025,11 +2479,15 @@ ${prompt} } const duration = ((Date.now() - streamStart) / 1000).toFixed(1) - console.log(`[SD] M:END sub=${subId} reason=ok n=${chunkCount} last=${lastChunkType} t=${duration}s`) + console.log( + `[SD] M:END sub=${subId} reason=ok n=${chunkCount} last=${lastChunkType} t=${duration}s`, + ) safeComplete() } catch (error) { const duration = ((Date.now() - streamStart) / 1000).toFixed(1) - console.log(`[SD] M:END sub=${subId} reason=unexpected_error n=${chunkCount} t=${duration}s`) + console.log( + `[SD] M:END sub=${subId} reason=unexpected_error n=${chunkCount} t=${duration}s`, + ) emitError(error, "Unexpected error") safeEmit({ type: "finish" } as UIMessageChunk) safeComplete() @@ -2040,7 +2498,9 @@ ${prompt} // Cleanup on unsubscribe return () => { - console.log(`[SD] M:CLEANUP sub=${subId} sessionId=${currentSessionId || 'none'}`) + console.log( + `[SD] M:CLEANUP sub=${subId} sessionId=${currentSessionId || "none"}`, + ) isObservableActive = false // Prevent emit after unsubscribe abortController.abort() activeSessions.delete(input.subChatId) @@ -2070,21 +2530,26 @@ ${prompt} try { const config = await readClaudeConfig() const globalServers = config.mcpServers || {} - const projectMcpServers = getProjectMcpServers(config, input.projectPath) || {} + const projectMcpServers = + getProjectMcpServers(config, input.projectPath) || {} // Merge global + project (project overrides global) const merged = { ...globalServers, ...projectMcpServers } // Add plugin MCP servers (enabled + approved only) - const [enabledPluginSources, pluginMcpConfigs, approvedServers] = await Promise.all([ - getEnabledPlugins(), - discoverPluginMcpServers(), - getApprovedPluginMcpServers(), - ]) + const [enabledPluginSources, pluginMcpConfigs, approvedServers] = + await Promise.all([ + getEnabledPlugins(), + discoverPluginMcpServers(), + getApprovedPluginMcpServers(), + ]) for (const pluginConfig of pluginMcpConfigs) { - if (!enabledPluginSources.includes(pluginConfig.pluginSource)) continue - for (const [name, serverConfig] of Object.entries(pluginConfig.mcpServers)) { + if (!enabledPluginSources.includes(pluginConfig.pluginSource)) + continue + for (const [name, serverConfig] of Object.entries( + pluginConfig.mcpServers, + )) { if (!merged[name]) { const identifier = `${pluginConfig.pluginSource}:${name}` if (approvedServers.includes(identifier)) { @@ -2095,22 +2560,28 @@ ${prompt} } // Convert to array format - determine status from config (no caching) - const mcpServers = Object.entries(merged).map(([name, serverConfig]) => { - const configObj = serverConfig as Record - const status = getServerStatusFromConfig(configObj) - const hasUrl = !!configObj.url + const mcpServers = Object.entries(merged).map( + ([name, serverConfig]) => { + const configObj = serverConfig as Record + const status = getServerStatusFromConfig(configObj) + const hasUrl = !!configObj.url - return { - name, - status, - config: { ...configObj, _hasUrl: hasUrl }, - } - }) + return { + name, + status, + config: { ...configObj, _hasUrl: hasUrl }, + } + }, + ) return { mcpServers, projectPath: input.projectPath } } catch (error) { console.error("[getMcpConfig] Error reading config:", error) - return { mcpServers: [], projectPath: input.projectPath, error: String(error) } + return { + mcpServers: [], + projectPath: input.projectPath, + error: String(error), + } } }), @@ -2134,7 +2605,6 @@ ${prompt} clearPendingApprovals("Session cancelled.", input.subChatId) } - return { cancelled: !!controller } }), @@ -2172,10 +2642,12 @@ ${prompt} * Fetches OAuth metadata internally when needed */ startMcpOAuth: publicProcedure - .input(z.object({ - serverName: z.string(), - projectPath: z.string(), - })) + .input( + z.object({ + serverName: z.string(), + projectPath: z.string(), + }), + ) .mutation(async ({ input }) => { return startMcpOAuth(input.serverName, input.projectPath) }), @@ -2184,27 +2656,37 @@ ${prompt} * Get MCP auth status for a server */ getMcpAuthStatus: publicProcedure - .input(z.object({ - serverName: z.string(), - projectPath: z.string(), - })) + .input( + z.object({ + serverName: z.string(), + projectPath: z.string(), + }), + ) .query(async ({ input }) => { return getMcpAuthStatus(input.serverName, input.projectPath) }), addMcpServer: publicProcedure - .input(z.object({ - name: z.string().min(1).regex(/^[a-zA-Z0-9_-]+$/, "Name must contain only letters, numbers, underscores, and hyphens"), - scope: z.enum(["global", "project"]), - projectPath: z.string().optional(), - transport: z.enum(["stdio", "http"]), - command: z.string().optional(), - args: z.array(z.string()).optional(), - env: z.record(z.string()).optional(), - url: z.string().url().optional(), - authType: z.enum(["none", "oauth", "bearer"]).optional(), - bearerToken: z.string().optional(), - })) + .input( + z.object({ + name: z + .string() + .min(1) + .regex( + /^[a-zA-Z0-9_-]+$/, + "Name must contain only letters, numbers, underscores, and hyphens", + ), + scope: z.enum(["global", "project"]), + projectPath: z.string().optional(), + transport: z.enum(["stdio", "http"]), + command: z.string().optional(), + args: z.array(z.string()).optional(), + env: z.record(z.string()).optional(), + url: z.string().url().optional(), + authType: z.enum(["none", "oauth", "bearer"]).optional(), + bearerToken: z.string().optional(), + }), + ) .mutation(async ({ input }) => { const serverName = input.name.trim() @@ -2233,7 +2715,9 @@ ${prompt} serverConfig.authType = input.authType } if (input.bearerToken) { - serverConfig.headers = { Authorization: `Bearer ${input.bearerToken}` } + serverConfig.headers = { + Authorization: `Bearer ${input.bearerToken}`, + } } } @@ -2242,7 +2726,9 @@ ${prompt} const projectPath = input.projectPath if (input.scope === "project" && projectPath) { if (existingConfig.projects?.[projectPath]?.mcpServers?.[serverName]) { - throw new Error(`Server "${serverName}" already exists in this project`) + throw new Error( + `Server "${serverName}" already exists in this project`, + ) } } else { if (existingConfig.mcpServers?.[serverName]) { @@ -2250,29 +2736,40 @@ ${prompt} } } - const config = updateMcpServerConfig(existingConfig, input.scope === "project" ? projectPath ?? null : null, serverName, serverConfig) + const config = updateMcpServerConfig( + existingConfig, + input.scope === "project" ? (projectPath ?? null) : null, + serverName, + serverConfig, + ) await writeClaudeConfig(config) return { success: true, name: serverName } }), updateMcpServer: publicProcedure - .input(z.object({ - name: z.string(), - scope: z.enum(["global", "project"]), - projectPath: z.string().optional(), - newName: z.string().regex(/^[a-zA-Z0-9_-]+$/).optional(), - command: z.string().optional(), - args: z.array(z.string()).optional(), - env: z.record(z.string()).optional(), - url: z.string().url().optional(), - authType: z.enum(["none", "oauth", "bearer"]).optional(), - bearerToken: z.string().optional(), - disabled: z.boolean().optional(), - })) + .input( + z.object({ + name: z.string(), + scope: z.enum(["global", "project"]), + projectPath: z.string().optional(), + newName: z + .string() + .regex(/^[a-zA-Z0-9_-]+$/) + .optional(), + command: z.string().optional(), + args: z.array(z.string()).optional(), + env: z.record(z.string()).optional(), + url: z.string().url().optional(), + authType: z.enum(["none", "oauth", "bearer"]).optional(), + bearerToken: z.string().optional(), + disabled: z.boolean().optional(), + }), + ) .mutation(async ({ input }) => { const config = await readClaudeConfig() - const projectPath = input.scope === "project" ? input.projectPath : undefined + const projectPath = + input.scope === "project" ? input.projectPath : undefined // Check server exists let servers: Record | undefined @@ -2292,8 +2789,17 @@ ${prompt} if (servers[input.newName]) { throw new Error(`Server "${input.newName}" already exists`) } - const updated = removeMcpServerConfig(config, projectPath ?? null, input.name) - const finalConfig = updateMcpServerConfig(updated, projectPath ?? null, input.newName, existing) + const updated = removeMcpServerConfig( + config, + projectPath ?? null, + input.name, + ) + const finalConfig = updateMcpServerConfig( + updated, + projectPath ?? null, + input.newName, + existing, + ) await writeClaudeConfig(finalConfig) return { success: true, name: input.newName } } @@ -2323,21 +2829,29 @@ ${prompt} } const merged = { ...existing, ...update } - const updatedConfig = updateMcpServerConfig(config, projectPath ?? null, input.name, merged) + const updatedConfig = updateMcpServerConfig( + config, + projectPath ?? null, + input.name, + merged, + ) await writeClaudeConfig(updatedConfig) return { success: true, name: input.name } }), removeMcpServer: publicProcedure - .input(z.object({ - name: z.string(), - scope: z.enum(["global", "project"]), - projectPath: z.string().optional(), - })) + .input( + z.object({ + name: z.string(), + scope: z.enum(["global", "project"]), + projectPath: z.string().optional(), + }), + ) .mutation(async ({ input }) => { const config = await readClaudeConfig() - const projectPath = input.scope === "project" ? input.projectPath : undefined + const projectPath = + input.scope === "project" ? input.projectPath : undefined // Check server exists let servers: Record | undefined @@ -2350,22 +2864,29 @@ ${prompt} throw new Error(`Server "${input.name}" not found`) } - const updated = removeMcpServerConfig(config, projectPath ?? null, input.name) + const updated = removeMcpServerConfig( + config, + projectPath ?? null, + input.name, + ) await writeClaudeConfig(updated) return { success: true } }), setMcpBearerToken: publicProcedure - .input(z.object({ - name: z.string(), - scope: z.enum(["global", "project"]), - projectPath: z.string().optional(), - token: z.string(), - })) + .input( + z.object({ + name: z.string(), + scope: z.enum(["global", "project"]), + projectPath: z.string().optional(), + token: z.string(), + }), + ) .mutation(async ({ input }) => { const config = await readClaudeConfig() - const projectPath = input.scope === "project" ? input.projectPath : undefined + const projectPath = + input.scope === "project" ? input.projectPath : undefined // Check server exists let servers: Record | undefined @@ -2385,7 +2906,12 @@ ${prompt} headers: { Authorization: `Bearer ${input.token}` }, } - const updatedConfig = updateMcpServerConfig(config, projectPath ?? null, input.name, updated) + const updatedConfig = updateMcpServerConfig( + config, + projectPath ?? null, + input.name, + updated, + ) await writeClaudeConfig(updatedConfig) return { success: true } @@ -2394,16 +2920,19 @@ ${prompt} getPendingPluginMcpApprovals: publicProcedure .input(z.object({ projectPath: z.string().optional() })) .query(async ({ input }) => { - const [enabledPluginSources, pluginMcpConfigs, approvedServers] = await Promise.all([ - getEnabledPlugins(), - discoverPluginMcpServers(), - getApprovedPluginMcpServers(), - ]) + const [enabledPluginSources, pluginMcpConfigs, approvedServers] = + await Promise.all([ + getEnabledPlugins(), + discoverPluginMcpServers(), + getApprovedPluginMcpServers(), + ]) // Read global/project servers for conflict check const config = await readClaudeConfig() const globalServers = config.mcpServers || {} - const projectServers = input.projectPath ? getProjectMcpServers(config, input.projectPath) || {} : {} + const projectServers = input.projectPath + ? getProjectMcpServers(config, input.projectPath) || {} + : {} const pending: Array<{ pluginSource: string @@ -2415,9 +2944,15 @@ ${prompt} for (const pluginConfig of pluginMcpConfigs) { if (!enabledPluginSources.includes(pluginConfig.pluginSource)) continue - for (const [name, serverConfig] of Object.entries(pluginConfig.mcpServers)) { + for (const [name, serverConfig] of Object.entries( + pluginConfig.mcpServers, + )) { const identifier = `${pluginConfig.pluginSource}:${name}` - if (!approvedServers.includes(identifier) && !globalServers[name] && !projectServers[name]) { + if ( + !approvedServers.includes(identifier) && + !globalServers[name] && + !projectServers[name] + ) { pending.push({ pluginSource: pluginConfig.pluginSource, serverName: name, diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 727f272c1..07931cc7d 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -18,6 +18,12 @@ export interface DesktopUser { username: string | null } +export interface WorktreeSetupFailurePayload { + kind: "create-failed" | "setup-failed" + message: string + projectId: string +} + export interface DesktopApi { // Platform info platform: NodeJS.Platform @@ -81,6 +87,9 @@ export interface DesktopApi { // Shortcuts onShortcutNewAgent: (callback: () => void) => () => void + + // Worktree setup failures + onWorktreeSetupFailed: (callback: (payload: WorktreeSetupFailurePayload) => void) => () => void } declare global { diff --git a/src/preload/index.ts b/src/preload/index.ts index 5152745df..bea19eef0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -214,6 +214,13 @@ contextBridge.exposeInMainWorld("desktopApi", { return () => ipcRenderer.removeListener("git:status-changed", handler) }, + // Worktree setup failure events + onWorktreeSetupFailed: (callback: (data: { kind: "create-failed" | "setup-failed"; message: string; projectId: string }) => void) => { + const handler = (_event: unknown, data: { kind: "create-failed" | "setup-failed"; message: string; projectId: string }) => callback(data) + ipcRenderer.on("worktree:setup-failed", handler) + return () => ipcRenderer.removeListener("worktree:setup-failed", handler) + }, + // Subscribe to git watcher for a worktree (from renderer) subscribeToGitWatcher: (worktreePath: string) => ipcRenderer.invoke("git:subscribe-watcher", worktreePath), unsubscribeFromGitWatcher: (worktreePath: string) => ipcRenderer.invoke("git:unsubscribe-watcher", worktreePath), 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 df286431b..f028dee42 100644 --- a/src/renderer/components/dialogs/settings-tabs/agents-beta-tab.tsx +++ b/src/renderer/components/dialogs/settings-tabs/agents-beta-tab.tsx @@ -367,8 +367,8 @@ export function AgentsBetaTab() {

+ {/* Early Access section hidden until beta-mac.yml is published to CDN
- {/* Early Access Toggle */}
@@ -387,7 +387,6 @@ export function AgentsBetaTab() { />
- {/* Version & Check */}
@@ -414,6 +413,7 @@ export function AgentsBetaTab() {
+ */}
) diff --git a/src/renderer/components/dialogs/settings-tabs/agents-custom-agents-tab.tsx b/src/renderer/components/dialogs/settings-tabs/agents-custom-agents-tab.tsx index 4e2d9df1c..2d24a631d 100644 --- a/src/renderer/components/dialogs/settings-tabs/agents-custom-agents-tab.tsx +++ b/src/renderer/components/dialogs/settings-tabs/agents-custom-agents-tab.tsx @@ -131,9 +131,9 @@ function AgentDetail({ Inherit from parent - Sonnet - Opus - Haiku + Sonnet 4.5 + Opus 4.6 + Haiku 4.5 @@ -250,9 +250,9 @@ function CreateAgentForm({ Inherit from parent - Sonnet - Opus - Haiku + Sonnet 4.5 + Opus 4.6 + Haiku 4.5 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 2267689d5..079b05008 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 @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react" import { useListKeyboardNav } from "./use-list-keyboard-nav" -import { useSetAtom } from "jotai" +import { useAtomValue, useSetAtom } from "jotai" import { trpc } from "../../../lib/trpc" import { Button, buttonVariants } from "../../ui/button" import { Input } from "../../ui/input" @@ -585,6 +585,7 @@ function ProjectDetail({ projectId }: { projectId: string }) { // --- Main Two-Panel Component --- export function AgentsProjectsTab() { + const selectedProject = useAtomValue(selectedProjectAtom) const [selectedProjectId, setSelectedProjectId] = useState(null) const [searchQuery, setSearchQuery] = useState("") const searchInputRef = useRef(null) @@ -645,6 +646,12 @@ export function AgentsProjectsTab() { } }, [projects, selectedProjectId, isLoading]) + // Sync selection from global selectedProject (e.g., toast action) + useEffect(() => { + if (!selectedProject?.id) return + setSelectedProjectId(selectedProject.id) + }, [selectedProject?.id]) + return (
{/* Left sidebar - project list */} diff --git a/src/renderer/features/agents/atoms/index.ts b/src/renderer/features/agents/atoms/index.ts index 6a6ed4c88..0ed6ffa63 100644 --- a/src/renderer/features/agents/atoms/index.ts +++ b/src/renderer/features/agents/atoms/index.ts @@ -557,7 +557,7 @@ export const selectedCommitAtom = atom(null) // Pending PR message to send to chat // Set by ChatView when "Create PR" is clicked, consumed by ChatViewInner -export const pendingPrMessageAtom = atom(null) +export const pendingPrMessageAtom = atom<{ message: string; subChatId: string } | null>(null) // Pending Review message to send to chat // Set by ChatView when "Review" is clicked, consumed by ChatViewInner diff --git a/src/renderer/features/agents/components/open-locally-dialog.tsx b/src/renderer/features/agents/components/open-locally-dialog.tsx index b10b9f6b9..762b0ac3e 100644 --- a/src/renderer/features/agents/components/open-locally-dialog.tsx +++ b/src/renderer/features/agents/components/open-locally-dialog.tsx @@ -7,11 +7,10 @@ import { Button } from "../../../components/ui/button" import { trpc } from "../../../lib/trpc" import { toast } from "sonner" import { useSetAtom } from "jotai" -import { selectedAgentChatIdAtom } from "../atoms" +import { selectedAgentChatIdAtom, desktopViewAtom } from "../atoms" import { chatSourceModeAtom } from "../../../lib/atoms" import type { RemoteChat } from "../../../lib/remote-api" import { Folder, Download, Check } from "lucide-react" -import { agentChatStore } from "../stores/agent-chat-store" interface Project { id: string @@ -45,6 +44,7 @@ export function OpenLocallyDialog({ const openAtRef = useRef(0) const setSelectedChatId = useSetAtom(selectedAgentChatIdAtom) const setChatSourceMode = useSetAtom(chatSourceModeAtom) + const setDesktopView = useSetAtom(desktopViewAtom) const utils = trpc.useUtils() // For multiple projects view @@ -54,22 +54,17 @@ export function OpenLocallyDialog({ const locateMutation = trpc.projects.locateAndAddProject.useMutation() const importMutation = trpc.sandboxImport.importSandboxChat.useMutation({ - onSuccess: async (result) => { + onSuccess: (result) => { toast.success("Opened locally") - // 1. Clear stale Chat instances from cache - agentChatStore.clear() - - // 2. Invalidate list queries + // Invalidate list queries so sidebar updates utils.chats.list.invalidate() utils.projects.list.invalidate() - // 3. Prefetch: Wait for chat data to be in cache before switching - await utils.chats.get.fetch({ id: result.chatId }) - - // 4. Now safe to switch - data is ready + // Switch to local chat view — let the normal architecture load the chat setChatSourceMode("local") setSelectedChatId(result.chatId) + setDesktopView(null) onClose() }, onError: (error) => { @@ -80,22 +75,17 @@ export function OpenLocallyDialog({ const pickDestMutation = trpc.projects.pickCloneDestination.useMutation() const cloneMutation = trpc.sandboxImport.cloneFromSandbox.useMutation({ - onSuccess: async (result) => { + onSuccess: (result) => { toast.success("Cloned and opened locally") - // 1. Clear stale Chat instances from cache - agentChatStore.clear() - - // 2. Invalidate list queries - utils.projects.list.invalidate() + // Invalidate list queries so sidebar updates utils.chats.list.invalidate() + utils.projects.list.invalidate() - // 3. Prefetch: Wait for chat data to be in cache before switching - await utils.chats.get.fetch({ id: result.chatId }) - - // 4. Now safe to switch - data is ready + // Switch to local chat view — let the normal architecture load the chat setChatSourceMode("local") setSelectedChatId(result.chatId) + setDesktopView(null) onClose() }, onError: (error) => { diff --git a/src/renderer/features/agents/components/queue-processor.tsx b/src/renderer/features/agents/components/queue-processor.tsx index 992d4b698..72ae9a721 100644 --- a/src/renderer/features/agents/components/queue-processor.tsx +++ b/src/renderer/features/agents/components/queue-processor.tsx @@ -141,6 +141,8 @@ export function QueueProcessor() { toast.error("Failed to send queued message. It will be retried.") } finally { processingRef.current.delete(subChatId) + // Re-kick after releasing lock to avoid lost wakeups + setTimeout(checkAllQueues, 0) } } @@ -162,7 +164,7 @@ export function QueueProcessor() { } // Check all queues and schedule processing for ready sub-chats - const checkAllQueues = () => { + function checkAllQueues() { const queues = useMessageQueueStore.getState().queues for (const subChatId of Object.keys(queues)) { diff --git a/src/renderer/features/agents/hooks/use-auto-import.ts b/src/renderer/features/agents/hooks/use-auto-import.ts index 878884338..6ebbe3c47 100644 --- a/src/renderer/features/agents/hooks/use-auto-import.ts +++ b/src/renderer/features/agents/hooks/use-auto-import.ts @@ -2,7 +2,7 @@ import { useCallback } from "react" import { trpc } from "../../../lib/trpc" import { toast } from "sonner" import { useSetAtom } from "jotai" -import { selectedAgentChatIdAtom } from "../atoms" +import { selectedAgentChatIdAtom, desktopViewAtom } from "../atoms" import { chatSourceModeAtom } from "../../../lib/atoms" import type { RemoteChat } from "../../../lib/remote-api" @@ -17,14 +17,21 @@ interface Project { export function useAutoImport() { const setSelectedChatId = useSetAtom(selectedAgentChatIdAtom) const setChatSourceMode = useSetAtom(chatSourceModeAtom) + const setDesktopView = useSetAtom(desktopViewAtom) const utils = trpc.useUtils() const importMutation = trpc.sandboxImport.importSandboxChat.useMutation({ onSuccess: (result) => { toast.success("Opened locally") + + // Invalidate list queries so sidebar updates + utils.chats.list.invalidate() + utils.projects.list.invalidate() + + // Switch to local chat view — let the normal architecture load the chat setChatSourceMode("local") setSelectedChatId(result.chatId) - utils.chats.list.invalidate() + setDesktopView(null) }, onError: (error) => { toast.error(`Import failed: ${error.message}`) diff --git a/src/renderer/features/agents/hooks/use-changed-files-tracking.ts b/src/renderer/features/agents/hooks/use-changed-files-tracking.ts index c0cdd41fe..f20c674f0 100644 --- a/src/renderer/features/agents/hooks/use-changed-files-tracking.ts +++ b/src/renderer/features/agents/hooks/use-changed-files-tracking.ts @@ -29,6 +29,7 @@ export function useChangedFilesTracking( subChatId: string, isStreaming: boolean = false, chatId?: string, + projectPath?: string, ) { const setSubChatFiles = useSetAtom(subChatFilesAtom) const setSubChatToChatMap = useSetAtom(subChatToChatMapAtom) @@ -37,6 +38,12 @@ export function useChangedFilesTracking( const getDisplayPath = useCallback((filePath: string): string => { if (!filePath) return "" + // Strip project path prefix first (most reliable for desktop) + if (projectPath && filePath.startsWith(projectPath)) { + const relative = filePath.slice(projectPath.length) + return relative.startsWith("/") ? relative.slice(1) : relative + } + // Use constant from codesandbox-constants const prefixes = [`${REPO_ROOT_PATH}/`, "/project/sandbox/", "/project/"] @@ -64,7 +71,7 @@ export function useChangedFilesTracking( } return filePath - }, []) + }, [projectPath]) // Calculate diff stats from old_string and new_string // For Edit: old_string lines are deletions, new_string lines are additions diff --git a/src/renderer/features/agents/lib/ipc-chat-transport.ts b/src/renderer/features/agents/lib/ipc-chat-transport.ts index 9a36a7544..00a1b8845 100644 --- a/src/renderer/features/agents/lib/ipc-chat-transport.ts +++ b/src/renderer/features/agents/lib/ipc-chat-transport.ts @@ -113,9 +113,8 @@ const ERROR_TOAST_CONFIG: Record< description: "Your session may have expired. Try logging in again.", }, USAGE_POLICY_VIOLATION: { - title: "Request declined", - // description will be set from chunk.errorText which contains the full API error message - description: "", + title: "Anthropic API hiccup", + description: "The request was rejected by Anthropic's servers. Please try again shortly.", }, // SDK_ERROR and other unknown errors use chunk.errorText for description } @@ -270,16 +269,24 @@ export class IPCChatTransport implements ChatTransport { } // Handle compacting status - track in atom for UI display - if (chunk.type === "system-Compact") { + if ( + (chunk.type === "tool-input-start" && chunk.toolName === "Compact") || + (chunk.type === "tool-input-available" && chunk.toolName === "Compact") + ) { const compacting = appStore.get(compactingSubChatsAtom) const newCompacting = new Set(compacting) - if (chunk.state === "input-streaming") { - // Compacting started - newCompacting.add(this.config.subChatId) - } else { - // Compacting finished (output-available) - newCompacting.delete(this.config.subChatId) - } + // Compacting started + newCompacting.add(this.config.subChatId) + appStore.set(compactingSubChatsAtom, newCompacting) + } + if ( + (chunk.type === "tool-output-available" && chunk.toolCallId?.startsWith("compact-")) || + (chunk.type === "tool-output-error" && chunk.toolCallId?.startsWith("compact-")) + ) { + const compacting = appStore.get(compactingSubChatsAtom) + const newCompacting = new Set(compacting) + // Compacting finished + newCompacting.delete(this.config.subChatId) appStore.set(compactingSubChatsAtom, newCompacting) } @@ -345,6 +352,15 @@ export class IPCChatTransport implements ChatTransport { return } + // Handle retry notification - show friendly toast instead of scary error + if (chunk.type === "retry-notification") { + toast.info("Retrying request", { + description: chunk.message || "Request was unsuccessful, trying again...", + duration: 4000, + }) + return // don't enqueue retry-notification as a stream chunk + } + // Handle errors - show toast to user FIRST before anything else if (chunk.type === "error") { const category = chunk.debugInfo?.category || "UNKNOWN" diff --git a/src/renderer/features/agents/lib/models.ts b/src/renderer/features/agents/lib/models.ts index fb43b940d..12924b429 100644 --- a/src/renderer/features/agents/lib/models.ts +++ b/src/renderer/features/agents/lib/models.ts @@ -1,5 +1,5 @@ export const CLAUDE_MODELS = [ - { id: "opus", name: "Opus" }, - { id: "sonnet", name: "Sonnet" }, - { id: "haiku", name: "Haiku" }, + { id: "opus", name: "Opus 4.6" }, + { id: "sonnet", name: "Sonnet 4.5" }, + { id: "haiku", name: "Haiku 4.5" }, ] diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index e57be3598..e3d51f83b 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -80,6 +80,8 @@ import { trpc, trpcClient } from "../../../lib/trpc" import { cn } from "../../../lib/utils" import { isDesktopApp } from "../../../lib/utils/platform" import { ChangesPanel } from "../../changes" +import { useCommitActions } from "../../changes/components/commit-input" +import { usePushAction } from "../../changes/hooks/use-push-action" import { DiffCenterPeekDialog } from "../../changes/components/diff-center-peek-dialog" import { DiffFullPageView } from "../../changes/components/diff-full-page-view" import { DiffSidebarHeader } from "../../changes/components/diff-sidebar-header" @@ -184,7 +186,7 @@ import { } from "../search" import { agentChatStore } from "../stores/agent-chat-store" import { EMPTY_QUEUE, useMessageQueueStore } from "../stores/message-queue-store" -import { clearSubChatCaches, isRollingBackAtom, rollbackHandlerAtom, syncMessagesWithStatusAtom } from "../stores/message-store" +import { clearSubChatCaches, isRollingBackAtom, syncMessagesWithStatusAtom } from "../stores/message-store" import { useStreamingStatusStore } from "../stores/streaming-status-store" import { useAgentSubChatStore, @@ -343,9 +345,9 @@ const CodexIcon = (props: React.SVGProps) => ( // Model options for Claude Code const claudeModels = [ - { id: "opus", name: "Opus" }, - { id: "sonnet", name: "Sonnet" }, - { id: "haiku", name: "Haiku" }, + { id: "opus", name: "Opus 4.6" }, + { id: "sonnet", name: "Sonnet 4.5" }, + { id: "haiku", name: "Haiku 4.5" }, ] // Agent providers @@ -867,9 +869,14 @@ const ScrollToBottomButton = memo(function ScrollToBottomButton({ transition={{ duration: 0.2, ease: [0.23, 1, 0.32, 1] }} onClick={onScrollToBottom} className={cn( - "absolute right-4 p-2 rounded-full bg-background border border-border shadow-md hover:bg-accent active:scale-[0.97] transition-colors z-20", - hasStackedCards ? "bottom-44 sm:bottom-36" : "bottom-32 sm:bottom-24" + "absolute p-2 rounded-full bg-background border border-border shadow-md hover:bg-accent active:scale-[0.97] transition-[color,background-color,bottom] duration-200 z-20", )} + style={{ + right: "0.75rem", + // Wide screen (container > 48rem): button sits in bottom-right corner + // Narrow screen (container <= 48rem): button lifts above the input + bottom: "clamp(0.75rem, (48rem - var(--chat-container-width, 0px)) * 1000, calc(var(--chat-input-height, 4rem) + 1rem))", + }} aria-label="Scroll to bottom" > @@ -1054,6 +1061,8 @@ interface DiffSidebarContentProps { onCreatePr?: () => void // Called after successful commit to reset diff view state onCommitSuccess?: () => void + // Called after discarding/deleting changes to refresh diff + onDiscardSuccess?: () => void // Subchats with changed files for filtering subChats?: Array<{ id: string; name: string; filePaths: string[]; fileCount: number }> // Initial subchat filter (e.g., from Review button) @@ -1117,6 +1126,7 @@ const DiffSidebarContent = memo(function DiffSidebarContent({ diffMode, setDiffMode, onCreatePr, + onDiscardSuccess, subChats = [], }: Omit) { // Get values from context instead of props @@ -1282,6 +1292,7 @@ const DiffSidebarContent = memo(function DiffSidebarContent({ onFileOpenPinned={() => {}} onCreatePr={onCreatePr} onCommitSuccess={handleCommitSuccess} + onDiscardSuccess={onDiscardSuccess} subChats={subChats} initialSubChatFilter={filteredSubChatId} chatId={chatId} @@ -1395,6 +1406,7 @@ const DiffSidebarContent = memo(function DiffSidebarContent({ onFileOpenPinned={() => {}} onCreatePr={onCreatePr} onCommitSuccess={handleCommitSuccess} + onDiscardSuccess={onDiscardSuccess} subChats={subChats} initialSubChatFilter={filteredSubChatId} chatId={chatId} @@ -1605,7 +1617,7 @@ const DiffStateProvider = memo(function DiffStateProvider({ }) setTimeout(() => { fetchDiffStats() - }, 500) + }, 2000) }, [setSelectedFilePath, setFilteredDiffFiles, setParsedFileDiffs, setDiffContent, setPrefetchedFileContents, setDiffStats, fetchDiffStats]) const handleCloseDiff = useCallback(() => { @@ -1691,6 +1703,7 @@ interface DiffSidebarRendererProps { isCommittingToPr: boolean subChatsWithFiles: Array<{ id: string; name: string; filePaths: string[]; fileCount: number }> setDiffStats: (stats: { isLoading: boolean; hasChanges: boolean; fileCount: number; additions: number; deletions: number }) => void + onDiscardSuccess?: () => void } const DiffSidebarRenderer = memo(function DiffSidebarRenderer({ @@ -1737,10 +1750,25 @@ const DiffSidebarRenderer = memo(function DiffSidebarRenderer({ isCommittingToPr, subChatsWithFiles, setDiffStats, + onDiscardSuccess, }: DiffSidebarRendererProps) { // Get callbacks and state from context const { handleCloseDiff, viewedCount, handleViewedCountChange } = useDiffState() + const handleReviewWithAI = useCallback(() => { + if (diffDisplayMode !== "side-peek") { + handleCloseDiff() + } + handleReview() + }, [diffDisplayMode, handleCloseDiff, handleReview]) + + const handleCreatePrWithAI = useCallback(() => { + if (diffDisplayMode !== "side-peek") { + handleCloseDiff() + } + handleCreatePr() + }, [diffDisplayMode, handleCloseDiff, handleCreatePr]) + // Width for responsive layouts - use stored width for sidebar, fixed for dialog/fullpage const effectiveWidth = diffDisplayMode === "side-peek" ? diffSidebarWidth @@ -1766,11 +1794,11 @@ const DiffSidebarRenderer = memo(function DiffSidebarRenderer({ isSyncStatusLoading={isGitStatusLoading} aheadOfDefault={gitStatus?.ahead ?? 0} behindDefault={gitStatus?.behind ?? 0} - onReview={handleReview} + onReview={handleReviewWithAI} isReviewing={isReviewing} onCreatePr={handleCreatePrDirect} isCreatingPr={isCreatingPr} - onCreatePrWithAI={handleCreatePr} + onCreatePrWithAI={handleCreatePrWithAI} isCreatingPrWithAI={isCreatingPr} onMergePr={handleMergePr} isMergingPr={mergePrMutation.isPending} @@ -1826,6 +1854,7 @@ const DiffSidebarRenderer = memo(function DiffSidebarRenderer({ diffMode={diffMode} setDiffMode={setDiffMode} onCreatePr={handleCreatePrDirect} + onDiscardSuccess={onDiscardSuccess} subChats={subChatsWithFiles} />
@@ -2491,20 +2520,25 @@ const ChatViewInner = memo(function ChatViewInner({ const [pendingPrMessage, setPendingPrMessage] = useAtom(pendingPrMessageAtom) useEffect(() => { - if (pendingPrMessage && !isStreaming && isActive) { + if (pendingPrMessage?.subChatId === subChatId && !isStreaming && isActive) { // Clear the pending message immediately to prevent double-sending setPendingPrMessage(null) // Send the message to Claude sendMessage({ role: "user", - parts: [{ type: "text", text: pendingPrMessage }], + parts: [{ type: "text", text: pendingPrMessage.message }], }) // Reset creating PR state after message is sent setIsCreatingPr(false) + + // Ensure the target sub-chat is focused after sending + const store = useAgentSubChatStore.getState() + store.addToOpenSubChats(subChatId) + store.setActiveSubChat(subChatId) } - }, [pendingPrMessage, isStreaming, isActive, sendMessage, setPendingPrMessage]) + }, [pendingPrMessage, isStreaming, isActive, sendMessage, setPendingPrMessage, setIsCreatingPr, subChatId]) // Watch for pending Review message and send it const [pendingReviewMessage, setPendingReviewMessage] = useAtom( @@ -2575,18 +2609,42 @@ const ChatViewInner = memo(function ChatViewInner({ // Pre-compute token data for ChatInputArea to avoid passing unstable messages array // This prevents ChatInputArea from re-rendering on every streaming chunk + // NOTE: Tokens are counted since the last completed compact boundary. const messageTokenData = useMemo(() => { + let startIndex = 0 + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] + const parts = (msg as any)?.parts as Array<{ type?: string; state?: string }> | undefined + if ( + parts?.some( + (part) => + part.type === "tool-Compact" && + (part.state === "output-available" || part.state === "result"), + ) + ) { + // Include the compact result itself in the token window + startIndex = i + } + } + let totalInputTokens = 0 let totalOutputTokens = 0 let totalCostUsd = 0 - for (const msg of messages) { + for (let i = startIndex; i < messages.length; i++) { + const msg = messages[i] if (msg.metadata) { totalInputTokens += msg.metadata.inputTokens || 0 totalOutputTokens += msg.metadata.outputTokens || 0 totalCostUsd += msg.metadata.totalCostUsd || 0 } } - return { totalInputTokens, totalOutputTokens, totalCostUsd, messageCount: messages.length } + const messageCount = Math.max(0, messages.length - startIndex) + return { + totalInputTokens, + totalOutputTokens, + totalCostUsd, + messageCount, + } }, [messages]) // Track previous streaming state to detect stream stop @@ -2990,6 +3048,14 @@ const ChatViewInner = memo(function ChatViewInner({ // Only check after streaming ends if (isStreaming) return + // Don't run until agentChat has loaded so we know the real existingPrUrl + if (existingPrUrl === undefined) return + + // Sync ref when existingPrUrl loads (prevents re-detection on remount) + if (existingPrUrl && !detectedPrUrlRef.current) { + detectedPrUrlRef.current = existingPrUrl + } + // Look through messages for PR URLs for (const msg of messages) { if (msg.role !== "assistant") continue @@ -3024,7 +3090,7 @@ const ChatViewInner = memo(function ChatViewInner({ break // Only process first PR URL found } } - }, [messages, isStreaming, parentChatId]) + }, [messages, isStreaming, parentChatId, existingPrUrl]) // Track plan Edit completions to trigger sidebar refetch const triggerPlanEditRefetch = useSetAtom( @@ -3061,6 +3127,7 @@ const ChatViewInner = memo(function ChatViewInner({ subChatId, isStreaming, parentChatId, + projectPath, ) // Rollback handler - truncates messages to the clicked assistant message and restores git state @@ -3118,13 +3185,7 @@ const ChatViewInner = memo(function ChatViewInner({ ], ) - // Expose rollback handler/state via atoms for message action bar - const setRollbackHandler = useSetAtom(rollbackHandlerAtom) - useEffect(() => { - setRollbackHandler(() => handleRollback) - return () => setRollbackHandler(null) - }, [handleRollback, setRollbackHandler]) - + // Sync local isRollingBack state to global atom (prevents multiple rollbacks across chats) const setIsRollingBackAtom = useSetAtom(isRollingBackAtom) useEffect(() => { setIsRollingBackAtom(isRollingBack) @@ -3994,9 +4055,32 @@ const ChatViewInner = memo(function ChatViewInner({ useLayoutEffect(() => { // Skip syncing for inactive tabs - they shouldn't update global atoms if (!isActive) return + // DEBUG: track layout effect timing + const switchStart = (globalThis as any).__switchStart + if (switchStart) { + fetch('http://localhost:7799/log',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tag:'switch',msg:'useLayoutEffect:syncMessages',data:{subChatId:subChatId.slice(-8),msgCount:messages.length,sinceSwitch:+((performance.now()-switchStart)).toFixed(2)},ts:Date.now()})}).catch(()=>{}) + } syncMessages({ messages, status, subChatId }) + // DEBUG: after sync + if (switchStart) { + fetch('http://localhost:7799/log',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tag:'switch',msg:'useLayoutEffect:afterSync',data:{subChatId:subChatId.slice(-8),sinceSwitch:+((performance.now()-switchStart)).toFixed(2)},ts:Date.now()})}).catch(()=>{}) + } }, [messages, status, subChatId, syncMessages, isActive]) + // DEBUG: measure time to first paint after subchat switch + useEffect(() => { + if (!isActive) return + const switchStart = (globalThis as any).__switchStart + if (!switchStart) return + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const paintTime = performance.now() + fetch('http://localhost:7799/log',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tag:'switch',msg:'firstPaint',data:{subChatId:subChatId.slice(-8),sinceSwitch:+((paintTime-switchStart)).toFixed(2)},ts:Date.now()})}).catch(()=>{}) + ;(globalThis as any).__switchStart = null // clear to stop logging + }) + }) + }, [isActive, subChatId]) + // Sync status to global streaming status store for queue processing const setStreamingStatus = useStreamingStatusStore((s) => s.setStatus) useEffect(() => { @@ -4058,16 +4142,22 @@ const ChatViewInner = memo(function ChatViewInner({ // Calculate top offset for search bar based on sub-chat selector const searchBarTopOffset = isSubChatsSidebarOpen ? "52px" : undefined + const shouldShowStatusCard = + isStreaming || isCompacting || changedFilesForSubChat.length > 0 + const shouldShowStackedCards = + !displayQuestions && (queue.length > 0 || shouldShowStatusCard) return (
- {/* Text selection popover for adding text to context */} - + {/* Text selection popover for adding text to context - only render for active tab to avoid keep-alive portal collision */} + {isActive && ( + + )} {/* Quick comment input */} {quickCommentState && ( @@ -4113,11 +4203,20 @@ const ChatViewInner = memo(function ChatViewInner({ chatContainerRef.current = el - // Setup ResizeObserver for --chat-container-height CSS variable + // Setup ResizeObserver for --chat-container-height/width CSS variables + // Variables are set on both the element itself and the parent (relative wrapper) + // so siblings like ScrollToBottomButton can also access them if (el) { + const parent = el.parentElement const observer = new ResizeObserver((entries) => { - const height = entries[0]?.contentRect.height ?? 0 + const { height, width } = entries[0]?.contentRect ?? { + height: 0, + width: 0, + } el.style.setProperty("--chat-container-height", `${height}px`) + el.style.setProperty("--chat-container-width", `${width}px`) + parent?.style.setProperty("--chat-container-height", `${height}px`) + parent?.style.setProperty("--chat-container-width", `${width}px`) }) observer.observe(el) chatContainerObserverRef.current = observer @@ -4150,6 +4249,7 @@ const ChatViewInner = memo(function ChatViewInner({ ToolCallComponent={AgentToolCall} MessageGroupWrapper={MessageGroup} toolRegistry={AgentToolRegistry} + onRollback={handleRollback} />
@@ -4171,8 +4271,7 @@ const ChatViewInner = memo(function ChatViewInner({ )} {/* Stacked cards container - queue + status */} - {!displayQuestions && - (queue.length > 0 || changedFilesForSubChat.length > 0) && ( + {shouldShowStackedCards && (
{/* Queue indicator card - top card */} @@ -4182,11 +4281,11 @@ const ChatViewInner = memo(function ChatViewInner({ onRemoveItem={handleRemoveFromQueue} onSendNow={handleSendFromQueue} isStreaming={isStreaming} - hasStatusCardBelow={changedFilesForSubChat.length > 0} + hasStatusCardBelow={shouldShowStatusCard} /> )} - {/* Status card - bottom card, only when there are changed files */} - {changedFilesForSubChat.length > 0 && ( + {/* Status card - bottom card */} + {shouldShowStatusCard && ( 0 || changedFilesForSubChat.length > 0)} + hasStackedCards={shouldShowStackedCards} subChatId={subChatId} isActive={isActive} /> @@ -4268,6 +4368,7 @@ export function ChatView({ onOpenPreview, onOpenDiff, onOpenTerminal, + hideHeader = false, }: { chatId: string isSidebarOpen: boolean @@ -4279,6 +4380,7 @@ export function ChatView({ onOpenPreview?: () => void onOpenDiff?: () => void onOpenTerminal?: () => void + hideHeader?: boolean }) { const [selectedTeamId] = useAtom(selectedTeamIdAtom) const [selectedModelId] = useAtom(lastSelectedModelIdAtom) @@ -4573,7 +4675,7 @@ export function ChatView({ }, [diffDisplayMode]) // Handle Diff + Details sidebar conflict (side-peek mode only) - // - If Diff opens in side-peek while Details is open: switch Diff to center-peek (dialog) mode + // - If Diff opens in side-peek while Details is open: close Details and remember // - If user manually switches Diff to side-peek while Details is open: close Details and remember // - If Details opens while Diff is in side-peek mode: close Diff and remember const prevDiffStateRef = useRef<{ isOpen: boolean; mode: string; detailsOpen: boolean }>({ @@ -4598,10 +4700,11 @@ export function ChatView({ auto.diffClosedByDetails = true setIsDiffSidebarOpen(false) } - // Diff just opened in side-peek mode → switch to dialog (don't close Details) + // Diff just opened in side-peek mode → close Details and remember // Skip if we're restoring Diff after Details closed else if (!prev.isOpen && !isRestoringDiffRef.current) { - setDiffDisplayMode("center-peek") + auto.detailsClosedBy = "diff" + setIsDetailsSidebarOpen(false) } // User manually switched to side-peek while Diff was already open → close Details and remember else if (prev.isOpen && prev.mode !== "side-peek") { @@ -4694,12 +4797,8 @@ export function ChatView({ } }, [isDiffSidebarOpen, storedDiffSidebarWidth]) - // Track changed files across all sub-chats for throttled diff refresh + // Track changed files across all sub-chats for filtering const subChatFiles = useAtomValue(subChatFilesAtom) - // Initialize to Date.now() to prevent double-fetch on mount - // (the "mount" effect already fetches, throttle should wait) - const lastDiffFetchTimeRef = useRef(Date.now()) - const DIFF_THROTTLE_MS = 2000 // Max 1 fetch per 2 seconds // Clear "unseen changes" when chat is opened useEffect(() => { @@ -5025,12 +5124,6 @@ export function ChatView({ // Desktop uses worktreePath, web uses sandboxUrl const chatWorkingDir = worktreePath || sandboxUrl - // Listen for file changes from Claude Write/Edit tools and invalidate git status - useFileChangeListener(worktreePath) - - // Subscribe to GitWatcher for real-time file system monitoring (chokidar on main process) - useGitWatcher(worktreePath) - // Plugin MCP approval - disabled for now since official marketplace plugins // are trusted by default. Will re-enable when third-party plugin support is added. @@ -5288,7 +5381,7 @@ export function ChatView({ } fetchDiffStatsDebounceRef.current = setTimeout(() => { fetchDiffStats() - }, 500) // 500ms debounce to avoid spamming if multiple streams end + }, 2000) // 2s debounce to avoid spamming if multiple streams end }, [fetchDiffStats]) // Ref to hold the latest fetchDiffStatsDebounced for use in onFinish callbacks @@ -5311,38 +5404,48 @@ export function ChatView({ } }, [isDiffSidebarOpen, fetchDiffStats]) - // Calculate total file count across all sub-chats for change detection - const totalSubChatFileCount = useMemo(() => { - let count = 0 - subChatFiles.forEach((files) => { - count += files.length - }) - return count - }, [subChatFiles]) - - // Throttled refetch when sub-chat files change (agent edits/writes files) - // This keeps the top-right diff sidebar in sync with the bottom "Generated X files" bar - useEffect(() => { - // Skip if no files tracked yet (initial state) - if (totalSubChatFileCount === 0) return + // Throttled diff refresh for filesystem events (file edits, git ops) + // Initialize to Date.now() to prevent double-fetch on mount + // (the "mount" effect already fetches, throttle should wait) + const lastDiffFetchTimeRef = useRef(Date.now()) + const DIFF_THROTTLE_MS = 2000 // Max 1 fetch per 2 seconds + const diffRefreshTimerRef = useRef(null) + const scheduleDiffRefresh = useCallback(() => { const now = Date.now() const timeSinceLastFetch = now - lastDiffFetchTimeRef.current if (timeSinceLastFetch >= DIFF_THROTTLE_MS) { - // Enough time passed, fetch immediately lastDiffFetchTimeRef.current = now fetchDiffStats() - } else { - // Schedule fetch for when throttle window ends - const delay = DIFF_THROTTLE_MS - timeSinceLastFetch - const timer = setTimeout(() => { - lastDiffFetchTimeRef.current = Date.now() - fetchDiffStats() - }, delay) - return () => clearTimeout(timer) + return + } + + const delay = DIFF_THROTTLE_MS - timeSinceLastFetch + if (diffRefreshTimerRef.current) { + clearTimeout(diffRefreshTimerRef.current) + } + diffRefreshTimerRef.current = setTimeout(() => { + diffRefreshTimerRef.current = null + lastDiffFetchTimeRef.current = Date.now() + fetchDiffStats() + }, delay) + }, [fetchDiffStats]) + + useEffect(() => { + return () => { + if (diffRefreshTimerRef.current) { + clearTimeout(diffRefreshTimerRef.current) + diffRefreshTimerRef.current = null + } } - }, [totalSubChatFileCount, fetchDiffStats]) + }, []) + + // Listen for file changes from Claude Write/Edit tools and refresh diff + useFileChangeListener(worktreePath, { onChange: scheduleDiffRefresh }) + + // Subscribe to GitWatcher for real-time file system monitoring (chokidar on main process) + useGitWatcher(worktreePath, { onChange: scheduleDiffRefresh, debounceMs: 200 }) // Handle Create PR (Direct) - pushes branch and opens GitHub compare URL const handleCreatePrDirect = useCallback(async () => { @@ -5370,6 +5473,18 @@ export function ChatView({ setIsCreatingPr(true) try { + const activeSubChatId = useAgentSubChatStore.getState().activeSubChatId + if (!activeSubChatId) { + toast.error("No active chat available", { position: "top-center" }) + setIsCreatingPr(false) + return + } + + // Ensure the target sub-chat is focused before sending + const store = useAgentSubChatStore.getState() + store.addToOpenSubChats(activeSubChatId) + store.setActiveSubChat(activeSubChatId) + // Get PR context from backend const context = await trpcClient.chats.getPrContext.query({ chatId }) if (!context) { @@ -5380,7 +5495,7 @@ export function ChatView({ // Generate message and set it for ChatViewInner to send const message = generatePrMessage(context) - setPendingPrMessage(message) + setPendingPrMessage({ message, subChatId: activeSubChatId }) // Don't reset isCreatingPr here - it will be reset after message is sent } catch (error) { toast.error( @@ -5389,7 +5504,7 @@ export function ChatView({ ) setIsCreatingPr(false) } - }, [chatId, setPendingPrMessage]) + }, [chatId, setPendingPrMessage, setIsCreatingPr]) // Handle Commit to existing PR - sends a message to Claude to commit and push // selectedPaths parameter is optional - if provided, only those files will be mentioned @@ -5402,6 +5517,18 @@ export function ChatView({ try { setIsCommittingToPr(true) + const activeSubChatId = useAgentSubChatStore.getState().activeSubChatId + if (!activeSubChatId) { + toast.error("No active chat available", { position: "top-center" }) + setIsCommittingToPr(false) + return + } + + // Ensure the target sub-chat is focused before sending + const store = useAgentSubChatStore.getState() + store.addToOpenSubChats(activeSubChatId) + store.setActiveSubChat(activeSubChatId) + const context = await trpcClient.chats.getPrContext.query({ chatId }) if (!context) { toast.error("Could not get git context", { position: "top-center" }) @@ -5409,7 +5536,7 @@ export function ChatView({ } const message = generateCommitToPrMessage(context) - setPendingPrMessage(message) + setPendingPrMessage({ message, subChatId: activeSubChatId }) } catch (error) { toast.error( error instanceof Error ? error.message : "Failed to prepare commit request", @@ -5418,7 +5545,7 @@ export function ChatView({ } finally { setIsCommittingToPr(false) } - }, [chatId, setPendingPrMessage]) + }, [chatId, setPendingPrMessage, setIsCommittingToPr]) // Handle Review - sends a message to Claude to review the diff const setPendingReviewMessage = useSetAtom(pendingReviewMessageAtom) @@ -5481,9 +5608,42 @@ Make sure to preserve all functionality from both branches when resolving confli // Fetch git status for sync counts (pushCount, pullCount, hasUpstream) const { data: gitStatus, refetch: refetchGitStatus, isLoading: isGitStatusLoading } = trpc.changes.getStatus.useQuery( { worktreePath: worktreePath || "" }, - { enabled: !!worktreePath && isDiffSidebarOpen, staleTime: 30000 } + { enabled: !!worktreePath && (isDiffSidebarOpen || isDetailsSidebarOpen), staleTime: 30000 } ) + const handleCommitChangesRefresh = useCallback(() => { + refetchGitStatus() + scheduleDiffRefresh() + }, [refetchGitStatus, scheduleDiffRefresh]) + + const { + commit: commitChanges, + isPending: isCommittingChanges, + } = useCommitActions({ + worktreePath, + chatId, + onRefresh: handleCommitChangesRefresh, + }) + + const { push: pushBranch, isPending: isPushing } = usePushAction({ + worktreePath, + hasUpstream: gitStatus?.hasUpstream ?? true, + onSuccess: handleCommitChangesRefresh, + }) + + const handleCommitChanges = useCallback((selectedPaths: string[]) => { + commitChanges({ filePaths: selectedPaths }) + }, [commitChanges]) + + const handleCommitAndPush = useCallback(async (selectedPaths: string[]) => { + const didCommit = await commitChanges({ filePaths: selectedPaths }) + if (didCommit) { + pushBranch() + } + }, [commitChanges, pushBranch]) + + const isCommittingCombined = isCommittingChanges || isPushing + // Refetch git status and diff stats when window gains focus useEffect(() => { if (!worktreePath || !isDiffSidebarOpen) return @@ -6390,10 +6550,11 @@ Make sure to preserve all functionality from both branches when resolving confli // Determine if chat header should be hidden const shouldHideChatHeader = - subChatsSidebarMode === "sidebar" && + hideHeader || + (subChatsSidebarMode === "sidebar" && isPreviewSidebarOpen && isDiffSidebarOpen && - !isMobileFullscreen + !isMobileFullscreen) // No early return - let the UI render with loading state handled by activeChat check below @@ -6840,6 +7001,7 @@ Make sure to preserve all functionality from both branches when resolving confli isCommittingToPr={isCommittingToPr} subChatsWithFiles={subChatsWithFiles} setDiffStats={setDiffStats} + onDiscardSuccess={scheduleDiffRefresh} /> )} @@ -6998,8 +7160,12 @@ Make sure to preserve all functionality from both branches when resolving confli setIsDiffSidebarOpen={setIsDiffSidebarOpen} diffStats={diffStats} parsedFileDiffs={parsedFileDiffs} - onCommit={handleCommitToPr} - isCommitting={isCommittingToPr} + onCommit={worktreePath ? handleCommitChanges : undefined} + onCommitAndPush={worktreePath ? handleCommitAndPush : undefined} + isCommitting={isCommittingCombined} + gitStatus={gitStatus} + isGitStatusLoading={isGitStatusLoading} + currentBranch={branchData?.current} onExpandTerminal={() => setIsTerminalSidebarOpen(true)} onExpandPlan={() => setIsPlanSidebarOpen(true)} onExpandDiff={() => setIsDiffSidebarOpen(true)} diff --git a/src/renderer/features/agents/main/assistant-message-item.tsx b/src/renderer/features/agents/main/assistant-message-item.tsx index e30272479..c58cf7a03 100644 --- a/src/renderer/features/agents/main/assistant-message-item.tsx +++ b/src/renderer/features/agents/main/assistant-message-item.tsx @@ -7,7 +7,7 @@ import { memo, useCallback, useContext, useMemo, useState } from "react" import { CollapseIcon, ExpandIcon, IconTextUndo, PlanIcon } from "../../../components/ui/icons" import { TextShimmer } from "../../../components/ui/text-shimmer" import { cn } from "../../../lib/utils" -import { isRollingBackAtom, rollbackHandlerAtom } from "../stores/message-store" +import { isRollingBackAtom } from "../stores/message-store" import { selectedProjectAtom, showMessageJsonAtom } from "../atoms" import { MessageJsonDisplay } from "../ui/message-json-display" import { AgentAskUserQuestionTool } from "../ui/agent-ask-user-question-tool" @@ -176,6 +176,7 @@ export interface AssistantMessageItemProps { subChatId: string chatId: string sandboxSetupStatus?: "cloning" | "ready" | "error" + onRollback?: (msg: any) => void } // Cache for tracking previous message state per message (to detect AI SDK in-place mutations) @@ -275,8 +276,8 @@ export const AssistantMessageItem = memo(function AssistantMessageItem({ subChatId, chatId, sandboxSetupStatus = "ready", + onRollback, }: AssistantMessageItemProps) { - const onRollback = useAtomValue(rollbackHandlerAtom) const isRollingBack = useAtomValue(isRollingBackAtom) const showMessageJson = useAtomValue(showMessageJsonAtom) const selectedProject = useAtomValue(selectedProjectAtom) diff --git a/src/renderer/features/agents/main/chat-input-area.tsx b/src/renderer/features/agents/main/chat-input-area.tsx index 8e98ede18..23401a7c6 100644 --- a/src/renderer/features/agents/main/chat-input-area.tsx +++ b/src/renderer/features/agents/main/chat-input-area.tsx @@ -136,6 +136,7 @@ export interface ChatInputAreaProps { onStop: () => Promise onCompact: () => void onCreateNewSubChat?: () => void + onModeChange?: (newMode: AgentMode) => void // State from parent isStreaming: boolean isCompacting: boolean @@ -218,6 +219,7 @@ function arePropsEqual(prevProps: ChatInputAreaProps, nextProps: ChatInputAreaPr prevProps.onStop !== nextProps.onStop || prevProps.onCompact !== nextProps.onCompact || prevProps.onCreateNewSubChat !== nextProps.onCreateNewSubChat || + prevProps.onModeChange !== nextProps.onModeChange || prevProps.onAddAttachments !== nextProps.onAddAttachments || prevProps.onRemoveImage !== nextProps.onRemoveImage || prevProps.onRemoveFile !== nextProps.onRemoveFile || @@ -343,6 +345,7 @@ export const ChatInputArea = memo(function ChatInputArea({ onStop, onCompact, onCreateNewSubChat, + onModeChange, isStreaming, isCompacting, images, @@ -405,6 +408,12 @@ export const ChatInputArea = memo(function ChatInputArea({ const tooltipTimeoutRef = useRef | null>(null) const hasShownTooltipRef = useRef(false) + useEffect(() => { + if (!modeDropdownOpen) { + setModeTooltip(null) + } + }, [modeDropdownOpen]) + // Model dropdown state const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false) const [lastSelectedModelId, setLastSelectedModelId] = useAtom(lastSelectedModelIdAtom) @@ -504,9 +513,13 @@ export const ChatInputArea = memo(function ChatInputArea({ // Helper to update mode (atomFamily + Zustand store sync) const updateMode = useCallback((newMode: AgentMode) => { + if (onModeChange) { + onModeChange(newMode) + return + } setSubChatMode(newMode) useAgentSubChatStore.getState().updateSubChatMode(subChatId, newMode) - }, [setSubChatMode, subChatId]) + }, [onModeChange, setSubChatMode, subChatId]) // Toggle mode helper const toggleMode = useCallback(() => { @@ -1013,7 +1026,26 @@ export const ChatInputArea = memo(function ChatInputArea({ ) return ( -
+
{ + if (!el) return + if (el.dataset.observed) return + el.dataset.observed = "true" + const parent = el.parentElement + const observer = new ResizeObserver((entries) => { + const { height, width } = entries[0]?.contentRect ?? { + height: 0, + width: 0, + } + el.style.setProperty("--chat-input-height", `${height}px`) + el.style.setProperty("--chat-input-width", `${width}px`) + parent?.style.setProperty("--chat-input-height", `${height}px`) + parent?.style.setProperty("--chat-input-width", `${width}px`) + }) + observer.observe(el) + }} + className="px-2 pb-2 shadow-sm shadow-background relative z-10" + >
- {selectedModel?.name}{" "} - 4.5 - + selectedModel?.name )} @@ -1403,10 +1432,7 @@ export const ChatInputArea = memo(function ChatInputArea({ >
- - {model.name}{" "} - 4.5 - + {model.name}
{isSelected && ( @@ -1457,7 +1483,7 @@ export const ChatInputArea = memo(function ChatInputArea({ {/* Context window indicator - click to compact */} diff --git a/src/renderer/features/agents/main/isolated-message-group.tsx b/src/renderer/features/agents/main/isolated-message-group.tsx index f789e5386..5b04ac30b 100644 --- a/src/renderer/features/agents/main/isolated-message-group.tsx +++ b/src/renderer/features/agents/main/isolated-message-group.tsx @@ -38,6 +38,7 @@ interface IsolatedMessageGroupProps { stickyTopClass: string sandboxSetupError?: string onRetrySetup?: () => void + onRollback?: (msg: any) => void // Components passed from parent - must be stable references UserBubbleComponent: React.ComponentType<{ messageId: string @@ -65,13 +66,14 @@ function areGroupPropsEqual( prev.chatId === next.chatId && prev.isMobile === next.isMobile && prev.sandboxSetupStatus === next.sandboxSetupStatus && - prev.stickyTopClass === next.stickyTopClass && - prev.sandboxSetupError === next.sandboxSetupError && - prev.onRetrySetup === next.onRetrySetup && - prev.UserBubbleComponent === next.UserBubbleComponent && - prev.ToolCallComponent === next.ToolCallComponent && - prev.MessageGroupWrapper === next.MessageGroupWrapper && - prev.toolRegistry === next.toolRegistry + prev.stickyTopClass === next.stickyTopClass && + prev.sandboxSetupError === next.sandboxSetupError && + prev.onRetrySetup === next.onRetrySetup && + prev.onRollback === next.onRollback && + prev.UserBubbleComponent === next.UserBubbleComponent && + prev.ToolCallComponent === next.ToolCallComponent && + prev.MessageGroupWrapper === next.MessageGroupWrapper && + prev.toolRegistry === next.toolRegistry ) } @@ -84,6 +86,7 @@ export const IsolatedMessageGroup = memo(function IsolatedMessageGroup({ stickyTopClass, sandboxSetupError, onRetrySetup, + onRollback, UserBubbleComponent, ToolCallComponent, MessageGroupWrapper, @@ -135,19 +138,23 @@ export const IsolatedMessageGroup = memo(function IsolatedMessageGroup({ {((!isImageOnlyMessage && imageParts.length > 0) || textMentions.length > 0) && (
{imageParts.length > 0 && !isImageOnlyMessage && (() => { + const resolveImgUrl = (img: any) => + img.data?.base64Data && img.data?.mediaType + ? `data:${img.data.mediaType};base64,${img.data.base64Data}` + : img.data?.url || "" const allImages = imageParts - .filter((img: any) => img.data?.url) + .filter((img: any) => img.data?.url || img.data?.base64Data) .map((img: any, idx: number) => ({ id: `${userMsgId}-img-${idx}`, filename: img.data?.filename || "image", - url: img.data?.url || "", + url: resolveImgUrl(img), })) return imageParts.map((img: any, idx: number) => ( @@ -237,6 +244,7 @@ export const IsolatedMessageGroup = memo(function IsolatedMessageGroup({ chatId={chatId} isMobile={isMobile} sandboxSetupStatus={sandboxSetupStatus} + onRollback={onRollback} /> )} diff --git a/src/renderer/features/agents/main/isolated-messages-section.tsx b/src/renderer/features/agents/main/isolated-messages-section.tsx index 6f8ee7dc7..5fd9d0ca0 100644 --- a/src/renderer/features/agents/main/isolated-messages-section.tsx +++ b/src/renderer/features/agents/main/isolated-messages-section.tsx @@ -26,6 +26,7 @@ interface IsolatedMessagesSectionProps { stickyTopClass: string sandboxSetupError?: string onRetrySetup?: () => void + onRollback?: (msg: any) => void // Components passed from parent - must be stable references UserBubbleComponent: React.ComponentType<{ messageId: string @@ -55,6 +56,7 @@ function areSectionPropsEqual( prev.stickyTopClass === next.stickyTopClass && prev.sandboxSetupError === next.sandboxSetupError && prev.onRetrySetup === next.onRetrySetup && + prev.onRollback === next.onRollback && prev.UserBubbleComponent === next.UserBubbleComponent && prev.ToolCallComponent === next.ToolCallComponent && prev.MessageGroupWrapper === next.MessageGroupWrapper && @@ -70,6 +72,7 @@ export const IsolatedMessagesSection = memo(function IsolatedMessagesSection({ stickyTopClass, sandboxSetupError, onRetrySetup, + onRollback, UserBubbleComponent, ToolCallComponent, MessageGroupWrapper, @@ -109,6 +112,7 @@ export const IsolatedMessagesSection = memo(function IsolatedMessagesSection({ stickyTopClass={stickyTopClass} sandboxSetupError={sandboxSetupError} onRetrySetup={onRetrySetup} + onRollback={onRollback} UserBubbleComponent={UserBubbleComponent} ToolCallComponent={ToolCallComponent} MessageGroupWrapper={MessageGroupWrapper} diff --git a/src/renderer/features/agents/main/messages-list.tsx b/src/renderer/features/agents/main/messages-list.tsx index 9e5654611..e2787b7a6 100644 --- a/src/renderer/features/agents/main/messages-list.tsx +++ b/src/renderer/features/agents/main/messages-list.tsx @@ -291,6 +291,7 @@ interface MessageItemWrapperProps { chatId: string isMobile: boolean sandboxSetupStatus: "cloning" | "ready" | "error" + onRollback?: (msg: any) => void } // Hook that only re-renders THIS component when it becomes/stops being the last message @@ -363,12 +364,14 @@ const NonStreamingMessageItem = memo(function NonStreamingMessageItem({ chatId, isMobile, sandboxSetupStatus, + onRollback, }: { messageId: string subChatId: string chatId: string isMobile: boolean sandboxSetupStatus: "cloning" | "ready" | "error" + onRollback?: (msg: any) => void }) { // Subscribe to this specific message via Jotai - only re-renders when THIS message changes const message = useAtomValue(messageAtomFamily(messageId)) @@ -385,6 +388,7 @@ const NonStreamingMessageItem = memo(function NonStreamingMessageItem({ chatId={chatId} isMobile={isMobile} sandboxSetupStatus={sandboxSetupStatus} + onRollback={onRollback} /> ) }) @@ -397,12 +401,14 @@ const StreamingMessageItem = memo(function StreamingMessageItem({ chatId, isMobile, sandboxSetupStatus, + onRollback, }: { messageId: string subChatId: string chatId: string isMobile: boolean sandboxSetupStatus: "cloning" | "ready" | "error" + onRollback?: (msg: any) => void }) { // Subscribe to this specific message via Jotai - only re-renders when THIS message changes const message = useAtomValue(messageAtomFamily(messageId)) @@ -423,6 +429,7 @@ const StreamingMessageItem = memo(function StreamingMessageItem({ chatId={chatId} isMobile={isMobile} sandboxSetupStatus={sandboxSetupStatus} + onRollback={onRollback} /> ) }) @@ -480,6 +487,7 @@ export const MessageItemWrapper = memo(function MessageItemWrapper({ chatId, isMobile, sandboxSetupStatus, + onRollback, }: MessageItemWrapperProps) { // Only subscribe to isLast - NOT to message content! @@ -496,6 +504,7 @@ export const MessageItemWrapper = memo(function MessageItemWrapper({ chatId={chatId} isMobile={isMobile} sandboxSetupStatus={sandboxSetupStatus} + onRollback={onRollback} /> ) } @@ -508,6 +517,7 @@ export const MessageItemWrapper = memo(function MessageItemWrapper({ chatId={chatId} isMobile={isMobile} sandboxSetupStatus={sandboxSetupStatus} + onRollback={onRollback} /> ) }) @@ -527,6 +537,7 @@ interface MemoizedAssistantMessagesProps { chatId: string isMobile: boolean sandboxSetupStatus: "cloning" | "ready" | "error" + onRollback?: (msg: any) => void } function areMemoizedAssistantMessagesEqual( @@ -550,6 +561,7 @@ function areMemoizedAssistantMessagesEqual( if (prev.chatId !== next.chatId) return false if (prev.isMobile !== next.isMobile) return false if (prev.sandboxSetupStatus !== next.sandboxSetupStatus) return false + if (prev.onRollback !== next.onRollback) return false return true } @@ -560,6 +572,7 @@ export const MemoizedAssistantMessages = memo(function MemoizedAssistantMessages chatId, isMobile, sandboxSetupStatus, + onRollback, }: MemoizedAssistantMessagesProps) { // This component only re-renders when assistantMsgIds changes // During streaming, IDs stay the same, so this doesn't re-render @@ -575,6 +588,7 @@ export const MemoizedAssistantMessages = memo(function MemoizedAssistantMessages chatId={chatId} isMobile={isMobile} sandboxSetupStatus={sandboxSetupStatus} + onRollback={onRollback} /> ))} diff --git a/src/renderer/features/agents/main/new-chat-form.tsx b/src/renderer/features/agents/main/new-chat-form.tsx index 4d9dc25b1..858ef7732 100644 --- a/src/renderer/features/agents/main/new-chat-form.tsx +++ b/src/renderer/features/agents/main/new-chat-form.tsx @@ -398,6 +398,12 @@ export function NewChatForm({ const tooltipTimeoutRef = useRef | null>(null) const hasShownTooltipRef = useRef(false) const [modeDropdownOpen, setModeDropdownOpen] = useState(false) + + useEffect(() => { + if (!modeDropdownOpen) { + setModeTooltip(null) + } + }, [modeDropdownOpen]) const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false) // Voice input state @@ -674,6 +680,10 @@ export function NewChatForm({ } }, [validatedProject?.path, fetchRemoteMutation, branchesQuery]) + // Stable ref for handleRefreshBranches to avoid re-running effects on every render + const handleRefreshBranchesRef = useRef(handleRefreshBranches) + handleRefreshBranchesRef.current = handleRefreshBranches + // Transform branch data to match web app format const branches = useMemo(() => { if (!branchesQuery.data) return [] @@ -823,7 +833,7 @@ export function NewChatForm({ // Fetch remote branches in background when starting new workspace if (validatedProject?.path) { - handleRefreshBranches() + handleRefreshBranchesRef.current() } } return @@ -848,7 +858,7 @@ export function NewChatForm({ return () => clearTimeout(timeoutId) } } - }, [selectedDraftId, handleRefreshBranches, validatedProject?.path]) + }, [selectedDraftId, validatedProject?.path]) // Mark draft as visible when component unmounts (user navigates away) // This ensures the draft only appears in the sidebar after leaving the form diff --git a/src/renderer/features/agents/stores/message-store.ts b/src/renderer/features/agents/stores/message-store.ts index 9bc93a3e2..ee939f678 100644 --- a/src/renderer/features/agents/stores/message-store.ts +++ b/src/renderer/features/agents/stores/message-store.ts @@ -64,8 +64,7 @@ export const streamingMessageIdAtom = atom(null) // Chat status atom export const chatStatusAtom = atom("ready") -// Rollback handler/state (optional) to avoid prop drilling -export const rollbackHandlerAtom = atom<((msg: any) => void) | null>(null) +// Global rollback state - prevents multiple rollbacks across all chats export const isRollingBackAtom = atom(false) // Current subChatId - used to isolate caches per chat @@ -440,8 +439,11 @@ type TokenData = { reasoningTokens: number totalTokens: number messageCount: number + totalMessageCount: number // Track last message's output tokens to detect when streaming completes lastMsgOutputTokens: number + // Track last message parts signature to detect compact boundary updates + lastMsgPartsKey: string } const tokenDataCacheByChat = new Map() @@ -454,6 +456,9 @@ export const messageTokenDataAtom = atom((get) => { const lastMsg = lastId ? get(messageAtomFamily(lastId)) : null // Note: metadata has flat structure (metadata.outputTokens), not nested (metadata.usage.outputTokens) const lastMsgOutputTokens = (lastMsg?.metadata as any)?.outputTokens || 0 + const lastMsgParts = (lastMsg as any)?.parts as Array<{ type?: string; state?: string }> | undefined + const lastPart = lastMsgParts?.[lastMsgParts.length - 1] + const lastMsgPartsKey = `${lastMsgParts?.length ?? 0}:${lastPart?.type ?? ""}:${(lastPart as any)?.state ?? ""}` const cached = tokenDataCacheByChat.get(subChatId) @@ -462,21 +467,37 @@ export const messageTokenDataAtom = atom((get) => { // 2. Last message's output tokens haven't changed (detects streaming completion) if ( cached && - ids.length === cached.messageCount && - lastMsgOutputTokens === cached.lastMsgOutputTokens + ids.length === cached.totalMessageCount && + lastMsgOutputTokens === cached.lastMsgOutputTokens && + lastMsgPartsKey === cached.lastMsgPartsKey ) { return cached } - // Recalculate token data + // Recalculate token data (since last completed compact boundary) + let startIndex = 0 + for (let i = 0; i < ids.length; i++) { + const msg = get(messageAtomFamily(ids[i])) + const parts = (msg as any)?.parts as Array<{ type?: string; state?: string }> | undefined + if ( + parts?.some( + (part) => + part.type === "tool-Compact" && + (part.state === "output-available" || part.state === "result"), + ) + ) { + // Include the compact result itself in the token window + startIndex = i + } + } + let inputTokens = 0 let outputTokens = 0 let cacheReadTokens = 0 let cacheWriteTokens = 0 let reasoningTokens = 0 - - for (const id of ids) { - const msg = get(messageAtomFamily(id)) + for (let i = startIndex; i < ids.length; i++) { + const msg = get(messageAtomFamily(ids[i])) const metadata = msg?.metadata as any // Note: metadata has flat structure from transform.ts (metadata.inputTokens, metadata.outputTokens) // Extended fields like cacheReadInputTokens are not currently in MessageMetadata type @@ -489,6 +510,7 @@ export const messageTokenDataAtom = atom((get) => { reasoningTokens += metadata.reasoningTokens || 0 } } + const messageCount = Math.max(0, ids.length - startIndex) const newTokenData: TokenData = { inputTokens, @@ -497,8 +519,10 @@ export const messageTokenDataAtom = atom((get) => { cacheWriteTokens, reasoningTokens, totalTokens: inputTokens + outputTokens, - messageCount: ids.length, + messageCount, + totalMessageCount: ids.length, lastMsgOutputTokens, + lastMsgPartsKey, } tokenDataCacheByChat.set(subChatId, newTokenData) diff --git a/src/renderer/features/agents/ui/agent-diff-view.tsx b/src/renderer/features/agents/ui/agent-diff-view.tsx index c6b2fbacb..760cc5b64 100644 --- a/src/renderer/features/agents/ui/agent-diff-view.tsx +++ b/src/renderer/features/agents/ui/agent-diff-view.tsx @@ -81,6 +81,8 @@ import { trpcClient } from "../../../lib/trpc" import { remoteApi } from "../../../lib/remote-api" export type DiffViewMode = "unified" | "split" +const LARGE_DIFF_LINE_THRESHOLD = 2000 + // Simple fast string hash (djb2 algorithm) for content change detection function hashString(str: string): string { let hash = 5381 @@ -539,6 +541,7 @@ const FileDiffCard = memo(function FileDiffCard({ chatId, }: FileDiffCardProps) { const diffCardRef = useRef(null) + const isLargeDiff = file.additions + file.deletions >= LARGE_DIFF_LINE_THRESHOLD // Build FileDiffMetadata from file content (enables clickable "N unmodified lines" sections) // Computed whenever fileContent is available, not just when fully expanded @@ -546,6 +549,11 @@ const FileDiffCard = memo(function FileDiffCard({ const [isExpandLoading, setIsExpandLoading] = useState(false) useEffect(() => { + if (isLargeDiff) { + setFileDiffMeta(null) + setIsExpandLoading(false) + return + } if (!fileContent) { setFileDiffMeta(null) return @@ -579,7 +587,7 @@ const FileDiffCard = memo(function FileDiffCard({ } }) return () => cancelAnimationFrame(frame) - }, [fileContent, file.diffText, file.oldPath, file.newPath]) + }, [fileContent, file.diffText, file.oldPath, file.newPath, isLargeDiff]) // tRPC mutations for file operations const openInFinderMutation = trpcClient.external.openInFinder.mutate @@ -744,7 +752,7 @@ const FileDiffCard = memo(function FileDiffCard({
{/* Expand/Collapse full file button - only show if content is available */} - {!isCollapsed && !file.isBinary && hasContent && ( + {!isCollapsed && !file.isBinary && !isLargeDiff && hasContent && (
+ ) : isLargeDiff ? ( +
+
+
+ File is too large to display here +
+ {absolutePath && ( +
+ + +
+ )} +
+
) : !file.isValid ? (
@@ -1547,7 +1583,9 @@ export const AgentDiffView = forwardRef( try { // Limit files to prefetch to prevent overwhelming the system - const filesToProcess = fileDiffs.slice(0, MAX_PREFETCH_FILES) + const filesToProcess = fileDiffs + .filter((file) => file.additions + file.deletions < LARGE_DIFF_LINE_THRESHOLD) + .slice(0, MAX_PREFETCH_FILES) // Build list of files to fetch (filter out /dev/null) const filesToFetch = filesToProcess diff --git a/src/renderer/features/agents/ui/agent-task-tool.tsx b/src/renderer/features/agents/ui/agent-task-tool.tsx index b98938e0d..3d66adf37 100644 --- a/src/renderer/features/agents/ui/agent-task-tool.tsx +++ b/src/renderer/features/agents/ui/agent-task-tool.tsx @@ -52,13 +52,13 @@ export const AgentTaskTool = memo(function AgentTaskTool({ const description = part.input?.description || "" - // Use startedAt from backend for persistent timing across re-renders - const startedAt = part.startedAt as number | undefined + // Get startedAt from providerMetadata (passed through AI SDK) + const startedAt = (part.callProviderMetadata?.custom?.startedAt as number | undefined) + ?? (part.startedAt as number | undefined) - // Track elapsed time while task is running using backend timestamp + // Tick elapsed time while task is running useEffect(() => { if (isPending && startedAt) { - // Set initial elapsed time immediately setElapsedMs(Date.now() - startedAt) const interval = setInterval(() => { @@ -69,7 +69,7 @@ export const AgentTaskTool = memo(function AgentTaskTool({ }, [isPending, startedAt]) // Use output duration from Claude Code if available, otherwise use our tracked time - const outputDuration = part.output?.duration || part.output?.duration_ms + const outputDuration = part.output?.totalDurationMs || part.output?.duration || part.output?.duration_ms const displayMs = !isPending && outputDuration ? outputDuration : elapsedMs const elapsedTimeDisplay = formatElapsedTime(displayMs) @@ -82,11 +82,20 @@ export const AgentTaskTool = memo(function AgentTaskTool({ const hasNestedTools = nestedTools.length > 0 - // Build subtitle - always show description + // Build subtitle - show latest tool activity when running, description otherwise const getSubtitle = () => { + if (isPending && hasNestedTools) { + const lastTool = nestedTools[nestedTools.length - 1] + const meta = lastTool ? AgentToolRegistry[lastTool.type] : null + if (meta) { + const title = meta.title(lastTool) + const sub = meta.subtitle?.(lastTool) + return sub ? `${title} ${sub}` : title + } + } if (description) { - const truncated = description.length > 60 - ? description.slice(0, 57) + "..." + const truncated = description.length > 60 + ? description.slice(0, 57) + "..." : description return truncated } diff --git a/src/renderer/features/agents/ui/agent-thinking-tool.tsx b/src/renderer/features/agents/ui/agent-thinking-tool.tsx index 7b68045f3..9ccdebe88 100644 --- a/src/renderer/features/agents/ui/agent-thinking-tool.tsx +++ b/src/renderer/features/agents/ui/agent-thinking-tool.tsx @@ -4,6 +4,7 @@ import { memo, useState, useEffect, useRef } from "react" import { ChevronRight } from "lucide-react" import { cn } from "../../../lib/utils" import { ChatMarkdownRenderer } from "../../../components/chat-markdown-renderer" +import { TextShimmer } from "../../../components/ui/text-shimmer" import { AgentToolInterrupted } from "./agent-tool-interrupted" import { areToolPropsEqual } from "./agent-tool-utils" @@ -16,6 +17,7 @@ interface ThinkingToolPart { output?: { completed?: boolean } + startedAt?: number } interface AgentThinkingToolProps { @@ -23,9 +25,17 @@ interface AgentThinkingToolProps { chatStatus?: string } -// Constants for thinking preview and scrolling const PREVIEW_LENGTH = 60 -const SCROLL_THRESHOLD = 500 + +function formatElapsedTime(ms: number): string { + if (ms < 1000) return "" + const seconds = Math.floor(ms / 1000) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + if (remainingSeconds === 0) return `${minutes}m` + return `${minutes}m ${remainingSeconds}s` +} export const AgentThinkingTool = memo(function AgentThinkingTool({ part, @@ -33,61 +43,78 @@ export const AgentThinkingTool = memo(function AgentThinkingTool({ }: AgentThinkingToolProps) { const isPending = part.state !== "output-available" && part.state !== "output-error" - // Include "submitted" status - this is when request was sent but streaming hasn't started yet const isActivelyStreaming = chatStatus === "streaming" || chatStatus === "submitted" const isStreaming = isPending && isActivelyStreaming const isInterrupted = isPending && !isActivelyStreaming && chatStatus !== undefined - // Default: expanded while streaming, collapsed when done - const [isExpanded, setIsExpanded] = useState(isStreaming) - const wasStreamingRef = useRef(isStreaming) + // Always start collapsed — like SubAgent tools + const [isExpanded, setIsExpanded] = useState(false) const scrollRef = useRef(null) - // Auto-collapse when streaming ends (transition from true -> false) + // Elapsed time — ticks every second while streaming + const startedAtRef = useRef(part.startedAt || Date.now()) + const [elapsedMs, setElapsedMs] = useState(0) + useEffect(() => { - if (wasStreamingRef.current && !isStreaming) { - setIsExpanded(false) - } - wasStreamingRef.current = isStreaming + if (!isStreaming) return + const tick = () => setElapsedMs(Date.now() - startedAtRef.current) + tick() + const interval = setInterval(tick, 1000) + return () => clearInterval(interval) }, [isStreaming]) - // Auto-scroll to bottom when streaming + // Auto-scroll when expanded during streaming useEffect(() => { if (isStreaming && isExpanded && scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight } }, [part.input?.text, isStreaming, isExpanded]) - // Get thinking text const thinkingText = part.input?.text || "" - // Build preview for collapsed state const previewText = thinkingText.slice(0, PREVIEW_LENGTH).replace(/\n/g, " ") - // Show interrupted state if thinking was interrupted without completing + const elapsedDisplay = isStreaming ? formatElapsedTime(elapsedMs) : "" + if (isInterrupted && !thinkingText) { return } return (
- {/* Header - clickable to toggle, same as Exploring */} + {/* Header - always visible, clickable to toggle */}
setIsExpanded(!isExpanded)} className="group flex items-start gap-1.5 py-0.5 px-2 cursor-pointer" >
- - {isStreaming ? "Thinking" : "Thought"} + + {isStreaming ? ( + + Thinking + + ) : ( + Thought + )} - {/* Preview text when collapsed */} + {/* Preview when collapsed */} {!isExpanded && previewText && ( - {previewText}... + {previewText} + + )} + {/* Elapsed time */} + {elapsedDisplay && ( + + {elapsedDisplay} )} - {/* Chevron - rotates when expanded, visible on hover when collapsed */} + {/* Chevron */}
- {/* Thinking content - only show when expanded */} + {/* Content - only when user explicitly expands */} {isExpanded && thinkingText && ( -
- {/* Top gradient fade when streaming and has lots of content */} - {isStreaming && thinkingText.length > SCROLL_THRESHOLD && ( -
+
+ {isStreaming ? ( +

{thinkingText}

+ ) : ( + )} - - {/* Scrollable container */} -
SCROLL_THRESHOLD && - "overflow-y-auto scrollbar-none max-h-24", - )} - > - {/* Markdown content */} - - {/* Blinking cursor when streaming */} - {isStreaming && ( - - )} -
)}
diff --git a/src/renderer/features/agents/ui/agent-tool-registry.tsx b/src/renderer/features/agents/ui/agent-tool-registry.tsx index d5a4d57bf..be04e053e 100644 --- a/src/renderer/features/agents/ui/agent-tool-registry.tsx +++ b/src/renderer/features/agents/ui/agent-tool-registry.tsx @@ -607,11 +607,13 @@ export const AgentToolRegistry: Record = { }, // System tools - "system-Compact": { + "tool-Compact": { icon: Minimize2, title: (part) => { const isPending = - part.state !== "output-available" && part.state !== "output-error" + part.state !== "output-available" && + part.state !== "output-error" && + part.state !== "result" return isPending ? "Compacting..." : "Compacted" }, variant: "simple", diff --git a/src/renderer/features/agents/ui/agent-user-message-bubble.tsx b/src/renderer/features/agents/ui/agent-user-message-bubble.tsx index 0361988f9..1c2a93c3e 100644 --- a/src/renderer/features/agents/ui/agent-user-message-bubble.tsx +++ b/src/renderer/features/agents/ui/agent-user-message-bubble.tsx @@ -193,12 +193,16 @@ export const AgentUserMessageBubble = memo(function AgentUserMessageBubble({
{(() => { // Build allImages array for gallery navigation + const resolveImgUrl = (img: any) => + img.data?.base64Data && img.data?.mediaType + ? `data:${img.data.mediaType};base64,${img.data.base64Data}` + : img.data?.url || "" const allImages = imageParts - .filter((img) => img.data?.url) + .filter((img) => img.data?.url || img.data?.base64Data) .map((img, idx) => ({ id: `${messageId}-img-${idx}`, filename: img.data?.filename || "image", - url: img.data?.url || "", + url: resolveImgUrl(img), })) return imageParts.map((img, idx) => ( @@ -206,7 +210,7 @@ export const AgentUserMessageBubble = memo(function AgentUserMessageBubble({ key={`${messageId}-img-${idx}`} id={`${messageId}-img-${idx}`} filename={img.data?.filename || "image"} - url={img.data?.url || ""} + url={resolveImgUrl(img)} allImages={allImages} imageIndex={idx} /> diff --git a/src/renderer/features/agents/ui/agents-content.tsx b/src/renderer/features/agents/ui/agents-content.tsx index 1c7a8d7e1..0a73f30ed 100644 --- a/src/renderer/features/agents/ui/agents-content.tsx +++ b/src/renderer/features/agents/ui/agents-content.tsx @@ -760,7 +760,10 @@ export function AgentsContent() { // Track sub-chats sidebar open state for animation control // Now renders even while loading to show spinner (mobile always uses tabs) const isSubChatsSidebarOpen = - selectedChatId && subChatsSidebarMode === "sidebar" && !isMobile + selectedChatId && + subChatsSidebarMode === "sidebar" && + !isMobile && + !desktopView useEffect(() => { // When sidebar closes, reset for animation on next open diff --git a/src/renderer/features/agents/ui/sub-chat-selector.tsx b/src/renderer/features/agents/ui/sub-chat-selector.tsx index e42264456..610783116 100644 --- a/src/renderer/features/agents/ui/sub-chat-selector.tsx +++ b/src/renderer/features/agents/ui/sub-chat-selector.tsx @@ -599,7 +599,7 @@ export function SubChatSelector({ variant="ghost" size="icon" 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" + className="h-6 w-6 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="Back to chats" style={{ // @ts-expect-error - WebKit-specific property diff --git a/src/renderer/features/agents/ui/sub-chat-status-card.tsx b/src/renderer/features/agents/ui/sub-chat-status-card.tsx index 3f59b6b81..535bbac8a 100644 --- a/src/renderer/features/agents/ui/sub-chat-status-card.tsx +++ b/src/renderer/features/agents/ui/sub-chat-status-card.tsx @@ -1,21 +1,22 @@ "use client" -import { memo, useState, useMemo, useEffect } from "react" -import { useSetAtom, useAtom } from "jotai" +import { useAtom, useSetAtom } from "jotai" import { ChevronDown } from "lucide-react" -import { motion, AnimatePresence } from "motion/react" +import { AnimatePresence, motion } from "motion/react" +import { memo, useEffect, useMemo, useState } from "react" import { Button } from "../../../components/ui/button" -import { cn } from "../../../lib/utils" -import { trpc } from "../../../lib/trpc" import { useFileChangeListener } from "../../../lib/hooks/use-file-change-listener" -import { getFileIconByExtension } from "../mentions/agents-file-mention" +import { trpc } from "../../../lib/trpc" +import { cn } from "../../../lib/utils" import { - diffSidebarOpenAtomFamily, agentsFocusedDiffFileAtom, + diffSidebarOpenAtomFamily, filteredDiffFilesAtom, filteredSubChatIdAtom, + selectedDiffFilePathAtom, type SubChatFileChange, } from "../atoms" +import { getFileIconByExtension } from "../mentions/agents-file-mention" // Animated dots component that cycles through ., .., ... function AnimatedDots() { @@ -53,6 +54,7 @@ export const SubChatStatusCard = memo(function SubChatStatusCard({ onStop, hasQueueCardAbove = false, }: SubChatStatusCardProps) { + const isBusy = isStreaming || isCompacting const [isExpanded, setIsExpanded] = useState(false) // Use per-chat atom family instead of legacy global atom const diffSidebarAtom = useMemo( @@ -63,6 +65,7 @@ export const SubChatStatusCard = memo(function SubChatStatusCard({ const setFilteredDiffFiles = useSetAtom(filteredDiffFilesAtom) const setFilteredSubChatId = useSetAtom(filteredSubChatIdAtom) const setFocusedDiffFile = useSetAtom(agentsFocusedDiffFileAtom) + const setSelectedFilePath = useSetAtom(selectedDiffFilePathAtom) // Listen for file changes from Claude Write/Edit tools useFileChangeListener(worktreePath) @@ -71,7 +74,7 @@ export const SubChatStatusCard = memo(function SubChatStatusCard({ const { data: gitStatus } = trpc.changes.getStatus.useQuery( { worktreePath: worktreePath || "", defaultBranch: "main" }, { - enabled: !!worktreePath && changedFiles.length > 0 && !isStreaming, + enabled: !!worktreePath && changedFiles.length > 0 && !isBusy, // No polling - updates triggered by file-changed events from Claude tools staleTime: 30000, placeholderData: (prev) => prev, @@ -80,17 +83,8 @@ export const SubChatStatusCard = memo(function SubChatStatusCard({ // Filter changedFiles to only include files that are still uncommitted const uncommittedFiles = useMemo(() => { - console.log(`[StatusCard] Computing uncommittedFiles:`, { - changedFilesCount: changedFiles.length, - changedFiles: changedFiles.map(f => f.displayPath), - hasGitStatus: !!gitStatus, - worktreePath, - isStreaming, - }) - - // If no git status yet, no worktreePath, or still streaming - show all files - if (!gitStatus || !worktreePath || isStreaming) { - console.log(`[StatusCard] Returning all changedFiles (no filter)`) + // If no git status yet, no worktreePath, or still busy - show all files + if (!gitStatus || !worktreePath || isBusy) { return changedFiles } @@ -113,17 +107,9 @@ export const SubChatStatusCard = memo(function SubChatStatusCard({ } } - console.log(`[StatusCard] Git uncommitted paths:`, Array.from(uncommittedPaths)) - // Filter changedFiles to only include files that are still uncommitted - const filtered = changedFiles.filter((file) => { - const hasMatch = uncommittedPaths.has(file.displayPath) - console.log(`[StatusCard] Checking file "${file.displayPath}" -> hasMatch: ${hasMatch}`) - return hasMatch - }) - console.log(`[StatusCard] Filtered result:`, filtered.map(f => f.displayPath)) - return filtered - }, [changedFiles, gitStatus, worktreePath, isStreaming]) + return changedFiles.filter((file) => uncommittedPaths.has(file.displayPath)) + }, [changedFiles, gitStatus, worktreePath, isBusy]) // Calculate totals from uncommitted files only const totals = useMemo(() => { @@ -140,8 +126,7 @@ export const SubChatStatusCard = memo(function SubChatStatusCard({ const hasExpandableContent = uncommittedFiles.length > 0 // Don't show if no changed files - only show when there are files to review - if (uncommittedFiles.length === 0) { - console.log(`[StatusCard] Returning null - no uncommitted files`) + if (!isBusy && uncommittedFiles.length === 0) { return null } @@ -149,7 +134,6 @@ export const SubChatStatusCard = memo(function SubChatStatusCard({ // Set filter to only show files from this sub-chat // Use displayPath (relative path) to match git diff paths const filePaths = uncommittedFiles.map((f) => f.displayPath) - console.log('[SubChatStatusCard] handleReview:', { subChatId, filePaths }) setFilteredDiffFiles(filePaths.length > 0 ? filePaths : null) // Also set subchat ID filter for ChangesPanel - use the prop, not activeSubChatId from store setFilteredSubChatId(subChatId) @@ -189,14 +173,14 @@ export const SubChatStatusCard = memo(function SubChatStatusCard({ /> {/* Streaming indicator */} - {isStreaming && ( + {isBusy && ( {isCompacting ? "Compacting" : "Generating"} )} {/* File count and stats - only show when not streaming */} - {!isStreaming && ( + {!isBusy && ( {totals.fileCount} {totals.fileCount === 1 ? "file" : "files"} {(totals.additions > 0 || totals.deletions > 0) && ( @@ -217,7 +201,7 @@ export const SubChatStatusCard = memo(function SubChatStatusCard({ {/* Right side: buttons */}
{/* Stop button */} - {isStreaming && onStop && ( + {isBusy && onStop && ( + {uncommittedFiles.length > 0 && ( + + )}
@@ -262,11 +248,10 @@ export const SubChatStatusCard = memo(function SubChatStatusCard({ const FileIcon = getFileIconByExtension(file.displayPath) const handleFileClick = () => { - // Set filter to only show files from this sub-chat - // Use displayPath (relative path) to match git diff paths - const filePaths = uncommittedFiles.map((f) => f.displayPath) - setFilteredDiffFiles(filePaths.length > 0 ? filePaths : null) - // Set focus on this specific file + // Set selected file and filter to just this file — avoids intermediate 2→1 file flash + setSelectedFilePath(file.displayPath) + setFilteredDiffFiles([file.displayPath]) + // Set focus on this specific file (for scroll-to) setFocusedDiffFile(file.displayPath) // Open diff sidebar setDiffSidebarOpen(true) diff --git a/src/renderer/features/automations/_components/constants.ts b/src/renderer/features/automations/_components/constants.ts index bebcca43e..a1fafd10d 100644 --- a/src/renderer/features/automations/_components/constants.ts +++ b/src/renderer/features/automations/_components/constants.ts @@ -37,7 +37,7 @@ export const AUTOMATION_TABS = [ // ============================================================================== export const CLAUDE_MODELS = [ - { id: "opus", name: "Opus" }, - { id: "sonnet", name: "Sonnet" }, - { id: "haiku", name: "Haiku" }, + { id: "opus", name: "Opus 4.6" }, + { id: "sonnet", name: "Sonnet 4.5" }, + { id: "haiku", name: "Haiku 4.5" }, ] as const diff --git a/src/renderer/features/automations/inbox-view.tsx b/src/renderer/features/automations/inbox-view.tsx index aa0627f95..14cc28766 100644 --- a/src/renderer/features/automations/inbox-view.tsx +++ b/src/renderer/features/automations/inbox-view.tsx @@ -2,7 +2,7 @@ import "./inbox-styles.css" import { useAtomValue, useSetAtom, useAtom } from "jotai" -import { selectedTeamIdAtom, isDesktopAtom, isFullscreenAtom } from "../../lib/atoms" +import { selectedTeamIdAtom, isDesktopAtom, isFullscreenAtom, chatSourceModeAtom } from "../../lib/atoms" import { inboxSelectedChatIdAtom, agentsInboxSidebarWidthAtom, @@ -11,26 +11,11 @@ import { inboxMobileViewModeAtom, } from "../agents/atoms" import { IconSpinner, SettingsIcon } from "../../components/ui/icons" -import { Inbox as InboxIcon } from "lucide-react" +import { Inbox as InboxIcon, Archive as ArchiveIcon } from "lucide-react" import { Logo } from "../../components/ui/logo" -import { Badge } from "../../components/ui/badge" import { cn } from "../../lib/utils" import { useState, useMemo, useEffect, useCallback } from "react" -// Inline time-ago formatter to avoid date-fns dependency resolution issues -function formatDistanceToNow(date: Date): string { - const now = new Date() - const diffMs = now.getTime() - date.getTime() - const diffSec = Math.floor(diffMs / 1000) - const diffMin = Math.floor(diffSec / 60) - const diffHr = Math.floor(diffMin / 60) - const diffDay = Math.floor(diffHr / 24) - if (diffSec < 60) return "less than a minute" - if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? "" : "s"}` - if (diffHr < 24) return `${diffHr} hour${diffHr === 1 ? "" : "s"}` - if (diffDay < 30) return `${diffDay} day${diffDay === 1 ? "" : "s"}` - const diffMonth = Math.floor(diffDay / 30) - return `${diffMonth} month${diffMonth === 1 ? "" : "s"}` -} +import { formatTimeAgo } from "../agents/utils/format-time-ago" import { GitHubIcon } from "../../icons" import { ResizableSidebar } from "../../components/ui/resizable-sidebar" import { ArrowUpDown, AlignJustify } from "lucide-react" @@ -51,22 +36,19 @@ import { SelectValue, } from "../../components/ui/select" import { Switch } from "../../components/ui/switch" +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "../../components/ui/context-menu" import { ChatView } from "../agents/main/active-chat" -import { useAgentSubChatStore } from "../agents/stores/sub-chat-store" import { TrafficLightSpacer } from "../agents/components/traffic-light-spacer" - -function getStatusColor(status: string) { - switch (status) { - case "success": - return "bg-green-500/10 text-green-600 border-green-500/20" - case "failed": - return "bg-red-500/10 text-red-600 border-red-500/20" - case "pending": - return "bg-yellow-500/10 text-yellow-600 border-yellow-500/20" - default: - return "bg-muted text-muted-foreground" - } -} +import { OpenLocallyDialog } from "../agents/components/open-locally-dialog" +import { useAutoImport } from "../agents/hooks/use-auto-import" +import { trpc } from "../../lib/trpc" +import type { RemoteChat } from "../../lib/remote-api" interface InboxChat { id: string @@ -78,6 +60,206 @@ interface InboxChat { externalUrl: string | null status: string isRead: boolean + meta?: { repository?: string; branch?: string } | null +} + +function AutomationsIcon(props: React.SVGProps) { + return ( + + + + + + + ) +} + +// GitHub avatar with loading state and fallback +function InboxGitHubAvatar({ gitOwner }: { gitOwner: string }) { + const [isLoaded, setIsLoaded] = useState(false) + const [hasError, setHasError] = useState(false) + + if (hasError) { + return + } + + return ( +
+ {!isLoaded && ( +
+ )} + {gitOwner} setIsLoaded(true)} + onError={() => setHasError(true)} + /> +
+ ) +} + +function InboxChatIcon({ chat, isSelected }: { chat: InboxChat; isSelected: boolean }) { + const repoOwner = chat.meta?.repository?.split("/")[0] || null + + return ( +
+ {repoOwner ? ( + + ) : ( + + )} + {/* Unread badge - bottom-right, matching sidebar ChatIcon style */} + {!chat.isRead && ( +
+
+
+ )} +
+ ) +} + +function InboxItemDesktop({ + chat, + isSelected, + onClick, + onArchive, + onArchiveOthers, + onArchiveBelow, + onForkLocally, + isOnlyChat, + isLastChat, +}: { + chat: InboxChat + isSelected: boolean + onClick: () => void + onArchive: (e: React.MouseEvent) => void + onArchiveOthers: () => void + onArchiveBelow: () => void + onForkLocally: () => void + isOnlyChat: boolean + isLastChat: boolean +}) { + const repoName = chat.meta?.repository?.split("/").pop() || null + const displayText = repoName || chat.automationName + + return ( + + + +
+
+
+ {displayText} + {formatTimeAgo(new Date(chat.createdAt))} +
+
+
+ + + + + Fork Locally + + + onArchive(e as unknown as React.MouseEvent)}> + Archive + + + Archive others + + + Archive all below + + + + ) +} + +function InboxItemMobile({ + chat, + onClick, + onArchive, +}: { + chat: InboxChat + onClick: () => void + onArchive: (e: React.MouseEvent) => void +}) { + const repoName = chat.meta?.repository?.split("/").pop() || null + const displayText = repoName || chat.automationName + + return ( + +
+
+ {displayText} + {formatTimeAgo(new Date(chat.createdAt))} +
+
+
+ + ) } export function InboxView() { @@ -90,13 +272,22 @@ export function InboxView() { const isMobile = useIsMobile() const isDesktop = useAtomValue(isDesktopAtom) const isFullscreen = useAtomValue(isFullscreenAtom) + const setChatSourceMode = useSetAtom(chatSourceModeAtom) const queryClient = useQueryClient() + // Inbox always shows remote/sandbox chats — ensure correct mode on mount + useEffect(() => { + setChatSourceMode("sandbox") + }, [setChatSourceMode]) + const [searchQuery, setSearchQuery] = useState("") const [ordering, setOrdering] = useState<"newest" | "oldest">("newest") const [showRead, setShowRead] = useState(true) const [showUnreadFirst, setShowUnreadFirst] = useState(false) + // Fork Locally state + const [importDialogOpen, setImportDialogOpen] = useState(false) + const { data, isLoading } = useQuery({ queryKey: ["automations", "inboxChats", teamId], queryFn: () => remoteTrpc.automations.getInboxChats.query({ teamId: teamId!, limit: 50 }), @@ -112,6 +303,54 @@ export function InboxView() { }, }) + const archiveMutation = useMutation({ + mutationFn: (chatId: string) => + remoteTrpc.agents.archiveChat.mutate({ chatId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["automations", "inboxChats"] }) + queryClient.invalidateQueries({ queryKey: ["automations", "inboxUnreadCount"] }) + }, + }) + + // Fork Locally: projects, auto-import + // Note: inbox chats are excluded from useRemoteChats() (getAgentChats filters them out), + // so we fetch the individual chat on demand via remoteTrpc.agents.getAgentChat + const { data: projects } = trpc.projects.list.useQuery() + const { getMatchingProjects, autoImport, isImporting } = useAutoImport() + const [importingRemoteChat, setImportingRemoteChat] = useState(null) + + const handleForkLocally = useCallback( + async (chatId: string) => { + try { + const chatData = await remoteTrpc.agents.getAgentChat.query({ chatId }) + const remoteChat = chatData as RemoteChat + if (!remoteChat) return + + const matchingProjects = getMatchingProjects(projects ?? [], remoteChat) + + if (matchingProjects.length === 1) { + autoImport(remoteChat, matchingProjects[0]!) + } else { + setImportingRemoteChat(remoteChat) + setImportDialogOpen(true) + } + } catch (err) { + console.error("[InboxView] Failed to fetch chat for fork locally:", err) + } + }, + [projects, getMatchingProjects, autoImport] + ) + + const handleCloseImportDialog = useCallback(() => { + setImportDialogOpen(false) + setImportingRemoteChat(null) + }, []) + + const importMatchingProjects = useMemo(() => { + if (!importingRemoteChat) return [] + return getMatchingProjects(projects ?? [], importingRemoteChat) + }, [importingRemoteChat, projects, getMatchingProjects]) + // Auto-switch mobile view mode when chat is selected/deselected useEffect(() => { if (isMobile && selectedChatId && mobileViewMode === "list") { @@ -135,14 +374,6 @@ export function InboxView() { setAgentsMobileViewMode("chats") }, [setDesktopView, setAgentsMobileViewMode]) - // Initialize sub-chat store when chat is selected - useEffect(() => { - if (selectedChatId) { - const store = useAgentSubChatStore.getState() - store.setChatId(selectedChatId) - } - }, [selectedChatId]) - // Filter and sort chats const filteredChats = useMemo(() => { let chats = (data?.chats || []) as InboxChat[] @@ -185,9 +416,38 @@ export function InboxView() { if (!chat.isRead) { markReadMutation.mutate(chat.executionId) } + setChatSourceMode("sandbox") setSelectedChatId(chat.id) } + const handleArchive = useCallback((e: React.MouseEvent, chatId: string) => { + e.stopPropagation() + archiveMutation.mutate(chatId) + if (selectedChatId === chatId) { + setSelectedChatId(null) + } + }, [archiveMutation, selectedChatId, setSelectedChatId]) + + const handleArchiveOthers = useCallback((chatId: string) => { + filteredChats.forEach((chat) => { + if (chat.id !== chatId) { + archiveMutation.mutate(chat.id) + } + }) + setSelectedChatId(chatId) + }, [archiveMutation, filteredChats, setSelectedChatId]) + + const handleArchiveBelow = useCallback((chatId: string) => { + const index = filteredChats.findIndex((c) => c.id === chatId) + if (index === -1) return + filteredChats.slice(index + 1).forEach((chat) => { + archiveMutation.mutate(chat.id) + }) + if (selectedChatId && filteredChats.findIndex(c => c.id === selectedChatId) > index) { + setSelectedChatId(chatId) + } + }, [archiveMutation, filteredChats, selectedChatId, setSelectedChatId]) + if (!teamId) { return (
@@ -196,6 +456,36 @@ export function InboxView() { ) } + // Shared filter settings popover content + const filterContent = ( +
+
+
+ + Ordering +
+ +
+
+
+ Show read + +
+
+ Show unread first + +
+
+ ) + // Mobile layout - fullscreen list or fullscreen chat if (isMobile) { return ( @@ -222,32 +512,7 @@ export function InboxView() { -
-
-
- - Ordering -
- -
-
-
- Show read - -
-
- Show unread first - -
-
+ {filterContent}
@@ -277,47 +542,21 @@ export function InboxView() { ) : (
{filteredChats.map((chat) => ( - + onArchive={(e) => handleArchive(e, chat.id)} + /> ))}
)}
) : ( - // Fullscreen chat view {}} isMobileFullscreen={true} onBackToChats={handleBackToList} @@ -329,6 +568,7 @@ export function InboxView() { // Desktop layout return ( + <>
{/* Left sidebar - Inbox list */} -
-
-
- - Ordering -
- -
-
-
- Show read - -
-
- Show unread first - -
-
+ {filterContent}
@@ -432,32 +647,7 @@ export function InboxView() { -
-
-
- - Ordering -
- -
-
-
- Show read - -
-
- Show unread first - -
-
+ {filterContent}
@@ -487,43 +677,19 @@ export function InboxView() {
) : (
- {filteredChats.map((chat) => ( - + onArchive={(e) => handleArchive(e, chat.id)} + onArchiveOthers={() => handleArchiveOthers(chat.id)} + onArchiveBelow={() => handleArchiveBelow(chat.id)} + onForkLocally={() => handleForkLocally(chat.id)} + isOnlyChat={filteredChats.length <= 1} + isLastChat={index === filteredChats.length - 1} + /> ))}
)} @@ -536,7 +702,7 @@ export function InboxView() { {selectedChatId ? ( {}} /> ) : ( @@ -551,5 +717,15 @@ export function InboxView() { )}
+ + + ) } diff --git a/src/renderer/features/changes/changes-panel.tsx b/src/renderer/features/changes/changes-panel.tsx index e844686e2..306a35063 100644 --- a/src/renderer/features/changes/changes-panel.tsx +++ b/src/renderer/features/changes/changes-panel.tsx @@ -21,6 +21,8 @@ interface ChangesPanelProps { onCreatePr?: () => void; /** Called after a successful commit to reset diff view state */ onCommitSuccess?: () => void; + /** Called after discarding/deleting changes to refresh diff */ + onDiscardSuccess?: () => void; /** Available subchats for filtering */ subChats?: SubChatFilterItem[]; /** Currently selected subchat ID for filtering (passed from Review button) */ @@ -46,6 +48,7 @@ export function ChangesPanel({ onFileOpenPinned, onCreatePr, onCommitSuccess, + onDiscardSuccess, subChats, initialSubChatFilter, chatId, @@ -72,6 +75,7 @@ export function ChangesPanel({ onFileOpenPinned={onFileOpenPinned} onCreatePr={onCreatePr} onCommitSuccess={onCommitSuccess} + onDiscardSuccess={onDiscardSuccess} subChats={subChats} initialSubChatFilter={initialSubChatFilter} chatId={chatId} diff --git a/src/renderer/features/changes/changes-view.tsx b/src/renderer/features/changes/changes-view.tsx index d0f1db52f..80e82c7b4 100644 --- a/src/renderer/features/changes/changes-view.tsx +++ b/src/renderer/features/changes/changes-view.tsx @@ -36,6 +36,7 @@ import { GitPullRequest, Eye } from "lucide-react"; import type { ChangedFile as HistoryChangedFile } from "../../../shared/changes-types"; import { viewedFilesAtomFamily, type ViewedFileState } from "../agents/atoms"; import { Kbd } from "../../components/ui/kbd"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../../components/ui/tooltip"; // Memoized file item component with context menu to prevent re-renders const ChangesFileItemWithContext = memo(function ChangesFileItemWithContext({ @@ -128,16 +129,27 @@ const ChangesFileItemWithContext = memo(function ChangesFileItemWithContext({ onClick={(e) => e.stopPropagation()} className="size-4 shrink-0 border-muted-foreground/50" /> -
- {dirPath && ( - - {dirPath}/ - - )} - - {fileName} - -
+ + +
+ {dirPath && ( + + {dirPath}/ + + )} + + {fileName} + +
+
+ + {file.path} + +
{isViewed && (
@@ -232,6 +244,8 @@ interface ChangesViewProps { onCreatePr?: () => void; /** Called after a successful commit to reset diff view state */ onCommitSuccess?: () => void; + /** Called after discarding/deleting changes to refresh diff */ + onDiscardSuccess?: () => void; /** Available subchats for filtering */ subChats?: SubChatFilterItem[]; /** Currently selected subchat ID for filtering (passed from Review button) */ @@ -257,6 +271,7 @@ export function ChangesView({ onFileOpenPinned, onCreatePr, onCommitSuccess, + onDiscardSuccess, subChats = [], initialSubChatFilter = null, chatId, @@ -304,8 +319,8 @@ export function ChangesView({ // Handle successful commit - reset local state and notify parent const handleCommitSuccess = useCallback(() => { - // Reset selection state so new files will be auto-selected - setHasInitializedSelection(false); + // Clear commit selection without re-initializing auto-select + // (prevents remaining files from being re-selected after commit) setSelectedForCommit(new Set()); // Notify parent to reset diff view selection onCommitSuccess?.(); @@ -340,6 +355,7 @@ export function ChangesView({ onSuccess: () => { toast.success("Changes discarded"); refetch(); + onDiscardSuccess?.(); }, onError: (error) => { toast.error(`Failed to discard changes: ${error.message}`); @@ -349,6 +365,7 @@ export function ChangesView({ onSuccess: () => { toast.success("File deleted"); refetch(); + onDiscardSuccess?.(); }, onError: (error) => { toast.error(`Failed to delete file: ${error.message}`); @@ -360,6 +377,7 @@ export function ChangesView({ onSuccess: () => { toast.success("Changes discarded"); refetch(); + onDiscardSuccess?.(); }, onError: (error) => { toast.error(`Failed to discard changes: ${error.message}`); @@ -369,6 +387,7 @@ export function ChangesView({ onSuccess: () => { toast.success("Files deleted"); refetch(); + onDiscardSuccess?.(); }, onError: (error) => { toast.error(`Failed to delete files: ${error.message}`); @@ -394,6 +413,7 @@ export function ChangesView({ const [subChatFilter, setSubChatFilter] = useState(initialSubChatFilter); const [activeTab, setActiveTab] = useState<"changes" | "history">("changes"); const fileListRef = useRef(null); + const prevAllPathsRef = useRef>(new Set()); // Update subchat filter when initialSubChatFilter changes (e.g., from Review button) useEffect(() => { @@ -418,6 +438,7 @@ export function ChangesView({ setHasInitializedSelection(false); setSelectedForCommit(new Set()); setHighlightedFiles(new Set()); + prevAllPathsRef.current = new Set(); }, [worktreePath, initialSubChatFilter]); // Combine all files into a flat list @@ -447,13 +468,36 @@ export function ChangesView({ return files; }, [status]); - // Initialize selection - select all files by default when data loads + // Initialize selection, then auto-select newly added paths on subsequent updates useEffect(() => { + const allPaths = new Set(allFiles.map(f => f.file.path)); + if (!hasInitializedSelection && allFiles.length > 0) { - const allPaths = new Set(allFiles.map(f => f.file.path)); setSelectedForCommit(allPaths); setHasInitializedSelection(true); + prevAllPathsRef.current = allPaths; + return; } + + const prevPaths = prevAllPathsRef.current; + const newPaths: string[] = []; + for (const path of allPaths) { + if (!prevPaths.has(path)) { + newPaths.push(path); + } + } + + if (newPaths.length > 0) { + setSelectedForCommit(prev => { + const next = new Set(prev); + for (const path of newPaths) { + next.add(path); + } + return next; + }); + } + + prevAllPathsRef.current = allPaths; }, [allFiles, hasInitializedSelection]); // Get file paths for selected subchat filter diff --git a/src/renderer/features/changes/components/commit-input/commit-input.tsx b/src/renderer/features/changes/components/commit-input/commit-input.tsx index 010613647..5113ef8d5 100644 --- a/src/renderer/features/changes/components/commit-input/commit-input.tsx +++ b/src/renderer/features/changes/components/commit-input/commit-input.tsx @@ -1,13 +1,9 @@ import { Button } from "../../../../components/ui/button"; -import { toast } from "sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../../../components/ui/tooltip"; import { useState } from "react"; -import { trpc } from "../../../../lib/trpc"; import { cn } from "../../../../lib/utils"; import { IconSpinner } from "../../../../components/ui/icons"; -import { useQueryClient } from "@tanstack/react-query"; -import { useAtomValue } from "jotai"; -import { selectedOllamaModelAtom } from "../../../../lib/atoms"; +import { useCommitActions } from "./use-commit-actions"; interface CommitInputProps { worktreePath: string; @@ -35,40 +31,18 @@ export function CommitInput({ }: CommitInputProps) { const [summary, setSummary] = useState(""); const [description, setDescription] = useState(""); - const [isGenerating, setIsGenerating] = useState(false); - const queryClient = useQueryClient(); - const selectedOllamaModel = useAtomValue(selectedOllamaModelAtom); - - // AI commit message generation - const generateCommitMutation = trpc.chats.generateCommitMessage.useMutation(); - - // Use atomic commit when we have selected files (safer, single operation) - const atomicCommitMutation = trpc.changes.atomicCommit.useMutation({ - onSuccess: () => { + const { commit, isPending, isGenerating } = useCommitActions({ + worktreePath, + chatId, + onRefresh, + onCommitSuccess: () => { setSummary(""); setDescription(""); - // Invalidate the changes.getStatus query to force a fresh fetch - queryClient.invalidateQueries({ queryKey: [["changes", "getStatus"]] }); - onRefresh(); onCommitSuccess?.(); }, - onError: (error) => toast.error(`Commit failed: ${error.message}`), + onMessageGenerated: (message) => setSummary(message), }); - // Fallback to regular commit for staged changes - const commitMutation = trpc.changes.commit.useMutation({ - onSuccess: () => { - setSummary(""); - setDescription(""); - queryClient.invalidateQueries({ queryKey: [["changes", "getStatus"]] }); - onRefresh(); - onCommitSuccess?.(); - }, - onError: (error) => toast.error(`Commit failed: ${error.message}`), - }); - - const isPending = commitMutation.isPending || atomicCommitMutation.isPending || isGenerating; - // Build full commit message from summary and description const getCommitMessage = () => { const trimmedSummary = summary.trim(); @@ -85,53 +59,8 @@ export function CommitInput({ const handleCommit = async () => { if (!canCommit) return; - try { - // Get commit message - generate if empty - let commitMessage = getCommitMessage(); - console.log("[CommitInput] handleCommit called, commitMessage:", commitMessage, "chatId:", chatId); - - if (!commitMessage && chatId) { - console.log("[CommitInput] No message, generating with AI for files:", selectedFilePaths); - setIsGenerating(true); - try { - // Pass selected file paths to generate message only for those files - const result = await generateCommitMutation.mutateAsync({ - chatId, - filePaths: selectedFilePaths, - ollamaModel: selectedOllamaModel, - }); - console.log("[CommitInput] AI generated message:", result.message); - commitMessage = result.message; - // Also update the input field so user can see what was generated - setSummary(result.message); - } catch (error) { - console.error("[CommitInput] Failed to generate message:", error); - toast.error("Failed to generate commit message"); - setIsGenerating(false); - return; - } - setIsGenerating(false); - } - - if (!commitMessage) { - toast.error("Please enter a commit message"); - return; - } - - // Use atomic commit when we have selected files (single operation, safer) - if (selectedFilePaths && selectedFilePaths.length > 0) { - atomicCommitMutation.mutate({ - worktreePath, - filePaths: selectedFilePaths, - message: commitMessage, - }); - } else { - // Fallback to regular commit for pre-staged changes - commitMutation.mutate({ worktreePath, message: commitMessage }); - } - } catch (error) { - toast.error(`Failed to prepare commit: ${error instanceof Error ? error.message : "Unknown error"}`); - } + const commitMessage = getCommitMessage(); + await commit({ message: commitMessage, filePaths: selectedFilePaths }); }; // Build dynamic commit label @@ -156,7 +85,7 @@ export function CommitInput({ {/* Summary input - single line */} setSummary(e.target.value)} className={cn( diff --git a/src/renderer/features/changes/components/commit-input/index.ts b/src/renderer/features/changes/components/commit-input/index.ts index 5ba27f072..efbea91c3 100644 --- a/src/renderer/features/changes/components/commit-input/index.ts +++ b/src/renderer/features/changes/components/commit-input/index.ts @@ -1 +1,2 @@ export { CommitInput } from "./commit-input"; +export { useCommitActions } from "./use-commit-actions"; diff --git a/src/renderer/features/changes/components/commit-input/use-commit-actions.ts b/src/renderer/features/changes/components/commit-input/use-commit-actions.ts new file mode 100644 index 000000000..40823f44a --- /dev/null +++ b/src/renderer/features/changes/components/commit-input/use-commit-actions.ts @@ -0,0 +1,120 @@ +import { useCallback, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import { toast } from "sonner"; +import { trpc } from "../../../../lib/trpc"; +import { selectedOllamaModelAtom } from "../../../../lib/atoms"; + +interface CommitActionInput { + message?: string; + filePaths?: string[]; +} + +interface UseCommitActionsOptions { + worktreePath?: string | null; + chatId?: string; + onRefresh?: () => void; + onCommitSuccess?: () => void; + onMessageGenerated?: (message: string) => void; +} + +export function useCommitActions({ + worktreePath, + chatId, + onRefresh, + onCommitSuccess, + onMessageGenerated, +}: UseCommitActionsOptions) { + const [isGenerating, setIsGenerating] = useState(false); + const queryClient = useQueryClient(); + const selectedOllamaModel = useAtomValue(selectedOllamaModelAtom); + + const handleSuccess = useCallback(() => { + queryClient.invalidateQueries({ queryKey: [["changes", "getStatus"]] }); + onRefresh?.(); + onCommitSuccess?.(); + }, [queryClient, onRefresh, onCommitSuccess]); + + const handleError = useCallback((error: { message?: string }) => { + toast.error(`Commit failed: ${error.message ?? "Unknown error"}`); + }, []); + + // AI commit message generation + const generateCommitMutation = trpc.chats.generateCommitMessage.useMutation(); + + // Use atomic commit when we have selected files (safer, single operation) + const atomicCommitMutation = trpc.changes.atomicCommit.useMutation(); + + // Fallback to regular commit for staged changes + const commitMutation = trpc.changes.commit.useMutation(); + + const commit = useCallback( + async ({ message, filePaths }: CommitActionInput): Promise => { + if (!worktreePath) { + toast.error("Worktree path is required"); + return false; + } + + let commitMessage = message?.trim() ?? ""; + console.log("[CommitActions] commit called, commitMessage:", commitMessage, "chatId:", chatId); + + if (!commitMessage && chatId) { + console.log("[CommitActions] No message, generating with AI for files:", filePaths); + setIsGenerating(true); + try { + const result = await generateCommitMutation.mutateAsync({ + chatId, + filePaths, + ollamaModel: selectedOllamaModel, + }); + console.log("[CommitActions] AI generated message:", result.message); + commitMessage = result.message; + onMessageGenerated?.(result.message); + } catch (error) { + console.error("[CommitActions] Failed to generate message:", error); + toast.error("Failed to generate commit message"); + return false; + } finally { + setIsGenerating(false); + } + } + + if (!commitMessage) { + toast.error("Please enter a commit message"); + return false; + } + + try { + if (filePaths && filePaths.length > 0) { + await atomicCommitMutation.mutateAsync({ + worktreePath, + filePaths, + message: commitMessage, + }); + } else { + await commitMutation.mutateAsync({ worktreePath, message: commitMessage }); + } + handleSuccess(); + return true; + } catch (error) { + handleError(error as { message?: string }); + return false; + } + }, + [ + worktreePath, + chatId, + generateCommitMutation, + selectedOllamaModel, + onMessageGenerated, + atomicCommitMutation, + commitMutation, + handleSuccess, + handleError, + ], + ); + + const isPending = isGenerating || atomicCommitMutation.isPending || commitMutation.isPending; + + return { commit, isPending, isGenerating }; +} 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 f0d338791..994d26935 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 @@ -48,6 +48,8 @@ import { usePRStatus } from "../../../../hooks/usePRStatus"; import { PRIcon } from "../pr-icon"; import { toast } from "sonner"; import type { DiffViewMode } from "@/features/agents/ui/agent-diff-view"; +import { getSyncActionKind } from "../../utils/sync-actions"; +import { usePushAction } from "../../hooks/use-push-action"; interface DiffStats { isLoading: boolean; @@ -181,11 +183,10 @@ export const DiffSidebarHeader = memo(function DiffSidebarHeader({ }, }); - const pushMutation = trpc.changes.push.useMutation({ - onSuccess: () => { - onRefresh?.(); - }, - onError: (error) => toast.error(`Push failed: ${error.message}`), + const { push: pushBranch, isPending: isPushPending } = usePushAction({ + worktreePath, + hasUpstream, + onSuccess: onRefresh, }); const pullMutation = trpc.changes.pull.useMutation({ @@ -241,7 +242,7 @@ export const DiffSidebarHeader = memo(function DiffSidebarHeader({ }; const handlePush = () => { - pushMutation.mutate({ worktreePath, setUpstream: !hasUpstream }); + pushBranch(); }; const handlePull = () => { @@ -277,9 +278,14 @@ export const DiffSidebarHeader = memo(function DiffSidebarHeader({ }, []); // Check pending states - const isPushPending = pushMutation.isPending; const isPullPending = pullMutation.isPending; const isFetchPending = isRefreshing || fetchMutation.isPending; + const syncActionKind = getSyncActionKind({ + hasUpstream, + pullCount, + pushCount, + isSyncStatusLoading, + }); // ============ NEW BUTTON LOGIC ============ // Priority: @@ -304,7 +310,7 @@ export const DiffSidebarHeader = memo(function DiffSidebarHeader({ const getPrimaryAction = (): ActionButton => { // 0. Loading state - show loading indicator - if (isSyncStatusLoading) { + if (syncActionKind === "loading") { return { label: "", pendingLabel: "", @@ -318,7 +324,7 @@ export const DiffSidebarHeader = memo(function DiffSidebarHeader({ } // 1. Branch not published - must publish first - if (!hasUpstream) { + if (syncActionKind === "publish") { return { label: "Publish", pendingLabel: "Publishing...", @@ -331,7 +337,7 @@ export const DiffSidebarHeader = memo(function DiffSidebarHeader({ } // 2. Remote has changes we need to pull first - if (pullCount > 0) { + if (syncActionKind === "pull") { return { label: "Pull", pendingLabel: "Pulling...", @@ -345,7 +351,7 @@ export const DiffSidebarHeader = memo(function DiffSidebarHeader({ } // 3. We have commits to push - if (pushCount > 0) { + if (syncActionKind === "push") { return { label: "Push", pendingLabel: "Pushing...", diff --git a/src/renderer/features/changes/hooks/use-push-action.ts b/src/renderer/features/changes/hooks/use-push-action.ts new file mode 100644 index 000000000..ccbe7ba3c --- /dev/null +++ b/src/renderer/features/changes/hooks/use-push-action.ts @@ -0,0 +1,32 @@ +import { useCallback } from "react"; +import { toast } from "sonner"; +import { trpc } from "../../../lib/trpc"; + +interface UsePushActionOptions { + worktreePath?: string | null; + hasUpstream?: boolean; + onSuccess?: () => void; +} + +export function usePushAction({ + worktreePath, + hasUpstream = true, + onSuccess, +}: UsePushActionOptions) { + const pushMutation = trpc.changes.push.useMutation({ + onSuccess: () => { + onSuccess?.(); + }, + onError: (error) => toast.error(`Push failed: ${error.message}`), + }); + + const push = useCallback(() => { + if (!worktreePath) { + toast.error("Worktree path is required"); + return; + } + pushMutation.mutate({ worktreePath, setUpstream: !hasUpstream }); + }, [worktreePath, hasUpstream, pushMutation]); + + return { push, isPending: pushMutation.isPending }; +} diff --git a/src/renderer/features/changes/utils/index.ts b/src/renderer/features/changes/utils/index.ts index 661c60473..01a22e102 100644 --- a/src/renderer/features/changes/utils/index.ts +++ b/src/renderer/features/changes/utils/index.ts @@ -1,2 +1,4 @@ export { formatRelativeDate } from "./date"; export { getStatusColor, getStatusIndicator } from "./status"; +export { getSyncActionKind } from "./sync-actions"; +export type { SyncActionKind } from "./sync-actions"; diff --git a/src/renderer/features/changes/utils/sync-actions.ts b/src/renderer/features/changes/utils/sync-actions.ts new file mode 100644 index 000000000..5655759f1 --- /dev/null +++ b/src/renderer/features/changes/utils/sync-actions.ts @@ -0,0 +1,21 @@ +export type SyncActionKind = "loading" | "publish" | "pull" | "push" | "none"; + +interface SyncActionInput { + hasUpstream?: boolean; + pullCount?: number; + pushCount?: number; + isSyncStatusLoading?: boolean; +} + +export function getSyncActionKind({ + hasUpstream = true, + pullCount = 0, + pushCount = 0, + isSyncStatusLoading = false, +}: SyncActionInput): SyncActionKind { + if (isSyncStatusLoading) return "loading"; + if (!hasUpstream) return "publish"; + if (pullCount > 0) return "pull"; + if (pushCount > 0) return "push"; + return "none"; +} diff --git a/src/renderer/features/details-sidebar/details-sidebar.tsx b/src/renderer/features/details-sidebar/details-sidebar.tsx index 23da36740..9814237e8 100644 --- a/src/renderer/features/details-sidebar/details-sidebar.tsx +++ b/src/renderer/features/details-sidebar/details-sidebar.tsx @@ -39,7 +39,7 @@ import type { AgentMode } from "../agents/atoms" import { agentsSettingsDialogOpenAtom, agentsSettingsDialogActiveTabAtom, -} from "../../lib/atoms/agents-settings-dialog" +} from "@/lib/atoms" interface DetailsSidebarProps { /** Workspace/chat ID */ @@ -70,8 +70,16 @@ interface DetailsSidebarProps { parsedFileDiffs?: ParsedDiffFile[] | null /** Callback to commit selected changes */ onCommit?: (selectedPaths: string[]) => void + /** Callback to commit and push selected changes */ + onCommitAndPush?: (selectedPaths: string[]) => void /** Whether commit is in progress */ isCommitting?: boolean + /** Git sync status for push/pull actions */ + gitStatus?: { pushCount?: number; pullCount?: number; hasUpstream?: boolean } | null + /** Whether git sync status is loading */ + isGitStatusLoading?: boolean + /** Current branch name for header */ + currentBranch?: string /** Callbacks to expand widgets to legacy sidebars */ onExpandTerminal?: () => void onExpandPlan?: () => void @@ -105,7 +113,11 @@ export function DetailsSidebar({ diffStats, parsedFileDiffs, onCommit, + onCommitAndPush, isCommitting, + gitStatus, + isGitStatusLoading, + currentBranch, onExpandTerminal, onExpandPlan, onExpandDiff, @@ -408,7 +420,13 @@ export function DetailsSidebar({ diffStats={diffStats} parsedFileDiffs={parsedFileDiffs} onCommit={onCommit} + onCommitAndPush={onCommitAndPush} isCommitting={isCommitting} + pushCount={gitStatus?.pushCount ?? 0} + pullCount={gitStatus?.pullCount ?? 0} + hasUpstream={gitStatus?.hasUpstream ?? true} + isSyncStatusLoading={isGitStatusLoading} + currentBranch={currentBranch} // For remote chats on desktop, don't provide expand/file actions onExpand={canOpenDiff ? onExpandDiff : undefined} onFileSelect={canOpenDiff ? onFileSelect : undefined} @@ -453,4 +471,3 @@ export function DetailsSidebar({ ) } - diff --git a/src/renderer/features/details-sidebar/sections/changes-widget.tsx b/src/renderer/features/details-sidebar/sections/changes-widget.tsx index 8e7270f74..c42fdcbe8 100644 --- a/src/renderer/features/details-sidebar/sections/changes-widget.tsx +++ b/src/renderer/features/details-sidebar/sections/changes-widget.tsx @@ -1,6 +1,6 @@ "use client" -import { memo, useCallback, useMemo, useState, useEffect } from "react" +import { memo, useCallback, useMemo, useState, useEffect, useRef } from "react" import { useAtom, useAtomValue, useSetAtom } from "jotai" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" @@ -15,6 +15,7 @@ import { Kbd } from "@/components/ui/kbd" import { cn } from "@/lib/utils" import { useResolvedHotkeyDisplay } from "@/lib/hotkeys" import { viewedFilesAtomFamily, fileViewerOpenAtomFamily, diffSidebarOpenAtomFamily } from "@/features/agents/atoms" +import { getSyncActionKind } from "@/features/changes/utils" import { FileListItem, getFileName, @@ -31,7 +32,13 @@ interface ChangesWidgetProps { diffStats?: { additions: number; deletions: number; fileCount: number } | null parsedFileDiffs?: ParsedDiffFile[] | null onCommit?: (selectedPaths: string[]) => void + onCommitAndPush?: (selectedPaths: string[]) => void isCommitting?: boolean + pushCount?: number + pullCount?: number + hasUpstream?: boolean + isSyncStatusLoading?: boolean + currentBranch?: string onExpand?: () => void /** Called when a file is clicked - should open diff sidebar with this file selected */ onFileSelect?: (filePath: string) => void @@ -69,7 +76,13 @@ export const ChangesWidget = memo(function ChangesWidget({ diffStats, parsedFileDiffs, onCommit, + onCommitAndPush, isCommitting = false, + pushCount = 0, + pullCount = 0, + hasUpstream = true, + isSyncStatusLoading = false, + currentBranch, onExpand, onFileSelect, diffDisplayMode = "side-peek", @@ -98,6 +111,15 @@ export const ChangesWidget = memo(function ChangesWidget({ const openInFinderMutation = trpc.external.openInFinder.useMutation() const openInAppMutation = trpc.external.openInApp.useMutation() + const syncActionKind = getSyncActionKind({ + hasUpstream, + pullCount, + pushCount, + isSyncStatusLoading, + }) + + const shouldCommitAndPush = !!worktreePath && !!onCommitAndPush && !isSyncStatusLoading && syncActionKind !== "pull" && syncActionKind !== "loading" + // Preferred editor const preferredEditor = useAtomValue(preferredEditorAtom) const editorMeta = APP_META[preferredEditor] @@ -118,6 +140,7 @@ export const ChangesWidget = memo(function ChangesWidget({ // Selection state - all files selected by default const [selectedForCommit, setSelectedForCommit] = useState>(new Set()) const [hasInitializedSelection, setHasInitializedSelection] = useState(false) + const prevAllPathsRef = useRef>(new Set()) // Helper to get display path (handles /dev/null for deleted files) const getDisplayPath = useCallback((file: ParsedDiffFile): string => { @@ -130,13 +153,36 @@ export const ChangesWidget = memo(function ChangesWidget({ return file.newPath || file.oldPath }, []) - // Initialize selection - select all files by default when data loads + // Initialize selection, then auto-select newly added paths on subsequent updates useEffect(() => { + const allPaths = new Set(displayFiles.map((f) => getDisplayPath(f))) + if (!hasInitializedSelection && displayFiles.length > 0) { - const allPaths = new Set(displayFiles.map((f) => getDisplayPath(f))) setSelectedForCommit(allPaths) setHasInitializedSelection(true) + prevAllPathsRef.current = allPaths + return + } + + const prevPaths = prevAllPathsRef.current + const newPaths: string[] = [] + for (const path of allPaths) { + if (!prevPaths.has(path)) { + newPaths.push(path) + } } + + if (newPaths.length > 0) { + setSelectedForCommit((prev) => { + const next = new Set(prev) + for (const path of newPaths) { + next.add(path) + } + return next + }) + } + + prevAllPathsRef.current = allPaths }, [displayFiles, hasInitializedSelection, getDisplayPath]) // Reset selection when files change significantly @@ -144,6 +190,7 @@ export const ChangesWidget = memo(function ChangesWidget({ if (displayFiles.length === 0) { setHasInitializedSelection(false) setSelectedForCommit(new Set()) + prevAllPathsRef.current = new Set() } }, [displayFiles.length]) @@ -179,6 +226,9 @@ export const ChangesWidget = memo(function ChangesWidget({ ).length const allSelected = displayFiles.length > 0 && selectedCount === displayFiles.length const someSelected = selectedCount > 0 && selectedCount < displayFiles.length + const commitLabelSuffix = selectedCount > 0 + ? ` ${selectedCount} file${selectedCount !== 1 ? "s" : ""}` + : "" // Toggle all files selection const handleSelectAllChange = useCallback(() => { @@ -195,8 +245,12 @@ export const ChangesWidget = memo(function ChangesWidget({ const selectedPaths = displayFiles .filter((f) => selectedForCommit.has(getDisplayPath(f))) .map((f) => getDisplayPath(f)) - onCommit?.(selectedPaths) - }, [displayFiles, selectedForCommit, onCommit, getDisplayPath]) + if (shouldCommitAndPush && onCommitAndPush) { + onCommitAndPush(selectedPaths) + } else { + onCommit?.(selectedPaths) + } + }, [displayFiles, selectedForCommit, onCommit, onCommitAndPush, getDisplayPath, shouldCommitAndPush]) return (
@@ -206,8 +260,18 @@ export const ChangesWidget = memo(function ChangesWidget({ {/* Icon */} - {/* Title */} - Changes + {/* Title + branch */} +
+ Changes + {currentBranch && ( + + on + + {currentBranch} + + + )} +
{/* Stats in header - total lines changed */} {hasChanges && displayStats && ( @@ -319,7 +383,11 @@ export const ChangesWidget = memo(function ChangesWidget({ onClick={handleCommit} disabled={isCommitting || selectedCount === 0} > - {isCommitting ? "Committing..." : `Commit ${selectedCount} file${selectedCount !== 1 ? "s" : ""}`} + {isCommitting + ? (shouldCommitAndPush ? "Committing & pushing..." : "Committing...") + : (shouldCommitAndPush + ? `Commit & Push${commitLabelSuffix}` + : `Commit${commitLabelSuffix}`)} )} @@ -327,7 +395,7 @@ export const ChangesWidget = memo(function ChangesWidget({
)} + {/* Git activity badges - commit/PR pills */} + {(!isStreaming || !isLastMessage) && } + {isDev && showMessageJson && (
diff --git a/src/renderer/features/agents/main/chat-input-area.tsx b/src/renderer/features/agents/main/chat-input-area.tsx index 23401a7c6..03fa388cf 100644 --- a/src/renderer/features/agents/main/chat-input-area.tsx +++ b/src/renderer/features/agents/main/chat-input-area.tsx @@ -1412,7 +1412,10 @@ export const ChatInputArea = memo(function ChatInputArea({ {hasCustomClaudeConfig ? ( "Custom Model" ) : ( - selectedModel?.name + <> + {selectedModel?.name}{" "} + {selectedModel?.version} + )} @@ -1432,7 +1435,10 @@ export const ChatInputArea = memo(function ChatInputArea({ >
- {model.name} + + {model.name}{" "} + {model.version} +
{isSelected && ( diff --git a/src/renderer/features/agents/main/new-chat-form.tsx b/src/renderer/features/agents/main/new-chat-form.tsx index 858ef7732..ac1741e89 100644 --- a/src/renderer/features/agents/main/new-chat-form.tsx +++ b/src/renderer/features/agents/main/new-chat-form.tsx @@ -1813,7 +1813,7 @@ export function NewChatForm({ ) : ( <> {selectedModel?.name}{" "} - 4.5 + {selectedModel?.version} )} @@ -1836,7 +1836,7 @@ export function NewChatForm({ {model.name}{" "} - 4.5 + {model.version}
{isSelected && ( diff --git a/src/renderer/features/agents/ui/git-activity-badges.tsx b/src/renderer/features/agents/ui/git-activity-badges.tsx new file mode 100644 index 000000000..dfad9dc5d --- /dev/null +++ b/src/renderer/features/agents/ui/git-activity-badges.tsx @@ -0,0 +1,195 @@ +"use client" + +import { memo, useCallback, useMemo, useState } from "react" +import { GitCommit, GitPullRequest } from "lucide-react" +import { useSetAtom } from "jotai" +import { AnimatePresence, motion } from "motion/react" +import { + ExpandIcon, + CollapseIcon, +} from "../../../components/ui/icons" +import { + extractGitActivity, + extractChangedFiles, + type ChangedFileInfo, +} from "../utils/git-activity" +import { + diffSidebarOpenAtomFamily, + filteredDiffFilesAtom, + filteredSubChatIdAtom, + selectedCommitAtom, + diffActiveTabAtom, +} from "../atoms" +import { cn } from "../../../lib/utils" +import { getFileIconByExtension } from "../mentions/agents-file-mention" +import { useFileOpen } from "../mentions" + +interface GitActivityBadgesProps { + parts: any[] + chatId: string + subChatId: string +} + +export const GitActivityBadges = memo(function GitActivityBadges({ + parts, + chatId, + subChatId, +}: GitActivityBadgesProps) { + const setDiffSidebarOpen = useSetAtom(diffSidebarOpenAtomFamily(chatId)) + const setFilteredDiffFiles = useSetAtom(filteredDiffFilesAtom) + const setFilteredSubChatId = useSetAtom(filteredSubChatIdAtom) + const setSelectedCommit = useSetAtom(selectedCommitAtom) + const setDiffActiveTab = useSetAtom(diffActiveTabAtom) + const onOpenFile = useFileOpen() + + const [isExpanded, setIsExpanded] = useState(false) + + const activity = useMemo(() => extractGitActivity(parts), [parts]) + const changedFiles = useMemo(() => extractChangedFiles(parts), [parts]) + + const totals = useMemo(() => { + let additions = 0 + let deletions = 0 + for (const file of changedFiles) { + additions += file.additions + deletions += file.deletions + } + return { additions, deletions } + }, [changedFiles]) + + const handleOpenCommit = useCallback(() => { + if (activity?.type === "commit" && activity.hash) { + setSelectedCommit({ + hash: activity.hash, + shortHash: activity.hash.slice(0, 8), + message: activity.message, + }) + } + setFilteredDiffFiles(null) + setFilteredSubChatId(subChatId) + setDiffActiveTab("history") + setDiffSidebarOpen(true) + }, [activity, subChatId, setSelectedCommit, setFilteredDiffFiles, setFilteredSubChatId, setDiffActiveTab, setDiffSidebarOpen]) + + const handleFileClick = useCallback((file: ChangedFileInfo) => { + onOpenFile?.(file.filePath) + }, [onOpenFile]) + + if (!activity && changedFiles.length === 0) return null + + return ( +
+ {/* Changed files block - edit tool style */} + {changedFiles.length > 0 && ( +
+ {/* Header */} +
setIsExpanded(!isExpanded)} + className="flex items-center justify-between pl-2.5 pr-0.5 h-7 cursor-pointer hover:bg-muted/50 transition-colors duration-150" + > +
+ Edited {changedFiles.length} {changedFiles.length === 1 ? "file" : "files"} + {(totals.additions > 0 || totals.deletions > 0) && ( + <> + +{totals.additions} + -{totals.deletions} + + )} +
+ +
+ + {/* Expand/Collapse button */} +
+ +
+
+
+ + {/* File list */} + + {isExpanded && ( + +
+ {changedFiles.map((file) => { + const FileIcon = getFileIconByExtension(file.displayPath) + return ( +
handleFileClick(file)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + handleFileClick(file) + } + }} + className="flex items-center gap-2 px-2.5 py-1 text-xs hover:bg-muted/50 transition-colors cursor-pointer" + > + {FileIcon && ( + + )} + {file.displayPath} + +{file.additions} + -{file.deletions} +
+ ) + })} +
+
+ )} +
+
+ )} + + {/* Git activity badge */} + {activity?.type === "commit" && ( + + )} + + {activity?.type === "pr" && ( + + )} +
+ ) +}) diff --git a/src/renderer/features/agents/ui/sub-chat-status-card.tsx b/src/renderer/features/agents/ui/sub-chat-status-card.tsx index 535bbac8a..3e2762df0 100644 --- a/src/renderer/features/agents/ui/sub-chat-status-card.tsx +++ b/src/renderer/features/agents/ui/sub-chat-status-card.tsx @@ -152,25 +152,30 @@ export const SubChatStatusCard = memo(function SubChatStatusCard({
setIsExpanded(!isExpanded)} + onClick={() => hasExpandableContent && setIsExpanded(!isExpanded)} onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { + if (hasExpandableContent && (e.key === "Enter" || e.key === " ")) { e.preventDefault() setIsExpanded(!isExpanded) } }} - aria-expanded={isExpanded} + aria-expanded={hasExpandableContent ? isExpanded : undefined} aria-label={`${isExpanded ? "Collapse" : "Expand"} status details`} - className="flex items-center justify-between pr-1 pl-3 h-8 cursor-pointer hover:bg-muted/50 transition-colors duration-150 focus:outline-none rounded-sm" + className={cn( + "flex items-center justify-between pr-1 pl-3 h-8 transition-colors duration-150 focus:outline-none rounded-sm", + hasExpandableContent ? "cursor-pointer hover:bg-muted/50" : "cursor-default" + )} >
- {/* Expand/Collapse chevron - always show */} - + {/* Expand/Collapse chevron - only show when there are files to expand */} + {hasExpandableContent && ( + + )} {/* Streaming indicator */} {isBusy && ( diff --git a/src/renderer/features/agents/utils/git-activity.ts b/src/renderer/features/agents/utils/git-activity.ts new file mode 100644 index 000000000..df9bf18dd --- /dev/null +++ b/src/renderer/features/agents/utils/git-activity.ts @@ -0,0 +1,159 @@ +export interface GitCommitInfo { + type: "commit" + message: string + hash?: string +} + +export interface GitPrInfo { + type: "pr" + title: string + url: string + number?: number +} + +export type GitActivity = GitCommitInfo | GitPrInfo + +export interface ChangedFileInfo { + filePath: string + displayPath: string + additions: number + deletions: number +} + +/** + * Extract commit message from a git commit command and its output. + */ +function extractCommitInfo( + command: string, + stdout: string, +): GitCommitInfo | null { + if (!/git\s+commit/.test(command)) return null + + // Verify commit actually succeeded by checking stdout for git's commit output + // Format: [branch-name hash] commit message + const stdoutMatch = stdout.match(/\[[\w/.:-]+\s+([\da-f]+)\]\s+(.+)/) + if (!stdoutMatch) return null + + const hash = stdoutMatch[1] + let message = stdoutMatch[2]!.trim() + + // If stdout message is truncated, try to get full message from command + // Pattern 1: HEREDOC pattern (Claude's preferred format) + const heredocMatch = command.match( + /<<'?EOF'?\s*\n([\s\S]*?)\n\s*EOF/, + ) + if (heredocMatch) { + const heredocFirstLine = heredocMatch[1]!.split("\n")[0]!.trim() + if (heredocFirstLine) { + message = heredocFirstLine + } + } + + // Pattern 2: -m "message" or -m 'message' (simple inline message) + if (!heredocMatch) { + const mFlagMatch = command.match(/-m\s+["']([^"']+)["']/) + if (mFlagMatch) { + message = mFlagMatch[1]!.trim() + } + } + + return { type: "commit", message, hash } +} + +/** + * Extract PR info from a gh pr create command and its output. + */ +function extractPrInfo(command: string, stdout: string): GitPrInfo | null { + if (!/gh\s+pr\s+create/.test(command)) return null + + // Extract URL from stdout + const urlMatch = stdout.match( + /(https:\/\/github\.com\/[^\s]+\/pull\/\d+)/, + ) + if (!urlMatch) return null + + const url = urlMatch[1]! + 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 } +} + +/** + * Scan message parts and return the most significant git activity. + * Priority: last PR > last commit (PR is more significant). + * Returns null if no git activity found. + */ +export function extractGitActivity(parts: any[]): GitActivity | null { + let lastCommit: GitCommitInfo | null = null + let lastPr: GitPrInfo | null = null + + for (const part of parts) { + if (part.type !== "tool-Bash") continue + if (!part.output) continue + + const command: string = part.input?.command || "" + const stdout: string = part.output?.stdout || part.output?.output || "" + + const commit = extractCommitInfo(command, stdout) + if (commit) lastCommit = commit + + const pr = extractPrInfo(command, stdout) + if (pr) lastPr = pr + } + + // PR is more significant than commit + return lastPr || lastCommit +} + +function countLines(text: string): number { + if (!text) return 0 + return text.split("\n").length +} + +/** + * Extract changed files from Edit/Write tool parts in a message. + * Tracks additions and deletions per file. + */ +export function extractChangedFiles(parts: any[]): ChangedFileInfo[] { + const fileMap = new Map() + + for (const part of parts) { + if (part.type !== "tool-Edit" && part.type !== "tool-Write") continue + const filePath: string = part.input?.file_path || "" + if (!filePath) continue + + // Skip session/plan files + if (filePath.includes("claude-sessions") || filePath.includes("Application Support")) continue + + // Use basename as display, full path as key + const displayPath = filePath.split("/").pop() || filePath + + const existing = fileMap.get(filePath) + + if (part.type === "tool-Edit") { + const oldLines = countLines(part.input?.old_string || "") + const newLines = countLines(part.input?.new_string || "") + if (existing) { + existing.additions += newLines + existing.deletions += oldLines + } else { + fileMap.set(filePath, { filePath, displayPath, additions: newLines, deletions: oldLines }) + } + } else { + // tool-Write: all new content = additions + const lines = countLines(part.input?.content || "") + if (existing) { + existing.additions += lines + } else { + fileMap.set(filePath, { filePath, displayPath, additions: lines, deletions: 0 }) + } + } + } + + return Array.from(fileMap.values()) +} diff --git a/src/renderer/features/changes/changes-panel.tsx b/src/renderer/features/changes/changes-panel.tsx index 306a35063..8256234f1 100644 --- a/src/renderer/features/changes/changes-panel.tsx +++ b/src/renderer/features/changes/changes-panel.tsx @@ -5,6 +5,8 @@ import type { CommitInfo } from "./components/history-view"; interface ChangesPanelProps { worktreePath: string; + /** Controlled active tab for ChangesView */ + activeTab?: "changes" | "history"; /** Currently selected file path for highlighting */ selectedFilePath?: string | null; /** Callback when a file is selected */ @@ -43,6 +45,7 @@ interface ChangesPanelProps { export function ChangesPanel({ worktreePath, + activeTab, selectedFilePath, onFileSelect, onFileOpenPinned, @@ -70,6 +73,7 @@ export function ChangesPanel({
(initialSubChatFilter); - const [activeTab, setActiveTab] = useState<"changes" | "history">("changes"); + const [internalActiveTab, setInternalActiveTab] = useState<"changes" | "history">("changes"); + const activeTab = controlledActiveTab ?? internalActiveTab; const fileListRef = useRef(null); const prevAllPathsRef = useRef>(new Set()); + const handleActiveTabChange = useCallback((newTab: "changes" | "history") => { + // Update internal state only in uncontrolled mode + if (controlledActiveTab === undefined) { + setInternalActiveTab(newTab); + } + // Always notify parent and reset selected commit when leaving History + onActiveTabChange?.(newTab); + if (newTab === "changes" && onCommitSelect) { + onCommitSelect(null); + } + }, [controlledActiveTab, onActiveTabChange, onCommitSelect]); + // Update subchat filter when initialSubChatFilter changes (e.g., from Review button) useEffect(() => { setSubChatFilter(initialSubChatFilter); @@ -771,7 +787,7 @@ export function ChangesView({ }, [filteredFiles, highlightedFiles]); if (!worktreePath) { - return ( + return (
No worktree path available
@@ -779,7 +795,7 @@ export function ChangesView({ } if (isLoading) { - return ( + return (
Loading changes...
@@ -878,22 +894,16 @@ export function ChangesView({ }; return ( - <> -
- { - const newTab = v as "changes" | "history"; - setActiveTab(newTab); - // Notify parent about tab change - onActiveTabChange?.(newTab); - // Reset selected commit when switching to Changes tab - if (v === "changes" && onCommitSelect) { - onCommitSelect(null); - } - }} - className="flex flex-col h-full" - > + <> +
+ { + const newTab = v as "changes" | "history"; + handleActiveTabChange(newTab); + }} + className="flex flex-col h-full" + > {/* Tab triggers */}
-
{commit.message}
+
+ {commit.message} + {commit.tags?.map((tag) => ( + + {tag} + + ))} +
{commit.shortHash} · From 0bbece09eddc9d107c29f5fde9193895748bb876 Mon Sep 17 00:00:00 2001 From: serafim Date: Thu, 5 Feb 2026 20:08:50 -0800 Subject: [PATCH 4/6] Release v0.0.58 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Features - **Context Menu for Images** — Copy and save options added to fullscreen image viewer - **Graduated from Beta** — Rollback, Kanban, and Tasks features now available to all users - **Version Tags in History** — Show version tags on commits in history view ## Improvements & Fixes - **Optimized Archive Popover** — Improved archive popover with UnarchiveIcon - **Web Search Simplification** — Simplified web search results to single-line without icons - **Pasted Text Label** — Show "Using pasted text" label instead of "selected text" for pasted content - **Theme-Consistent Toasts** — Ensure toasts follow user-selected theme colors - **Auto-Collapse Sub-Agent** — Auto-collapse sub-agent tool when task completes - **Auto-Scroll on Send** — Scroll to bottom when queued message is auto-sent - **Thinking Tool UX** — Auto-expand/collapse thinking tool and fix exploring group collapse ## Downloads - **macOS ARM64 (Apple Silicon)**: Download the `-arm64.dmg` file - **macOS Intel**: Download the `.dmg` file (without arm64) Auto-updates are enabled. Existing users will be notified automatically. --- package.json | 2 +- src/main/lib/trpc/routers/chats.ts | 10 +- src/main/windows/main.ts | 38 +++ src/preload/index.ts | 5 + .../dialogs/settings-tabs/agents-beta-tab.tsx | 35 -- src/renderer/components/ui/icons.tsx | 22 ++ .../agents/components/queue-processor.tsx | 6 + .../agents/hooks/use-pasted-text-files.ts | 8 + .../features/agents/main/active-chat.tsx | 223 ++++++++++--- .../agents/main/assistant-message-item.tsx | 19 +- .../agents/main/isolated-message-group.tsx | 95 ++++-- .../features/agents/main/messages-list.tsx | 20 +- .../agents/stores/message-queue-store.ts | 16 + .../features/agents/stores/message-store.ts | 8 + .../features/agents/ui/agent-image-item.tsx | 109 ++++++- .../features/agents/ui/agent-task-tool.tsx | 9 + .../agents/ui/agent-thinking-tool.tsx | 44 ++- .../features/agents/ui/agent-tool-utils.ts | 8 +- .../agents/ui/agent-user-message-bubble.tsx | 6 +- .../ui/agent-web-search-collapsible.tsx | 14 +- .../agents/ui/agent-web-search-tool.tsx | 13 +- .../features/agents/ui/archive-popover.tsx | 16 +- .../agents/ui/git-activity-badges.tsx | 21 +- .../features/agents/ui/sub-chat-selector.tsx | 5 +- .../features/agents/utils/git-activity.ts | 14 +- .../features/automations/inbox-view.tsx | 302 +++++++++++++----- .../components/history-view/history-view.tsx | 18 +- src/renderer/lib/atoms/index.ts | 6 +- src/renderer/styles/globals.css | 35 +- 29 files changed, 817 insertions(+), 310 deletions(-) diff --git a/package.json b/package.json index d81fd516b..7ca4fca60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "21st-desktop", - "version": "0.0.57", + "version": "0.0.58", "private": true, "description": "1Code - UI for parallel work with AI agents", "author": { diff --git a/src/main/lib/trpc/routers/chats.ts b/src/main/lib/trpc/routers/chats.ts index 184545fd0..6f85a4d8c 100644 --- a/src/main/lib/trpc/routers/chats.ts +++ b/src/main/lib/trpc/routers/chats.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, inArray, isNotNull, isNull } from "drizzle-orm" +import { and, desc, eq, inArray, isNotNull, isNull, sql } from "drizzle-orm" import { BrowserWindow } from "electron" import * as fs from "fs/promises" import * as path from "path" @@ -1453,6 +1453,7 @@ export const chatsRouter = router({ 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, @@ -1460,7 +1461,12 @@ export const chatsRouter = router({ messages: subChats.messages, }) .from(subChats) - .where(inArray(subChats.chatId, input.chatIds)) + .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 diff --git a/src/main/windows/main.ts b/src/main/windows/main.ts index 36c88af32..f1997d39e 100644 --- a/src/main/windows/main.ts +++ b/src/main/windows/main.ts @@ -7,6 +7,7 @@ import { clipboard, session, nativeImage, + dialog, } from "electron" import { join } from "path" import { readFileSync, existsSync, writeFileSync, mkdirSync } from "fs" @@ -245,6 +246,43 @@ function registerIpcHandlers(): void { ) ipcMain.handle("clipboard:read", () => clipboard.readText()) + // Save file with native dialog + ipcMain.handle( + "dialog:save-file", + async ( + event, + options: { base64Data: string; filename: string; filters?: { name: string; extensions: string[] }[] }, + ) => { + const win = getWindowFromEvent(event) + if (!win) return { success: false } + + // Ensure window is focused before showing dialog (required on macOS) + if (!win.isFocused()) { + win.focus() + await new Promise((resolve) => setTimeout(resolve, 100)) + } + + const result = await dialog.showSaveDialog(win, { + defaultPath: options.filename, + filters: options.filters || [ + { name: "Images", extensions: ["png", "jpg", "jpeg", "webp", "gif"] }, + { name: "All Files", extensions: ["*"] }, + ], + }) + + if (result.canceled || !result.filePath) return { success: false } + + try { + const buffer = Buffer.from(options.base64Data, "base64") + writeFileSync(result.filePath, buffer) + return { success: true, filePath: result.filePath } + } catch (err) { + console.error("[dialog:save-file] Failed to write file:", err) + return { success: false } + } + }, + ) + // Auth IPC handlers const validateSender = (event: Electron.IpcMainInvokeEvent): boolean => { const senderUrl = event.sender.getURL() diff --git a/src/preload/index.ts b/src/preload/index.ts index bea19eef0..11a50490c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -131,6 +131,10 @@ contextBridge.exposeInMainWorld("desktopApi", { clipboardWrite: (text: string) => ipcRenderer.invoke("clipboard:write", text), clipboardRead: () => ipcRenderer.invoke("clipboard:read"), + // Save file with native dialog + saveFile: (options: { base64Data: string; filename: string; filters?: { name: string; extensions: string[] }[] }) => + ipcRenderer.invoke("dialog:save-file", options) as Promise<{ success: boolean; filePath?: string }>, + // Auth methods getUser: () => ipcRenderer.invoke("auth:get-user"), isAuthenticated: () => ipcRenderer.invoke("auth:is-authenticated"), @@ -315,6 +319,7 @@ export interface DesktopApi { getApiBaseUrl: () => Promise clipboardWrite: (text: string) => Promise clipboardRead: () => Promise + saveFile: (options: { base64Data: string; filename: string; filters?: { name: string; extensions: string[] }[] }) => Promise<{ success: boolean; filePath?: string }> // Auth getUser: () => Promise<{ id: string 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 f028dee42..b068eca46 100644 --- a/src/renderer/components/dialogs/settings-tabs/agents-beta-tab.tsx +++ b/src/renderer/components/dialogs/settings-tabs/agents-beta-tab.tsx @@ -5,9 +5,7 @@ import { useQuery } from "@tanstack/react-query" import { autoOfflineModeAtom, betaAutomationsEnabledAtom, - betaKanbanEnabledAtom, betaUpdatesEnabledAtom, - enableTasksAtom, historyEnabledAtom, selectedOllamaModelAtom, showOfflineModeFeaturesAtom, @@ -52,9 +50,7 @@ export function AgentsBetaTab() { const [showOfflineFeatures, setShowOfflineFeatures] = useAtom(showOfflineModeFeaturesAtom) const [autoOffline, setAutoOffline] = useAtom(autoOfflineModeAtom) const [selectedOllamaModel, setSelectedOllamaModel] = useAtom(selectedOllamaModelAtom) - const [kanbanEnabled, setKanbanEnabled] = useAtom(betaKanbanEnabledAtom) const [automationsEnabled, setAutomationsEnabled] = useAtom(betaAutomationsEnabledAtom) - const [enableTasks, setEnableTasks] = useAtom(enableTasksAtom) const [betaUpdatesEnabled, setBetaUpdatesEnabled] = useAtom(betaUpdatesEnabledAtom) // Check subscription to gate automations behind paid plan @@ -162,22 +158,6 @@ export function AgentsBetaTab() { />
- {/* Kanban Board Toggle */} -
-
- - Kanban Board - - - View workspaces as a Kanban board organized by status. - -
- -
- {/* Automations & Inbox Toggle */}
@@ -201,21 +181,6 @@ export function AgentsBetaTab() { />
- {/* Agent Tasks Toggle */} -
-
- - Agent Tasks - - - Enable Task instead of legacy Todo system. - -
- -
{/* Offline Mode Settings - only show when feature is enabled */} diff --git a/src/renderer/components/ui/icons.tsx b/src/renderer/components/ui/icons.tsx index bf4c4ccca..16a023f58 100644 --- a/src/renderer/components/ui/icons.tsx +++ b/src/renderer/components/ui/icons.tsx @@ -695,6 +695,28 @@ export function IconAlignEnd(props: IconProps) { ) } +export function UnarchiveIcon(props: IconProps) { + return ( + + + + + + + + ) +} + // Text alignment icons (5 options) export function IconTextUndo(props: IconProps) { return ( diff --git a/src/renderer/features/agents/components/queue-processor.tsx b/src/renderer/features/agents/components/queue-processor.tsx index 72ae9a721..b74f6292f 100644 --- a/src/renderer/features/agents/components/queue-processor.tsx +++ b/src/renderer/features/agents/components/queue-processor.tsx @@ -119,6 +119,12 @@ export function QueueProcessor() { ) } + // Signal active-chat to scroll to bottom BEFORE sending so that + // shouldAutoScrollRef is true for the entire streaming duration. + // (sendMessage awaits the full stream, so placing this after would + // only scroll after the response is complete.) + useMessageQueueStore.getState().triggerQueueSent(subChatId) + // Send message using Chat's sendMessage method await chat.sendMessage({ role: "user", parts }) diff --git a/src/renderer/features/agents/hooks/use-pasted-text-files.ts b/src/renderer/features/agents/hooks/use-pasted-text-files.ts index 7102a3432..71a2aff2e 100644 --- a/src/renderer/features/agents/hooks/use-pasted-text-files.ts +++ b/src/renderer/features/agents/hooks/use-pasted-text-files.ts @@ -16,6 +16,7 @@ export interface UsePastedTextFilesReturn { removePastedText: (id: string) => void clearPastedTexts: () => void pastedTextsRef: React.RefObject + setPastedTextsFromDraft: (texts: PastedTextFile[]) => void } export function usePastedTextFiles(subChatId: string): UsePastedTextFilesReturn { @@ -62,11 +63,18 @@ export function usePastedTextFiles(subChatId: string): UsePastedTextFilesReturn setPastedTexts([]) }, []) + // Direct state setter for restoring from draft/rollback + const setPastedTextsFromDraft = useCallback((texts: PastedTextFile[]) => { + setPastedTexts(texts) + pastedTextsRef.current = texts + }, []) + return { pastedTexts, addPastedText, removePastedText, clearPastedTexts, pastedTextsRef, + setPastedTextsFromDraft, } } diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index bcb683388..24ba548ee 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -16,7 +16,7 @@ import { IconCloseSidebarRight, IconOpenSidebarRight, IconSpinner, - IconTextUndo, + UnarchiveIcon, PauseIcon, VolumeIcon } from "../../../components/ui/icons" @@ -153,15 +153,16 @@ import { OpenLocallyDialog } from "../components/open-locally-dialog" import { PreviewSetupHoverCard } from "../components/preview-setup-hover-card" import type { TextSelectionSource } from "../context/text-selection-context" import { TextSelectionProvider } from "../context/text-selection-context" -import { useAgentsFileUpload } from "../hooks/use-agents-file-upload" +import { useAgentsFileUpload, type UploadedImage } from "../hooks/use-agents-file-upload" import { useAutoImport } from "../hooks/use-auto-import" import { useChangedFilesTracking } from "../hooks/use-changed-files-tracking" import { useDesktopNotifications } from "../hooks/use-desktop-notifications" import { useFocusInputOnEnter } from "../hooks/use-focus-input-on-enter" import { useHaptic } from "../hooks/use-haptic" -import { usePastedTextFiles } from "../hooks/use-pasted-text-files" +import { usePastedTextFiles, type PastedTextFile } from "../hooks/use-pasted-text-files" import { useTextContextSelection } from "../hooks/use-text-context-selection" import { useToggleFocusOnCmdEsc } from "../hooks/use-toggle-focus-on-cmd-esc" +import { type SelectedTextContext, type DiffTextContext, createTextPreview } from "../lib/queue-utils" import { clearSubChatDraft, getSubChatDraftFull @@ -234,6 +235,13 @@ function utf8ToBase64(str: string): string { return btoa(binString) } +// UTF-8 safe base64 decoding (atob doesn't support Unicode) +function base64ToUtf8(base64: string): string { + const binString = atob(base64) + const bytes = Uint8Array.from(binString, (char) => char.codePointAt(0)!) + return new TextDecoder().decode(bytes) +} + /** Wait for streaming to finish by subscribing to the status store. * Includes a 30s safety timeout — if the store never transitions to "ready", * the promise resolves anyway to prevent hanging the UI indefinitely. */ @@ -750,38 +758,6 @@ function PlayButton({ ) } -// Rollback button component for reverting to a previous message state -function RollbackButton({ - disabled = false, - onRollback, - isRollingBack = false, -}: { - disabled?: boolean - onRollback: () => void - isRollingBack?: boolean -}) { - return ( - - - - - - {isRollingBack ? "Rolling back..." : "Rollback to here"} - - - ) -} - // Isolated scroll-to-bottom button - uses own scroll listener to avoid re-renders of parent const ScrollToBottomButton = memo(function ScrollToBottomButton({ containerRef, @@ -2272,6 +2248,7 @@ const ChatViewInner = memo(function ChatViewInner({ removePastedText, clearPastedTexts, pastedTextsRef, + setPastedTextsFromDraft, } = usePastedTextFiles(subChatId) // File contents cache - stores content for file mentions (keyed by mentionId) @@ -3133,10 +3110,11 @@ const ChatViewInner = memo(function ChatViewInner({ projectPath, ) - // Rollback handler - truncates messages to the clicked assistant message and restores git state - // The SDK UUID from the last assistant message will be used for resumeSessionAt on next send + // Rollback handler - triggered from user message bubble + // Finds the last assistant message BEFORE this user message, rolls back to it, + // and inserts the user message text into the input for easy re-sending const handleRollback = useCallback( - async (assistantMsg: (typeof messages)[0]) => { + async (userMsg: (typeof messages)[0]) => { if (isRollingBack) { toast.error("Rollback already in progress") return @@ -3146,12 +3124,138 @@ const ChatViewInner = memo(function ChatViewInner({ return } - const sdkUuid = (assistantMsg.metadata as any)?.sdkMessageUuid + // Find the index of this user message + const userMsgIndex = messages.findIndex((m) => m.id === userMsg.id) + if (userMsgIndex === -1) { + toast.error("Cannot rollback: message not found") + return + } + + // Find the last assistant message BEFORE this user message + let targetAssistantMsg: (typeof messages)[0] | null = null + for (let i = userMsgIndex - 1; i >= 0; i--) { + if (messages[i].role === "assistant") { + targetAssistantMsg = messages[i] + break + } + } + + if (!targetAssistantMsg) { + toast.error("Cannot rollback: no previous assistant message found") + return + } + + const sdkUuid = (targetAssistantMsg.metadata as any)?.sdkMessageUuid if (!sdkUuid) { toast.error("Cannot rollback: message has no SDK UUID") return } + // Extract raw text from user message (includes mention tokens) + const rawText = userMsg.parts + ?.filter((p: any) => p.type === "text") + .map((p: any) => p.text) + .join("\n") || "" + + // Parse mention tokens from text to restore text contexts, diff contexts, and pasted texts + const restoredTextContexts: SelectedTextContext[] = [] + const restoredDiffTextContexts: DiffTextContext[] = [] + const restoredPastedTexts: PastedTextFile[] = [] + let cleanedText = rawText + + const mentionRegex = /@\[([^\]]+)\]/g + let match: RegExpExecArray | null + const mentionsToRemove: string[] = [] + + while ((match = mentionRegex.exec(rawText)) !== null) { + const id = match[1] + + if (id.startsWith("quote:")) { + const content = id.slice("quote:".length) + const sepIdx = content.indexOf(":") + if (sepIdx !== -1) { + const preview = content.slice(0, sepIdx) + const encoded = content.slice(sepIdx + 1) + let fullText = preview + try { fullText = base64ToUtf8(encoded) } catch { /* use preview */ } + restoredTextContexts.push({ + id: `tc_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`, + text: fullText, + sourceMessageId: "", + preview: createTextPreview(fullText), + createdAt: new Date(), + }) + } + mentionsToRemove.push(match[0]) + } else if (id.startsWith("diff:")) { + const content = id.slice("diff:".length) + const parts = content.split(":") + if (parts.length >= 3) { + const filePath = parts[0] || "" + const lineNumber = parseInt(parts[1] || "0", 10) || undefined + const preview = parts[2] || "" + const encoded = parts.slice(3).join(":") + let fullText = preview + try { if (encoded) fullText = base64ToUtf8(encoded) } catch { /* use preview */ } + restoredDiffTextContexts.push({ + id: `dtc_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`, + text: fullText, + filePath, + lineNumber, + preview: createTextPreview(fullText), + createdAt: new Date(), + }) + } + mentionsToRemove.push(match[0]) + } else if (id.startsWith("pasted:")) { + const content = id.slice("pasted:".length) + const pipeIdx = content.lastIndexOf("|") + if (pipeIdx !== -1) { + const beforePipe = content.slice(0, pipeIdx) + const filePath = content.slice(pipeIdx + 1) + const colonIdx = beforePipe.indexOf(":") + if (colonIdx !== -1) { + const size = parseInt(beforePipe.slice(0, colonIdx) || "0", 10) + const preview = beforePipe.slice(colonIdx + 1) + restoredPastedTexts.push({ + id: `pasted_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`, + filePath, + filename: filePath.split("/").pop() || "pasted.txt", + size, + preview, + createdAt: new Date(), + }) + } + } + mentionsToRemove.push(match[0]) + } + } + + // Remove mention tokens from text to get clean user text + for (const mentionStr of mentionsToRemove) { + cleanedText = cleanedText.replace(mentionStr, "") + } + cleanedText = cleanedText + .split("\n") + .map((line: string) => line.trim()) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim() + + // Extract images from user message for restoring into input + const userMsgImages: UploadedImage[] = (userMsg.parts || []) + .filter((p: any) => p.type === "data-image" && p.data) + .map((p: any) => ({ + id: crypto.randomUUID(), + filename: p.data.filename || "image", + url: p.data.url || (p.data.base64Data && p.data.mediaType + ? `data:${p.data.mediaType};base64,${p.data.base64Data}` + : ""), + base64Data: p.data.base64Data, + mediaType: p.data.mediaType, + isLoading: false, + })) + setIsRollingBack(true) try { @@ -3171,6 +3275,24 @@ const ChatViewInner = memo(function ChatViewInner({ setMessages(result.messages) recomputeChangedFiles(result.messages) refreshDiff?.() + + // Restore all user message content into input + if (cleanedText) { + editorRef.current?.setValue(cleanedText) + } + if (userMsgImages.length > 0) { + setImagesFromDraft(userMsgImages) + } + if (restoredTextContexts.length > 0) { + setTextContextsFromDraft(restoredTextContexts) + } + if (restoredDiffTextContexts.length > 0) { + setDiffTextContextsFromDraft(restoredDiffTextContexts) + } + if (restoredPastedTexts.length > 0) { + setPastedTextsFromDraft(restoredPastedTexts) + } + editorRef.current?.focus() } catch (error) { console.error("[handleRollback] Error:", error) toast.error("Failed to rollback") @@ -3181,10 +3303,15 @@ const ChatViewInner = memo(function ChatViewInner({ [ isRollingBack, isStreaming, + messages, setMessages, subChatId, recomputeChangedFiles, refreshDiff, + setImagesFromDraft, + setTextContextsFromDraft, + setDiffTextContextsFromDraft, + setPastedTextsFromDraft, ], ) @@ -3412,6 +3539,22 @@ const ChatViewInner = memo(function ChatViewInner({ } }, [isActive, messages, status, subChatId]) + // Scroll to bottom when QueueProcessor auto-sends a queued message. + // QueueProcessor runs globally and can't access scroll refs, so it + // signals via a store trigger that we subscribe to here. + useEffect(() => { + const unsub = useMessageQueueStore.subscribe( + (state) => state.queueSentTriggers[subChatId] || 0, + (trigger) => { + if (trigger === 0) return + if (!isActiveRef.current) return + shouldAutoScrollRef.current = true + scrollToBottom() + }, + ) + return unsub + }, [subChatId, scrollToBottom]) + // Auto-focus input when switching to this chat (any sub-chat change) // Skip on mobile to prevent keyboard from opening automatically useEffect(() => { @@ -6763,7 +6906,7 @@ Make sure to preserve all functionality from both branches when resolving confli className="h-6 px-2 gap-1.5 hover:bg-foreground/10 transition-colors text-foreground flex-shrink-0 rounded-md ml-2 flex items-center" aria-label="Restore workspace" > - + Restore diff --git a/src/renderer/features/agents/main/assistant-message-item.tsx b/src/renderer/features/agents/main/assistant-message-item.tsx index 75cac6c3d..a3b953846 100644 --- a/src/renderer/features/agents/main/assistant-message-item.tsx +++ b/src/renderer/features/agents/main/assistant-message-item.tsx @@ -4,10 +4,9 @@ import { useAtomValue } from "jotai" import { ListTree } from "lucide-react" import { memo, useCallback, useContext, useMemo, useState } from "react" -import { CollapseIcon, ExpandIcon, IconTextUndo, PlanIcon } from "../../../components/ui/icons" +import { CollapseIcon, ExpandIcon, PlanIcon } from "../../../components/ui/icons" import { TextShimmer } from "../../../components/ui/text-shimmer" import { cn } from "../../../lib/utils" -import { isRollingBackAtom } from "../stores/message-store" import { selectedProjectAtom, showMessageJsonAtom } from "../atoms" import { MessageJsonDisplay } from "../ui/message-json-display" import { AgentAskUserQuestionTool } from "../ui/agent-ask-user-question-tool" @@ -177,7 +176,6 @@ export interface AssistantMessageItemProps { subChatId: string chatId: string sandboxSetupStatus?: "cloning" | "ready" | "error" - onRollback?: (msg: any) => void } // Cache for tracking previous message state per message (to detect AI SDK in-place mutations) @@ -277,9 +275,7 @@ export const AssistantMessageItem = memo(function AssistantMessageItem({ subChatId, chatId, sandboxSetupStatus = "ready", - onRollback, }: AssistantMessageItemProps) { - const isRollingBack = useAtomValue(isRollingBackAtom) const showMessageJson = useAtomValue(showMessageJsonAtom) const selectedProject = useAtomValue(selectedProjectAtom) const projectPath = selectedProject?.path @@ -724,19 +720,6 @@ export const AssistantMessageItem = memo(function AssistantMessageItem({ text={getMessageTextContent(message)} isMobile={isMobile} /> - {onRollback && (message.metadata as any)?.sdkMessageUuid && ( - - )}
diff --git a/src/renderer/features/agents/main/isolated-message-group.tsx b/src/renderer/features/agents/main/isolated-message-group.tsx index 5b04ac30b..624ea6a7d 100644 --- a/src/renderer/features/agents/main/isolated-message-group.tsx +++ b/src/renderer/features/agents/main/isolated-message-group.tsx @@ -6,11 +6,16 @@ import { messageAtomFamily, assistantIdsForUserMsgAtomFamily, isLastUserMessageAtomFamily, + isFirstUserMessageAtomFamily, isStreamingAtom, + isRollingBackAtom, } from "../stores/message-store" import { MemoizedAssistantMessages } from "./messages-list" import { extractTextMentions, TextMentionBlocks, TextMentionBlock } from "../mentions/render-file-mentions" import { AgentImageItem } from "../ui/agent-image-item" +import { IconTextUndo } from "../../../components/ui/icons" +import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip" +import { cn } from "../../../lib/utils" // ============================================================================ // ISOLATED MESSAGE GROUP (LAYER 4) @@ -96,7 +101,12 @@ export const IsolatedMessageGroup = memo(function IsolatedMessageGroup({ const userMsg = useAtomValue(messageAtomFamily(userMsgId)) const assistantIds = useAtomValue(assistantIdsForUserMsgAtomFamily(userMsgId)) const isLastGroup = useAtomValue(isLastUserMessageAtomFamily(userMsgId)) + const isFirstUserMessage = useAtomValue(isFirstUserMessageAtomFamily(userMsgId)) const isStreaming = useAtomValue(isStreamingAtom) + const isRollingBack = useAtomValue(isRollingBackAtom) + + // Show rollback button on non-first user messages (first has no preceding assistant to roll back to) + const canRollback = onRollback && !isFirstUserMessage && !isStreaming // Extract user message content // Note: file-content parts are hidden from UI but sent to agent @@ -169,39 +179,65 @@ export const IsolatedMessageGroup = memo(function IsolatedMessageGroup({ {/* User message text - sticky (or attachment-only summary bubble) */}
div]:!mb-4 pointer-events-auto sticky z-10 ${stickyTopClass}`} + className={`group/user-message [&>div]:!mb-4 pointer-events-auto sticky z-10 ${stickyTopClass}`} > {/* Show "Using X" summary when no text but have attachments */} - {isAttachmentOnlyMessage && !isImageOnlyMessage ? ( -
-
-
- {(() => { - const parts: string[] = [] - if (imageParts.length > 0) { - parts.push(imageParts.length === 1 ? "image" : `${imageParts.length} images`) - } - const quoteCount = textMentions.filter(m => m.type === "quote" || m.type === "pasted").length - const codeCount = textMentions.filter(m => m.type === "diff").length - if (quoteCount > 0) { - parts.push(quoteCount === 1 ? "selected text" : `${quoteCount} text selections`) - } - if (codeCount > 0) { - parts.push(codeCount === 1 ? "code selection" : `${codeCount} code selections`) - } - return `Using ${parts.join(", ")}` - })()} +
+ {isAttachmentOnlyMessage && !isImageOnlyMessage ? ( +
+
+
+ {(() => { + const parts: string[] = [] + if (imageParts.length > 0) { + parts.push(imageParts.length === 1 ? "image" : `${imageParts.length} images`) + } + const quoteCount = textMentions.filter(m => m.type === "quote" || m.type === "pasted").length + const codeCount = textMentions.filter(m => m.type === "diff").length + if (quoteCount > 0) { + parts.push(quoteCount === 1 ? "selected text" : `${quoteCount} text selections`) + } + if (codeCount > 0) { + parts.push(codeCount === 1 ? "code selection" : `${codeCount} code selections`) + } + return `Using ${parts.join(", ")}` + })()} +
-
- ) : ( - - )} + ) : ( + + )} + + {/* Rollback button - overlay bottom-right of user bubble */} + {canRollback && ( +
+ + + + + + {isRollingBack ? "Rolling back..." : "Rollback to here"} + + +
+ )} +
{/* Cloning indicator */} {shouldShowCloning && ( @@ -244,7 +280,6 @@ export const IsolatedMessageGroup = memo(function IsolatedMessageGroup({ chatId={chatId} isMobile={isMobile} sandboxSetupStatus={sandboxSetupStatus} - onRollback={onRollback} /> )} diff --git a/src/renderer/features/agents/main/messages-list.tsx b/src/renderer/features/agents/main/messages-list.tsx index e2787b7a6..e9c050441 100644 --- a/src/renderer/features/agents/main/messages-list.tsx +++ b/src/renderer/features/agents/main/messages-list.tsx @@ -291,7 +291,6 @@ interface MessageItemWrapperProps { chatId: string isMobile: boolean sandboxSetupStatus: "cloning" | "ready" | "error" - onRollback?: (msg: any) => void } // Hook that only re-renders THIS component when it becomes/stops being the last message @@ -364,14 +363,12 @@ const NonStreamingMessageItem = memo(function NonStreamingMessageItem({ chatId, isMobile, sandboxSetupStatus, - onRollback, }: { messageId: string subChatId: string chatId: string isMobile: boolean sandboxSetupStatus: "cloning" | "ready" | "error" - onRollback?: (msg: any) => void }) { // Subscribe to this specific message via Jotai - only re-renders when THIS message changes const message = useAtomValue(messageAtomFamily(messageId)) @@ -388,7 +385,6 @@ const NonStreamingMessageItem = memo(function NonStreamingMessageItem({ chatId={chatId} isMobile={isMobile} sandboxSetupStatus={sandboxSetupStatus} - onRollback={onRollback} /> ) }) @@ -401,14 +397,12 @@ const StreamingMessageItem = memo(function StreamingMessageItem({ chatId, isMobile, sandboxSetupStatus, - onRollback, }: { messageId: string subChatId: string chatId: string isMobile: boolean sandboxSetupStatus: "cloning" | "ready" | "error" - onRollback?: (msg: any) => void }) { // Subscribe to this specific message via Jotai - only re-renders when THIS message changes const message = useAtomValue(messageAtomFamily(messageId)) @@ -429,7 +423,6 @@ const StreamingMessageItem = memo(function StreamingMessageItem({ chatId={chatId} isMobile={isMobile} sandboxSetupStatus={sandboxSetupStatus} - onRollback={onRollback} /> ) }) @@ -487,7 +480,6 @@ export const MessageItemWrapper = memo(function MessageItemWrapper({ chatId, isMobile, sandboxSetupStatus, - onRollback, }: MessageItemWrapperProps) { // Only subscribe to isLast - NOT to message content! @@ -504,7 +496,6 @@ export const MessageItemWrapper = memo(function MessageItemWrapper({ chatId={chatId} isMobile={isMobile} sandboxSetupStatus={sandboxSetupStatus} - onRollback={onRollback} /> ) } @@ -517,7 +508,6 @@ export const MessageItemWrapper = memo(function MessageItemWrapper({ chatId={chatId} isMobile={isMobile} sandboxSetupStatus={sandboxSetupStatus} - onRollback={onRollback} /> ) }) @@ -537,7 +527,6 @@ interface MemoizedAssistantMessagesProps { chatId: string isMobile: boolean sandboxSetupStatus: "cloning" | "ready" | "error" - onRollback?: (msg: any) => void } function areMemoizedAssistantMessagesEqual( @@ -561,7 +550,6 @@ function areMemoizedAssistantMessagesEqual( if (prev.chatId !== next.chatId) return false if (prev.isMobile !== next.isMobile) return false if (prev.sandboxSetupStatus !== next.sandboxSetupStatus) return false - if (prev.onRollback !== next.onRollback) return false return true } @@ -572,7 +560,6 @@ export const MemoizedAssistantMessages = memo(function MemoizedAssistantMessages chatId, isMobile, sandboxSetupStatus, - onRollback, }: MemoizedAssistantMessagesProps) { // This component only re-renders when assistantMsgIds changes // During streaming, IDs stay the same, so this doesn't re-render @@ -588,7 +575,6 @@ export const MemoizedAssistantMessages = memo(function MemoizedAssistantMessages chatId={chatId} isMobile={isMobile} sandboxSetupStatus={sandboxSetupStatus} - onRollback={onRollback} /> ))} @@ -1055,11 +1041,15 @@ export const SimpleIsolatedGroup = memo(function SimpleIsolatedGroup({ if (imageParts.length > 0) { parts.push(imageParts.length === 1 ? "image" : `${imageParts.length} images`) } - const quoteCount = textMentions.filter(m => m.type === "quote" || m.type === "pasted").length + const quoteCount = textMentions.filter(m => m.type === "quote").length + const pastedCount = textMentions.filter(m => m.type === "pasted").length const codeCount = textMentions.filter(m => m.type === "diff").length if (quoteCount > 0) { parts.push(quoteCount === 1 ? "selected text" : `${quoteCount} text selections`) } + if (pastedCount > 0) { + parts.push(pastedCount === 1 ? "pasted text" : `${pastedCount} pasted texts`) + } if (codeCount > 0) { parts.push(codeCount === 1 ? "code selection" : `${codeCount} code selections`) } diff --git a/src/renderer/features/agents/stores/message-queue-store.ts b/src/renderer/features/agents/stores/message-queue-store.ts index 137274dad..4af9d9bfa 100644 --- a/src/renderer/features/agents/stores/message-queue-store.ts +++ b/src/renderer/features/agents/stores/message-queue-store.ts @@ -11,6 +11,10 @@ interface MessageQueueState { // Map: subChatId -> queue items queues: Record + // Map: subChatId -> counter incremented each time QueueProcessor auto-sends a message. + // Used by active-chat to trigger scroll-to-bottom when a queued message is sent. + queueSentTriggers: Record + // Actions addToQueue: (subChatId: string, item: AgentQueueItem) => void removeFromQueue: (subChatId: string, itemId: string) => void @@ -21,11 +25,14 @@ interface MessageQueueState { popItem: (subChatId: string, itemId: string) => AgentQueueItem | null // Add item to front of queue (for error recovery) prependItem: (subChatId: string, item: AgentQueueItem) => void + // Signal that a queued message was auto-sent (for scroll triggering) + triggerQueueSent: (subChatId: string) => void } export const useMessageQueueStore = create()( subscribeWithSelector((set, get) => ({ queues: {}, + queueSentTriggers: {}, addToQueue: (subChatId, item) => { set((state) => ({ @@ -92,4 +99,13 @@ export const useMessageQueueStore = create()( }, })) }, + + triggerQueueSent: (subChatId) => { + set((state) => ({ + queueSentTriggers: { + ...state.queueSentTriggers, + [subChatId]: (state.queueSentTriggers[subChatId] || 0) + 1, + }, + })) + }, }))) diff --git a/src/renderer/features/agents/stores/message-store.ts b/src/renderer/features/agents/stores/message-store.ts index ee939f678..516416fa2 100644 --- a/src/renderer/features/agents/stores/message-store.ts +++ b/src/renderer/features/agents/stores/message-store.ts @@ -350,6 +350,14 @@ export const isLastUserMessageAtomFamily = atomFamily((userMsgId: string) => }) ) +// Is this user message the first one? (used to hide rollback button on first message) +export const isFirstUserMessageAtomFamily = atomFamily((userMsgId: string) => + atom((get) => { + const userIds = get(userMessageIdsAtom) + return userIds[0] === userMsgId + }) +) + // ============================================================================ // STREAMING STATUS // ============================================================================ diff --git a/src/renderer/features/agents/ui/agent-image-item.tsx b/src/renderer/features/agents/ui/agent-image-item.tsx index eb5b7cacd..b7d4e790c 100644 --- a/src/renderer/features/agents/ui/agent-image-item.tsx +++ b/src/renderer/features/agents/ui/agent-image-item.tsx @@ -2,13 +2,19 @@ import { useState, useEffect, useCallback } from "react" import { createPortal } from "react-dom" -import { X, ImageOff, ChevronLeft, ChevronRight } from "lucide-react" +import { X, ImageOff, ChevronLeft, ChevronRight, Copy, Download } from "lucide-react" import { IconSpinner } from "../../../components/ui/icons" import { HoverCard, HoverCardTrigger, HoverCardContent, } from "../../../components/ui/hover-card" +import { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, +} from "../../../components/ui/context-menu" interface ImageData { id: string @@ -41,6 +47,7 @@ export function AgentImageItem({ const [hasError, setHasError] = useState(false) const [isFullscreen, setIsFullscreen] = useState(false) const [currentIndex, setCurrentIndex] = useState(imageIndex) + const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) // Use allImages if provided, otherwise create single-image array const images = allImages || [{ id, filename, url }] @@ -71,6 +78,76 @@ export function AgentImageItem({ setCurrentIndex((prev) => (prev < images.length - 1 ? prev + 1 : 0)) }, [images.length]) + const handleCopyImage = useCallback(async () => { + try { + const imgUrl = (images[currentIndex] || images[0])?.url + if (!imgUrl) return + + const img = new Image() + img.crossOrigin = "anonymous" + await new Promise((resolve, reject) => { + img.onload = () => resolve() + img.onerror = reject + img.src = imgUrl + }) + + const canvas = document.createElement("canvas") + canvas.width = img.naturalWidth + canvas.height = img.naturalHeight + const ctx = canvas.getContext("2d") + ctx?.drawImage(img, 0, 0) + + const blob = await new Promise((resolve, reject) => { + canvas.toBlob( + (b) => (b ? resolve(b) : reject(new Error("Failed to create blob"))), + "image/png", + ) + }) + await navigator.clipboard.write([ + new ClipboardItem({ "image/png": blob }), + ]) + } catch (err) { + console.error("[AgentImageItem] Failed to copy image:", err) + } + }, [images, currentIndex]) + + const handleSaveImage = useCallback(async () => { + try { + const image = images[currentIndex] || images[0] + if (!image?.url) return + + // Use canvas to extract image data (avoids CSP issues with blob: URLs) + const img = new Image() + img.crossOrigin = "anonymous" + await new Promise((resolve, reject) => { + img.onload = () => resolve() + img.onerror = reject + img.src = image.url + }) + + const canvas = document.createElement("canvas") + canvas.width = img.naturalWidth + canvas.height = img.naturalHeight + const ctx = canvas.getContext("2d") + ctx?.drawImage(img, 0, 0) + + const dataUrl = canvas.toDataURL("image/png") + const base64Data = dataUrl.split(",")[1] || "" + const filename = image.filename || "image.png" + + await window.desktopApi?.saveFile({ + base64Data, + filename, + filters: [ + { name: "Images", extensions: ["png", "jpg", "jpeg", "webp", "gif"] }, + { name: "All Files", extensions: ["*"] }, + ], + }) + } catch (err) { + console.error("[AgentImageItem] Failed to save image:", err) + } + }, [images, currentIndex]) + // Handle keyboard navigation useEffect(() => { if (!isFullscreen) return @@ -160,7 +237,7 @@ export function AgentImageItem({ role="dialog" aria-modal="true" className="fixed inset-0 z-50 flex items-center justify-center bg-black/90" - onClick={closeFullscreen} + onClick={() => { if (!isContextMenuOpen) closeFullscreen() }} > {/* Close button */}
- {/* Content - only when user explicitly expands */} + {/* Content - expanded while streaming, collapsible after */} {isExpanded && thinkingText && ( -
- {isStreaming ? ( -

{thinkingText}

- ) : ( - - )} +
+ {/* Top gradient fade when streaming */} +
+
+ +
)}
diff --git a/src/renderer/features/agents/ui/agent-tool-utils.ts b/src/renderer/features/agents/ui/agent-tool-utils.ts index 11b8d1fb1..970494e20 100644 --- a/src/renderer/features/agents/ui/agent-tool-utils.ts +++ b/src/renderer/features/agents/ui/agent-tool-utils.ts @@ -174,15 +174,17 @@ export function areExploringGroupPropsEqual( if (!arePartsEqual(prevParts[i], nextParts[i])) return false } - // If all parts are completed, don't care about chatStatus or isStreaming + // isStreaming changes always matter - they drive auto-collapse via useEffect + if (prevProps.isStreaming !== nextProps.isStreaming) return false + + // If all parts are completed, don't care about chatStatus const allCompleted = nextParts.every(isToolCompleted) if (allCompleted) { return true } - // For pending groups, these matter + // For pending groups, chatStatus matters if (prevProps.chatStatus !== nextProps.chatStatus) return false - if (prevProps.isStreaming !== nextProps.isStreaming) return false return true } diff --git a/src/renderer/features/agents/ui/agent-user-message-bubble.tsx b/src/renderer/features/agents/ui/agent-user-message-bubble.tsx index 1c2a93c3e..25c07ae0b 100644 --- a/src/renderer/features/agents/ui/agent-user-message-bubble.tsx +++ b/src/renderer/features/agents/ui/agent-user-message-bubble.tsx @@ -256,12 +256,16 @@ export const AgentUserMessageBubble = memo(function AgentUserMessageBubble({ } // Count text mentions by type - const quoteCount = textMentions.filter(m => m.type === "quote" || m.type === "pasted").length + const quoteCount = textMentions.filter(m => m.type === "quote").length + const pastedCount = textMentions.filter(m => m.type === "pasted").length const codeCount = textMentions.filter(m => m.type === "diff").length if (quoteCount > 0) { parts.push(quoteCount === 1 ? "selected text" : `${quoteCount} text selections`) } + if (pastedCount > 0) { + parts.push(pastedCount === 1 ? "pasted text" : `${pastedCount} pasted texts`) + } if (codeCount > 0) { parts.push(codeCount === 1 ? "code selection" : `${codeCount} code selections`) } diff --git a/src/renderer/features/agents/ui/agent-web-search-collapsible.tsx b/src/renderer/features/agents/ui/agent-web-search-collapsible.tsx index 8630c49fc..0900b5026 100644 --- a/src/renderer/features/agents/ui/agent-web-search-collapsible.tsx +++ b/src/renderer/features/agents/ui/agent-web-search-collapsible.tsx @@ -2,7 +2,7 @@ import { memo, useState, useMemo } from "react" import { ChevronRight } from "lucide-react" -import { ExternalLinkIcon } from "../../../components/ui/icons" + import { areToolPropsEqual } from "./agent-tool-utils" import { cn } from "../../../lib/utils" @@ -105,17 +105,9 @@ export const AgentWebSearchCollapsible = memo( href={result.url} target="_blank" rel="noopener noreferrer" - className="flex items-start gap-1.5 px-2 py-1 rounded hover:bg-muted/50 transition-colors group/link" + className="block px-2 py-0.5 text-xs text-foreground truncate rounded hover:bg-muted/50 transition-colors" > - -
-
- {result.title} -
-
- {result.url} -
-
+ {result.title} ))}
diff --git a/src/renderer/features/agents/ui/agent-web-search-tool.tsx b/src/renderer/features/agents/ui/agent-web-search-tool.tsx index d38ff55ac..06166a4ee 100644 --- a/src/renderer/features/agents/ui/agent-web-search-tool.tsx +++ b/src/renderer/features/agents/ui/agent-web-search-tool.tsx @@ -6,7 +6,6 @@ import { IconSpinner, ExpandIcon, CollapseIcon, - ExternalLinkIcon, } from "../../../components/ui/icons" import { TextShimmer } from "../../../components/ui/text-shimmer" import { getToolStatus } from "./agent-tool-registry" @@ -138,17 +137,9 @@ export const AgentWebSearchTool = memo(function AgentWebSearchTool({ href={result.url} target="_blank" rel="noopener noreferrer" - className="flex items-start gap-2 px-2.5 py-1.5 hover:bg-muted/50 transition-colors group" + className="block px-2.5 py-1 text-xs text-foreground truncate hover:bg-muted/50 transition-colors" > - -
-
- {result.title} -
-
- {result.url} -
-
+ {result.title} ))}
diff --git a/src/renderer/features/agents/ui/archive-popover.tsx b/src/renderer/features/agents/ui/archive-popover.tsx index 44a381902..a8af3d2f5 100644 --- a/src/renderer/features/agents/ui/archive-popover.tsx +++ b/src/renderer/features/agents/ui/archive-popover.tsx @@ -18,7 +18,7 @@ import { Input } from "../../../components/ui/input" import { SearchIcon, ArchiveIcon, - IconTextUndo, + UnarchiveIcon, GitHubLogo, CloudIcon, } from "../../../components/ui/icons" @@ -193,7 +193,7 @@ const ArchiveChatItem = memo(function ArchiveChatItem({ className="flex-shrink-0 text-muted-foreground hover:text-foreground active:text-foreground transition-[color,transform] duration-150 ease-out active:scale-[0.97]" aria-label="Restore chat" > - +
@@ -246,7 +246,11 @@ export const ArchivePopover = memo(function ArchivePopover({ trigger }: ArchiveP // Local archived chats (always fetch) const { data: localArchivedChats, isLoading: isLocalLoading } = trpc.chats.listArchived.useQuery( {}, - { enabled: open }, + { + enabled: open, + staleTime: 5 * 60 * 1000, + placeholderData: (prev) => prev, + }, ) // Remote archived chats (always fetch) @@ -267,7 +271,11 @@ export const ArchivePopover = memo(function ArchivePopover({ trigger }: ArchiveP // Fetch file stats for archived local chats const { data: fileStatsData } = trpc.chats.getFileStats.useQuery( { chatIds: archivedChatIds }, - { enabled: open && archivedChatIds.length > 0 }, + { + enabled: open && archivedChatIds.length > 0, + staleTime: 5 * 60 * 1000, + placeholderData: (prev) => prev, + }, ) // Create map for quick project lookup by id diff --git a/src/renderer/features/agents/ui/git-activity-badges.tsx b/src/renderer/features/agents/ui/git-activity-badges.tsx index dfad9dc5d..3d7c3a463 100644 --- a/src/renderer/features/agents/ui/git-activity-badges.tsx +++ b/src/renderer/features/agents/ui/git-activity-badges.tsx @@ -2,7 +2,7 @@ import { memo, useCallback, useMemo, useState } from "react" import { GitCommit, GitPullRequest } from "lucide-react" -import { useSetAtom } from "jotai" +import { useAtomValue, useSetAtom } from "jotai" import { AnimatePresence, motion } from "motion/react" import { ExpandIcon, @@ -14,6 +14,7 @@ import { type ChangedFileInfo, } from "../utils/git-activity" import { + selectedProjectAtom, diffSidebarOpenAtomFamily, filteredDiffFilesAtom, filteredSubChatIdAtom, @@ -35,6 +36,7 @@ export const GitActivityBadges = memo(function GitActivityBadges({ chatId, subChatId, }: GitActivityBadgesProps) { + const selectedProject = useAtomValue(selectedProjectAtom) const setDiffSidebarOpen = useSetAtom(diffSidebarOpenAtomFamily(chatId)) const setFilteredDiffFiles = useSetAtom(filteredDiffFilesAtom) const setFilteredSubChatId = useSetAtom(filteredSubChatIdAtom) @@ -58,7 +60,20 @@ export const GitActivityBadges = memo(function GitActivityBadges({ }, [changedFiles]) const handleOpenCommit = useCallback(() => { - if (activity?.type === "commit" && activity.hash) { + if (activity?.type !== "commit") return + + // If pushed to remote — open on GitHub + const owner = selectedProject?.gitOwner + const repo = selectedProject?.gitRepo + if (activity.pushed && activity.hash && owner && repo) { + window.desktopApi.openExternal( + `https://github.com/${owner}/${repo}/commit/${activity.hash}`, + ) + return + } + + // Otherwise — open local diff sidebar with History tab + if (activity.hash) { setSelectedCommit({ hash: activity.hash, shortHash: activity.hash.slice(0, 8), @@ -69,7 +84,7 @@ export const GitActivityBadges = memo(function GitActivityBadges({ setFilteredSubChatId(subChatId) setDiffActiveTab("history") setDiffSidebarOpen(true) - }, [activity, subChatId, setSelectedCommit, setFilteredDiffFiles, setFilteredSubChatId, setDiffActiveTab, setDiffSidebarOpen]) + }, [activity, subChatId, selectedProject, setSelectedCommit, setFilteredDiffFiles, setFilteredSubChatId, setDiffActiveTab, setDiffSidebarOpen]) const handleFileClick = useCallback((file: ChangedFileInfo) => { onOpenFile?.(file.filePath) diff --git a/src/renderer/features/agents/ui/sub-chat-selector.tsx b/src/renderer/features/agents/ui/sub-chat-selector.tsx index 610783116..3144410d0 100644 --- a/src/renderer/features/agents/ui/sub-chat-selector.tsx +++ b/src/renderer/features/agents/ui/sub-chat-selector.tsx @@ -14,7 +14,7 @@ import { } from "../../details-sidebar/atoms" import { chatSourceModeAtom } from "../../../lib/atoms" import { trpc } from "../../../lib/trpc" -import { X, Plus, AlignJustify, Play, TerminalSquare } from "lucide-react" +import { Plus, AlignJustify, Play, TerminalSquare } from "lucide-react" import { IconSpinner, PlanIcon, @@ -24,6 +24,7 @@ import { DiffIcon, ClockIcon, QuestionIcon, + UnarchiveIcon, } from "../../../components/ui/icons" import { Button } from "../../../components/ui/button" import { cn } from "../../../lib/utils" @@ -826,7 +827,7 @@ export function SubChatSelector({ : "Close tab" } > - +
)} diff --git a/src/renderer/features/agents/utils/git-activity.ts b/src/renderer/features/agents/utils/git-activity.ts index df9bf18dd..831a7ba5a 100644 --- a/src/renderer/features/agents/utils/git-activity.ts +++ b/src/renderer/features/agents/utils/git-activity.ts @@ -2,6 +2,7 @@ export interface GitCommitInfo { type: "commit" message: string hash?: string + pushed?: boolean } export interface GitPrInfo { @@ -91,6 +92,7 @@ function extractPrInfo(command: string, stdout: string): GitPrInfo | null { export function extractGitActivity(parts: any[]): GitActivity | null { let lastCommit: GitCommitInfo | null = null let lastPr: GitPrInfo | null = null + let hasPush = false for (const part of parts) { if (part.type !== "tool-Bash") continue @@ -104,9 +106,19 @@ export function extractGitActivity(parts: any[]): GitActivity | null { const pr = extractPrInfo(command, stdout) if (pr) lastPr = pr + + // Detect successful git push (no stderr error, command contains git push) + if (/git\s+push/.test(command) && !part.output?.stderr?.includes("error")) { + hasPush = true + } + } + + // Mark commit as pushed if a git push was found in the same message + if (lastCommit && hasPush) { + lastCommit.pushed = true } - // PR is more significant than commit + // PR is more significant than commit (PR implies push already happened) return lastPr || lastCommit } diff --git a/src/renderer/features/automations/inbox-view.tsx b/src/renderer/features/automations/inbox-view.tsx index 14cc28766..d16d8ecf4 100644 --- a/src/renderer/features/automations/inbox-view.tsx +++ b/src/renderer/features/automations/inbox-view.tsx @@ -10,32 +10,25 @@ import { agentsMobileViewModeAtom, inboxMobileViewModeAtom, } from "../agents/atoms" -import { IconSpinner, SettingsIcon } from "../../components/ui/icons" -import { Inbox as InboxIcon, Archive as ArchiveIcon } from "lucide-react" +import { IconSpinner } from "../../components/ui/icons" +import { Archive as ArchiveIcon, ListFilter, MoreHorizontal, Clock, Check, AlignJustify } from "lucide-react" import { Logo } from "../../components/ui/logo" import { cn } from "../../lib/utils" import { useState, useMemo, useEffect, useCallback } from "react" import { formatTimeAgo } from "../agents/utils/format-time-ago" import { GitHubIcon } from "../../icons" import { ResizableSidebar } from "../../components/ui/resizable-sidebar" -import { ArrowUpDown, AlignJustify } from "lucide-react" import { useIsMobile } from "../../lib/hooks/use-mobile" import { desktopViewAtom } from "../agents/atoms" import { remoteTrpc } from "../../lib/remote-trpc" import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { - Popover, - PopoverContent, - PopoverTrigger, -} from "../../components/ui/popover" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../../components/ui/select" -import { Switch } from "../../components/ui/switch" + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "../../components/ui/dropdown-menu" import { ContextMenu, ContextMenuContent, @@ -80,6 +73,36 @@ function AutomationsIcon(props: React.SVGProps) { ) } +function InboxIcon(props: React.SVGProps) { + return ( + + + + ) +} + +function UnreadMailIcon(props: React.SVGProps) { + return ( + + + + + + ) +} + // GitHub avatar with loading state and fallback function InboxGitHubAvatar({ gitOwner }: { gitOwner: string }) { const [isLoaded, setIsLoaded] = useState(false) @@ -281,9 +304,7 @@ export function InboxView() { }, [setChatSourceMode]) const [searchQuery, setSearchQuery] = useState("") - const [ordering, setOrdering] = useState<"newest" | "oldest">("newest") - const [showRead, setShowRead] = useState(true) - const [showUnreadFirst, setShowUnreadFirst] = useState(false) + const [filterMode, setFilterMode] = useState<"unread_and_read" | "unread" | "archived" | "all">("unread_and_read") // Fork Locally state const [importDialogOpen, setImportDialogOpen] = useState(false) @@ -312,6 +333,24 @@ export function InboxView() { }, }) + const markAllReadMutation = useMutation({ + mutationFn: () => + remoteTrpc.automations.markAllInboxItemsRead.mutate({ teamId: teamId! }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["automations", "inboxUnreadCount"] }) + queryClient.invalidateQueries({ queryKey: ["automations", "inboxChats"] }) + }, + }) + + const archiveBatchMutation = useMutation({ + mutationFn: (chatIds: string[]) => + remoteTrpc.agents.archiveChatsBatch.mutate({ chatIds }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["automations", "inboxChats"] }) + queryClient.invalidateQueries({ queryKey: ["automations", "inboxUnreadCount"] }) + }, + }) + // Fork Locally: projects, auto-import // Note: inbox chats are excluded from useRemoteChats() (getAgentChats filters them out), // so we fetch the individual chat on demand via remoteTrpc.agents.getAgentChat @@ -378,9 +417,12 @@ export function InboxView() { const filteredChats = useMemo(() => { let chats = (data?.chats || []) as InboxChat[] - if (!showRead) { + if (filterMode === "unread") { chats = chats.filter((chat) => !chat.isRead) + } else if (filterMode === "unread_and_read") { + // show all non-archived (default) } + // "archived" and "all" modes would need backend support for archived inbox items if (searchQuery.trim()) { const query = searchQuery.toLowerCase() @@ -394,24 +436,24 @@ export function InboxView() { chats = [...chats].sort((a, b) => { const dateA = new Date(a.createdAt).getTime() const dateB = new Date(b.createdAt).getTime() - return ordering === "newest" ? dateB - dateA : dateA - dateB + return dateB - dateA }) - if (showUnreadFirst) { - chats = [...chats].sort((a, b) => { - if (a.isRead === b.isRead) return 0 - return a.isRead ? 1 : -1 - }) - } - return chats - }, [data?.chats, searchQuery, showRead, ordering, showUnreadFirst]) + }, [data?.chats, searchQuery, filterMode]) const unreadCount = useMemo(() => { const chats = (data?.chats || []) as InboxChat[] return chats.filter((chat) => !chat.isRead).length }, [data?.chats]) + const readCount = useMemo(() => { + return filteredChats.filter((c) => c.isRead).length + }, [filteredChats]) + + const hasNoUnread = unreadCount === 0 + const hasNoRead = readCount === 0 + const handleChatClick = (chat: InboxChat) => { if (!chat.isRead) { markReadMutation.mutate(chat.executionId) @@ -448,6 +490,32 @@ export function InboxView() { } }, [archiveMutation, filteredChats, selectedChatId, setSelectedChatId]) + const handleMarkAllRead = useCallback(() => { + if (teamId) { + markAllReadMutation.mutate() + } + }, [teamId, markAllReadMutation]) + + const handleArchiveAll = useCallback(() => { + const chatIds = filteredChats.map((c) => c.id) + if (chatIds.length > 0) { + archiveBatchMutation.mutate(chatIds) + if (selectedChatId) { + setSelectedChatId(null) + } + } + }, [filteredChats, archiveBatchMutation, selectedChatId, setSelectedChatId]) + + const handleArchiveRead = useCallback(() => { + const readChatIds = filteredChats.filter((c) => c.isRead).map((c) => c.id) + if (readChatIds.length > 0) { + archiveBatchMutation.mutate(readChatIds) + if (selectedChatId && readChatIds.includes(selectedChatId)) { + setSelectedChatId(null) + } + } + }, [filteredChats, archiveBatchMutation, selectedChatId, setSelectedChatId]) + if (!teamId) { return (
@@ -456,35 +524,12 @@ export function InboxView() { ) } - // Shared filter settings popover content - const filterContent = ( -
-
-
- - Ordering -
- -
-
-
- Show read - -
-
- Show unread first - -
-
- ) + const filterOptions = [ + { value: "unread_and_read" as const, label: "Unread & read", icon: InboxIcon }, + { value: "unread" as const, label: "Unread", icon: UnreadMailIcon }, + { value: "archived" as const, label: "Archived", icon: ArchiveIcon }, + { value: "all" as const, label: "All workspace updates", icon: Clock }, + ] // Mobile layout - fullscreen list or fullscreen chat if (isMobile) { @@ -505,16 +550,46 @@ export function InboxView() {

Inbox

- - - - - - {filterContent} - - +
+ + + + + + Filter + {filterOptions.map(({ value, label, icon: Icon }) => ( + setFilterMode(value)}> + + {label} + {filterMode === value && } + + ))} + + + + + + + + + + Mark all as read + + + + Archive all + + + + Archive read + + + +
)} - {/* Settings button - absolutely positioned when main sidebar is open */} + {/* Filter & actions buttons - absolutely positioned when main sidebar is open */} {sidebarOpen && (
- - + + + + + + Filter + {filterOptions.map(({ value, label, icon: Icon }) => ( + setFilterMode(value)}> + + {label} + {filterMode === value && } + + ))} + + + + - - - {filterContent} - - + + + + + Mark all as read + + + + Archive all + + + + Archive read + + +
)} @@ -635,21 +738,50 @@ export function InboxView() {
- - + + + + + + Filter + {filterOptions.map(({ value, label, icon: Icon }) => ( + setFilterMode(value)}> + + {label} + {filterMode === value && } + + ))} + + + + - - - {filterContent} - - + + + + + Mark all as read + + + + Archive all + + + + Archive read + + +
)} 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 db752f359..66f9f3661 100644 --- a/src/renderer/features/changes/components/history-view/history-view.tsx +++ b/src/renderer/features/changes/components/history-view/history-view.tsx @@ -13,6 +13,8 @@ import { ContextMenuTrigger, } from "../../../../components/ui/context-menu"; import { toast } from "sonner"; +import { useAtomValue } from "jotai"; +import { selectedProjectAtom } from "../../../agents/atoms"; export interface CommitInfo { hash: string; @@ -171,16 +173,24 @@ const HistoryCommitItem = memo(function HistoryCommitItem({ [commit.date], ); + const selectedProject = useAtomValue(selectedProjectAtom); + const handleCopySha = useCallback(() => { navigator.clipboard.writeText(commit.hash); toast.success("Copied SHA to clipboard"); }, [commit.hash]); const handleOpenOnRemote = useCallback(() => { - // TODO: Get repository URL and construct commit URL - // For now, just show a toast - toast.info("Open on remote - not implemented yet"); - }, []); + const owner = selectedProject?.gitOwner; + const repo = selectedProject?.gitRepo; + if (!owner || !repo) { + toast.error("Could not determine remote repository"); + return; + } + window.desktopApi.openExternal( + `https://github.com/${owner}/${repo}/commit/${commit.hash}`, + ); + }, [commit.hash, selectedProject?.gitOwner, selectedProject?.gitRepo]); return ( diff --git a/src/renderer/lib/atoms/index.ts b/src/renderer/lib/atoms/index.ts index 178d5645d..9ab30b6fb 100644 --- a/src/renderer/lib/atoms/index.ts +++ b/src/renderer/lib/atoms/index.ts @@ -373,7 +373,7 @@ export const extendedThinkingEnabledAtom = atomWithStorage( // When enabled, allow rollback to previous assistant messages export const historyEnabledAtom = atomWithStorage( "preferences:history-enabled", - false, + false, // Default OFF — beta feature undefined, { getOnInit: true }, ) @@ -426,11 +426,11 @@ export const betaGitFeaturesEnabledAtom = atomWithStorage( { getOnInit: true }, ) -// Beta: Enable Kanban board view +// Kanban board view // When enabled, shows Kanban button in sidebar to view workspaces as a board export const betaKanbanEnabledAtom = atomWithStorage( "preferences:beta-kanban-enabled", - false, // Default OFF + true, // Default ON — graduated from beta undefined, { getOnInit: true }, ) diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index abedec6c7..390f82598 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -200,24 +200,25 @@ } /* Sonner Toast Styling - Google-style layout */ +/* Use !important to override Sonner's internal [data-theme] selectors (higher specificity) */ [data-sonner-toaster] { - --normal-bg: hsl(var(--popover)); - --normal-text: hsl(var(--popover-foreground)); - --normal-border: hsl(var(--border)); - --border-radius: var(--radius); + --normal-bg: hsl(var(--popover)) !important; + --normal-text: hsl(var(--popover-foreground)) !important; + --normal-border: hsl(var(--border)) !important; + --border-radius: var(--radius) !important; /* Override rich colors - all toasts should be neutral */ - --success-bg: hsl(var(--popover)); - --success-border: hsl(var(--border)); - --success-text: hsl(var(--popover-foreground)); - --info-bg: hsl(var(--popover)); - --info-border: hsl(var(--border)); - --info-text: hsl(var(--popover-foreground)); - --warning-bg: hsl(var(--popover)); - --warning-border: hsl(var(--border)); - --warning-text: hsl(var(--popover-foreground)); - --error-bg: hsl(var(--popover)); - --error-border: hsl(var(--border)); - --error-text: hsl(var(--popover-foreground)); + --success-bg: hsl(var(--popover)) !important; + --success-border: hsl(var(--border)) !important; + --success-text: hsl(var(--popover-foreground)) !important; + --info-bg: hsl(var(--popover)) !important; + --info-border: hsl(var(--border)) !important; + --info-text: hsl(var(--popover-foreground)) !important; + --warning-bg: hsl(var(--popover)) !important; + --warning-border: hsl(var(--border)) !important; + --warning-text: hsl(var(--popover-foreground)) !important; + --error-bg: hsl(var(--popover)) !important; + --error-border: hsl(var(--border)) !important; + --error-text: hsl(var(--popover-foreground)) !important; } [data-sonner-toast][data-styled="true"] { @@ -226,7 +227,7 @@ gap: 8px; align-items: flex-start; flex-wrap: wrap; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); } /* Icon on the left */ From b72ae0e51ce6a2e2bbfc2aab676e743d9c99a57e Mon Sep 17 00:00:00 2001 From: serafim Date: Fri, 6 Feb 2026 11:22:12 -0800 Subject: [PATCH 5/6] Release v0.0.59 ## What's New ### Features - Open pushed commits on GitHub - Enable extended thinking by default ### Improvements & Fixes - Fix history view remote link - Handle rebase when resolving commit hash for GitHub URL - Show thinking gradient only when content overflows - Improve thinking tool content visibility - Hide scrollbar in thinking tool during streaming - Display model version separately in model selector - Move rollback button to user message bubble and restore input content ## Downloads - **macOS ARM64 (Apple Silicon)**: Download the `-arm64.dmg` file - **macOS Intel**: Download the `.dmg` file (without arm64) Auto-updates are enabled. Existing users will be notified automatically. --- package.json | 2 +- src/main/lib/trpc/routers/claude.ts | 16 +++ src/main/lib/trpc/routers/voice.ts | 4 +- .../agents/components/agents-help-popover.tsx | 104 +++++++++++++++++- .../features/agents/main/active-chat.tsx | 28 ++--- .../agents/main/isolated-message-group.tsx | 8 +- .../features/agents/stores/message-store.ts | 72 ++++++++++++ .../agents/ui/agent-thinking-tool.tsx | 15 ++- .../features/agents/ui/agents-content.tsx | 22 +++- .../features/agents/utils/git-activity.ts | 41 +++++-- src/renderer/lib/atoms/index.ts | 2 +- 11 files changed, 273 insertions(+), 41 deletions(-) diff --git a/package.json b/package.json index 7ca4fca60..9e3a1df50 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "21st-desktop", - "version": "0.0.58", + "version": "0.0.59", "private": true, "description": "1Code - UI for parallel work with AI agents", "author": { diff --git a/src/main/lib/trpc/routers/claude.ts b/src/main/lib/trpc/routers/claude.ts index bfe3c1b11..d9da5eee2 100644 --- a/src/main/lib/trpc/routers/claude.ts +++ b/src/main/lib/trpc/routers/claude.ts @@ -1790,11 +1790,13 @@ ${prompt} let policyRetryCount = 0 let policyRetryNeeded = false let messageCount = 0 + let pendingFinishChunk: UIMessageChunk | null = null // eslint-disable-next-line no-constant-condition while (true) { policyRetryNeeded = false messageCount = 0 + pendingFinishChunk = null // 5. Run Claude SDK let stream @@ -2102,6 +2104,14 @@ ${prompt} } } + // IMPORTANT: Defer the protocol "finish" chunk until after DB persistence. + // If we emit finish early, the UI can send the next user message before + // this assistant message is written, and the next save overwrites it. + if (chunk.type === "finish") { + pendingFinishChunk = chunk + continue + } + // Use safeEmit to prevent throws when observer is closed if (!safeEmit(chunk)) { // Observer closed (user clicked Stop), break out of loop @@ -2480,6 +2490,12 @@ ${prompt} console.log( `[SD] M:END sub=${subId} reason=ok n=${chunkCount} last=${lastChunkType} t=${duration}s`, ) + if (pendingFinishChunk) { + safeEmit(pendingFinishChunk) + } else { + // Keep protocol invariant for consumers that wait for finish. + safeEmit({ type: "finish" } as UIMessageChunk) + } safeComplete() } catch (error) { const duration = ((Date.now() - streamStart) / 1000).toFixed(1) diff --git a/src/main/lib/trpc/routers/voice.ts b/src/main/lib/trpc/routers/voice.ts index af2876aa5..63fc8014b 100644 --- a/src/main/lib/trpc/routers/voice.ts +++ b/src/main/lib/trpc/routers/voice.ts @@ -96,13 +96,13 @@ async function getUserPlan(): Promise<{ plan: string; status: string | null } | } /** - * Check if user has paid subscription (onecode_pro or onecode_max with active status) + * Check if user has paid subscription (onecode_pro, onecode_max_100, or onecode_max with active status) */ async function hasPaidSubscription(): Promise { const planData = await getUserPlan() if (!planData) return false - const paidPlans = ["onecode_pro", "onecode_max"] + const paidPlans = ["onecode_pro", "onecode_max_100", "onecode_max"] return paidPlans.includes(planData.plan) && planData.status === "active" } diff --git a/src/renderer/features/agents/components/agents-help-popover.tsx b/src/renderer/features/agents/components/agents-help-popover.tsx index d231ab869..5b2eadf6c 100644 --- a/src/renderer/features/agents/components/agents-help-popover.tsx +++ b/src/renderer/features/agents/components/agents-help-popover.tsx @@ -1,17 +1,44 @@ "use client" -import { useState } from "react" +import { useState, useEffect } from "react" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + DropdownMenuSeparator, + DropdownMenuLabel, } from "../../../components/ui/dropdown-menu" +import { ArrowUpRight } from "lucide-react" import { KeyboardIcon } from "../../../components/ui/icons" import { DiscordIcon } from "../../../icons" import { useSetAtom } from "jotai" import { agentsSettingsDialogOpenAtom, agentsSettingsDialogActiveTabAtom } from "../../../lib/atoms" +interface ReleaseHighlight { + version: string + title: string +} + +function parseFirstHighlight(content: string): string { + const lines = content.split("\n") + let inFeatures = false + for (const line of lines) { + if (/^###\s+Features/i.test(line)) { + inFeatures = true + continue + } + if (inFeatures && /^###?\s+/.test(line)) break + if (inFeatures) { + const bold = line.match(/^[-*]\s+\*\*(.+?)\*\*/) + if (bold) return bold[1] + const plain = line.match(/^[-*]\s+(.+)/) + if (plain) return plain[1].replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").trim() + } + } + return "Bug fixes & improvements" +} + interface AgentsHelpPopoverProps { children: React.ReactNode open?: boolean @@ -28,13 +55,48 @@ export function AgentsHelpPopover({ const [internalOpen, setInternalOpen] = useState(false) const setSettingsDialogOpen = useSetAtom(agentsSettingsDialogOpenAtom) const setSettingsActiveTab = useSetAtom(agentsSettingsDialogActiveTabAtom) + const [highlights, setHighlights] = useState([]) - // Use controlled state if provided, otherwise use internal state const open = controlledOpen ?? internalOpen const setOpen = controlledOnOpenChange ?? setInternalOpen + useEffect(() => { + let cancelled = false + window.desktopApi + .signedFetch("https://21st.dev/api/changelog/desktop?per_page=3") + .then((result) => { + if (cancelled) return + const data = result.data as { + releases?: Array<{ version?: string; content?: string }> + } + if (data?.releases) { + const items: ReleaseHighlight[] = [] + for (const release of data.releases) { + if (release.version) { + items.push({ version: release.version, title: parseFirstHighlight(release.content || "") }) + } + } + setHighlights(items) + } + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, []) + const handleCommunityClick = () => { - window.open("https://discord.gg/8ektTZGnj4", "_blank") + window.desktopApi.openExternal("https://discord.gg/8ektTZGnj4") + } + + const handleChangelogClick = () => { + window.desktopApi.openExternal("https://1code.dev/agents/changelog") + } + + const handleReleaseClick = (version: string) => { + window.desktopApi.openExternal( + `https://1code.dev/agents/changelog#${version}`, + ) } const handleKeyboardShortcutsClick = () => { @@ -46,7 +108,7 @@ export function AgentsHelpPopover({ return ( {children} - + Discord @@ -61,6 +123,40 @@ export function AgentsHelpPopover({ Shortcuts )} + + {highlights.length > 0 && ( + <> + +
+ What's new +
+ {highlights.map((item, i) => ( + handleReleaseClick(item.version)} + className="gap-0 items-stretch min-h-0 px-2 py-0" + > +
+ {i === 0 ?
:
} +
+
+
+ + {item.title} + + + ))} + +
+
+
+
+
+ Full changelog + + + + )} ) diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index 24ba548ee..378d09439 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -188,7 +188,12 @@ import { } from "../search" import { agentChatStore } from "../stores/agent-chat-store" import { EMPTY_QUEUE, useMessageQueueStore } from "../stores/message-queue-store" -import { clearSubChatCaches, isRollingBackAtom, syncMessagesWithStatusAtom } from "../stores/message-store" +import { + clearSubChatCaches, + findRollbackTargetSdkUuidForUserIndex, + isRollingBackAtom, + syncMessagesWithStatusAtom +} from "../stores/message-store" import { useStreamingStatusStore } from "../stores/streaming-status-store" import { useAgentSubChatStore, @@ -3131,23 +3136,14 @@ const ChatViewInner = memo(function ChatViewInner({ return } - // Find the last assistant message BEFORE this user message - let targetAssistantMsg: (typeof messages)[0] | null = null - for (let i = userMsgIndex - 1; i >= 0; i--) { - if (messages[i].role === "assistant") { - targetAssistantMsg = messages[i] - break - } - } - - if (!targetAssistantMsg) { - toast.error("Cannot rollback: no previous assistant message found") - return - } + const sdkUuid = findRollbackTargetSdkUuidForUserIndex( + userMsgIndex, + messages.length, + (index) => messages[index] as any, + ) - const sdkUuid = (targetAssistantMsg.metadata as any)?.sdkMessageUuid if (!sdkUuid) { - toast.error("Cannot rollback: message has no SDK UUID") + toast.error("Cannot rollback: this turn is not rollbackable") return } diff --git a/src/renderer/features/agents/main/isolated-message-group.tsx b/src/renderer/features/agents/main/isolated-message-group.tsx index 624ea6a7d..a00b8378d 100644 --- a/src/renderer/features/agents/main/isolated-message-group.tsx +++ b/src/renderer/features/agents/main/isolated-message-group.tsx @@ -6,7 +6,7 @@ import { messageAtomFamily, assistantIdsForUserMsgAtomFamily, isLastUserMessageAtomFamily, - isFirstUserMessageAtomFamily, + rollbackTargetSdkUuidForUserMsgAtomFamily, isStreamingAtom, isRollingBackAtom, } from "../stores/message-store" @@ -101,12 +101,12 @@ export const IsolatedMessageGroup = memo(function IsolatedMessageGroup({ const userMsg = useAtomValue(messageAtomFamily(userMsgId)) const assistantIds = useAtomValue(assistantIdsForUserMsgAtomFamily(userMsgId)) const isLastGroup = useAtomValue(isLastUserMessageAtomFamily(userMsgId)) - const isFirstUserMessage = useAtomValue(isFirstUserMessageAtomFamily(userMsgId)) + const rollbackTargetSdkUuid = useAtomValue(rollbackTargetSdkUuidForUserMsgAtomFamily(userMsgId)) const isStreaming = useAtomValue(isStreamingAtom) const isRollingBack = useAtomValue(isRollingBackAtom) - // Show rollback button on non-first user messages (first has no preceding assistant to roll back to) - const canRollback = onRollback && !isFirstUserMessage && !isStreaming + // Show rollback button only when this user turn has a valid rollback target. + const canRollback = onRollback && !!rollbackTargetSdkUuid && !isStreaming // Extract user message content // Note: file-content parts are hidden from UI but sent to agent diff --git a/src/renderer/features/agents/stores/message-store.ts b/src/renderer/features/agents/stores/message-store.ts index 516416fa2..ad72383b2 100644 --- a/src/renderer/features/agents/stores/message-store.ts +++ b/src/renderer/features/agents/stores/message-store.ts @@ -358,6 +358,78 @@ export const isFirstUserMessageAtomFamily = atomFamily((userMsgId: string) => }) ) +type RollbackLookupMessage = { + role: "user" | "assistant" | "system" + metadata?: any + parts?: MessagePart[] +} + +function hasCompactToolUsePart(parts?: MessagePart[]): boolean { + return !!parts?.some((part) => part.type === "tool-Compact") +} + +// Shared rollback target lookup used by both UI visibility and rollback action. +export function findRollbackTargetSdkUuidForUserIndex( + userMsgIndex: number, + totalMessageCount: number, + getMessageAt: (index: number) => RollbackLookupMessage | null | undefined, +): string | null { + if (userMsgIndex <= 0 || totalMessageCount <= 0) return null + + // 1) Pick the first assistant before this user message. + let targetAssistantIndex = -1 + let targetAssistantMessage: RollbackLookupMessage | null | undefined = null + for (let i = userMsgIndex - 1; i >= 0; i--) { + const message = getMessageAt(i) + if (!message || message.role !== "assistant") continue + targetAssistantIndex = i + targetAssistantMessage = message + break + } + + if (targetAssistantIndex === -1 || !targetAssistantMessage) return null + + // 2) Any compact after that assistant (up to the end of the dialog) means + // this assistant is already behind compact and cannot be a rollback target. + for (let i = targetAssistantIndex; i < totalMessageCount; i++) { + const message = getMessageAt(i) + if (!message || message.role !== "assistant") continue + if (hasCompactToolUsePart(message.parts)) { + return null + } + } + + // 3) No compact after target assistant: allow rollback only if target has SDK UUID. + const sdkUuid = (targetAssistantMessage.metadata as any)?.sdkMessageUuid + return typeof sdkUuid === "string" && sdkUuid.length > 0 ? sdkUuid : null +} + +// SDK UUID of the assistant message that rollback should target for this user message. +// Returns null when this turn cannot be rolled back. +export const rollbackTargetSdkUuidForUserMsgAtomFamily = atomFamily((userMsgId: string) => + atom((get) => { + const ids = get(messageIdsAtom) + const roles = get(messageRolesAtom) + const userMsgIndex = ids.indexOf(userMsgId) + + if (userMsgIndex <= 0) return null + + return findRollbackTargetSdkUuidForUserIndex(userMsgIndex, ids.length, (index) => { + const messageId = ids[index] + if (!messageId) return null + + const role = roles.get(messageId) + if (!role) return null + + if (role !== "assistant") { + return { role } + } + + return get(messageAtomFamily(messageId)) + }) + }) +) + // ============================================================================ // STREAMING STATUS // ============================================================================ diff --git a/src/renderer/features/agents/ui/agent-thinking-tool.tsx b/src/renderer/features/agents/ui/agent-thinking-tool.tsx index 39b4a4c97..edfda08c6 100644 --- a/src/renderer/features/agents/ui/agent-thinking-tool.tsx +++ b/src/renderer/features/agents/ui/agent-thinking-tool.tsx @@ -72,10 +72,15 @@ export const AgentThinkingTool = memo(function AgentThinkingTool({ return () => clearInterval(interval) }, [isStreaming]) - // Auto-scroll when expanded during streaming + // Track whether content overflows the scroll container + const [isOverflowing, setIsOverflowing] = useState(false) + + // Auto-scroll when expanded during streaming + check overflow useEffect(() => { if (isStreaming && isExpanded && scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight + const el = scrollRef.current + setIsOverflowing(el.scrollHeight > el.clientHeight) + el.scrollTop = el.scrollHeight } }, [part.input?.text, isStreaming, isExpanded]) @@ -142,14 +147,14 @@ export const AgentThinkingTool = memo(function AgentThinkingTool({
diff --git a/src/renderer/features/agents/ui/agents-content.tsx b/src/renderer/features/agents/ui/agents-content.tsx index 0a73f30ed..2080816b9 100644 --- a/src/renderer/features/agents/ui/agents-content.tsx +++ b/src/renderer/features/agents/ui/agents-content.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react" import { useAtom, useAtomValue, useSetAtom } from "jotai" +import { useQuery } from "@tanstack/react-query" // import { useSearchParams, useRouter } from "next/navigation" // Desktop doesn't use next/navigation // Desktop: mock Next.js navigation hooks const useSearchParams = () => ({ get: () => null }) @@ -61,6 +62,7 @@ import { AlignJustify } from "lucide-react" import { AgentsQuickSwitchDialog } from "../components/agents-quick-switch-dialog" import { SubChatsQuickSwitchDialog } from "../components/subchats-quick-switch-dialog" import { isDesktopApp } from "../../../lib/utils/platform" +import { remoteTrpc } from "../../../lib/remote-trpc" import { SettingsContent } from "../../settings/settings-content" // Desktop mock const useIsAdmin = () => false @@ -75,7 +77,7 @@ export function AgentsContent() { const selectedDraftId = useAtomValue(selectedDraftIdAtom) const showNewChatForm = useAtomValue(showNewChatFormAtom) const betaKanbanEnabled = useAtomValue(betaKanbanEnabledAtom) - const betaAutomationsEnabled = useAtomValue(betaAutomationsEnabledAtom) + const [betaAutomationsEnabled, setBetaAutomationsEnabled] = useAtom(betaAutomationsEnabledAtom) const [selectedTeamId] = useAtom(selectedTeamIdAtom) const [sidebarOpen, setSidebarOpen] = useAtom(agentsSidebarOpenAtom) const [previewSidebarOpen, setPreviewSidebarOpen] = useAtom( @@ -172,6 +174,24 @@ export function AgentsContent() { }) const selectedTeam = teams?.find((t: any) => t.id === selectedTeamId) as any + // Auto-activate automations & inbox if user has any automations configured + // One-shot check on app startup — no refetches, no polling + const { data: automationsData } = useQuery({ + queryKey: ["automations", "autoActivateCheck", selectedTeamId], + queryFn: () => remoteTrpc.automations.listAutomations.query({ teamId: selectedTeamId! }), + enabled: !!selectedTeamId && !betaAutomationsEnabled, + staleTime: Infinity, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: 1, + }) + + useEffect(() => { + if (!betaAutomationsEnabled && automationsData && automationsData.length > 0) { + setBetaAutomationsEnabled(true) + } + }, [betaAutomationsEnabled, automationsData, setBetaAutomationsEnabled]) + // Fetch agent chats for keyboard navigation and mobile view const { data: agentChats } = api.agents.getAgentChats.useQuery( { teamId: selectedTeamId! }, diff --git a/src/renderer/features/agents/utils/git-activity.ts b/src/renderer/features/agents/utils/git-activity.ts index 831a7ba5a..9078af119 100644 --- a/src/renderer/features/agents/utils/git-activity.ts +++ b/src/renderer/features/agents/utils/git-activity.ts @@ -92,7 +92,8 @@ function extractPrInfo(command: string, stdout: string): GitPrInfo | null { export function extractGitActivity(parts: any[]): GitActivity | null { let lastCommit: GitCommitInfo | null = null let lastPr: GitPrInfo | null = null - let hasPush = false + let lastPushHash: string | null = null + let hadRebase = false for (const part of parts) { if (part.type !== "tool-Bash") continue @@ -100,6 +101,7 @@ export function extractGitActivity(parts: any[]): GitActivity | null { const command: string = part.input?.command || "" const stdout: string = part.output?.stdout || part.output?.output || "" + const stderr: string = part.output?.stderr || "" const commit = extractCommitInfo(command, stdout) if (commit) lastCommit = commit @@ -107,15 +109,40 @@ export function extractGitActivity(parts: any[]): GitActivity | null { const pr = extractPrInfo(command, stdout) if (pr) lastPr = pr - // Detect successful git push (no stderr error, command contains git push) - if (/git\s+push/.test(command) && !part.output?.stderr?.includes("error")) { - hasPush = true + // Detect rebase (git pull --rebase rewrites commit hashes) + if (/git\s+pull\s+--rebase/.test(command)) { + hadRebase = true + } + + // Detect successful git push and extract the final pushed hash + // Push output format: "oldHash..newHash branch -> origin/branch" + if (/git\s+push/.test(command) && !stderr.includes("error")) { + // Check stdout and stderr for push ref update (git push outputs to stderr) + const pushOutput = stdout + "\n" + stderr + const pushMatch = pushOutput.match(/[\da-f]+\.\.([\da-f]+)\s+\S+\s*->\s*\S+/) + if (pushMatch) { + lastPushHash = pushMatch[1]! + } else { + // Push succeeded but no hash in output (e.g. first push with -u) + lastPushHash = "" + } } } - // Mark commit as pushed if a git push was found in the same message - if (lastCommit && hasPush) { - lastCommit.pushed = true + if (lastCommit && lastPushHash !== null) { + // If rebase happened after commit, the original hash is invalid — + // use the hash from the final git push output instead + if (hadRebase && lastPushHash) { + lastCommit.hash = lastPushHash + lastCommit.pushed = true + } else if (hadRebase && !lastPushHash) { + // Rebase happened but couldn't extract new hash from push output — + // don't mark as pushed (old hash would 404 on GitHub) + lastCommit.pushed = false + } else { + // No rebase — original hash is valid + lastCommit.pushed = true + } } // PR is more significant than commit (PR implies push already happened) diff --git a/src/renderer/lib/atoms/index.ts b/src/renderer/lib/atoms/index.ts index 9ab30b6fb..129e18cb5 100644 --- a/src/renderer/lib/atoms/index.ts +++ b/src/renderer/lib/atoms/index.ts @@ -364,7 +364,7 @@ export const activeConfigAtom = atom((get) => { // Note: Extended thinking disables response streaming export const extendedThinkingEnabledAtom = atomWithStorage( "preferences:extended-thinking-enabled", - false, + true, undefined, { getOnInit: true }, ) From cd73949d8250513cde866b3aae5037f26f878a52 Mon Sep 17 00:00:00 2001 From: d3oxy Date: Sat, 7 Feb 2026 15:16:10 +0530 Subject: [PATCH 6/6] fix: symlink plugins directory to isolated config dir Symlink ~/.claude/plugins to the isolated config dir so Claude binary can load user-installed plugins (e.g. CC Safety Net). --- src/main/lib/trpc/routers/claude.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/main/lib/trpc/routers/claude.ts b/src/main/lib/trpc/routers/claude.ts index d9da5eee2..f560f9469 100644 --- a/src/main/lib/trpc/routers/claude.ts +++ b/src/main/lib/trpc/routers/claude.ts @@ -1019,6 +1019,8 @@ export const claudeRouter = router({ const agentsTarget = path.join(isolatedConfigDir, "agents") const settingsSource = path.join(homeClaudeDir, "settings.json") const settingsTarget = path.join(isolatedConfigDir, "settings.json") + const pluginsSource = path.join(homeClaudeDir, "plugins") + const pluginsTarget = path.join(isolatedConfigDir, "plugins") // Symlink skills directory if source exists and target doesn't try { @@ -1066,6 +1068,24 @@ export const claudeRouter = router({ // Ignore symlink errors (might already exist or permission issues) } + // Symlink plugins directory so Claude binary sees user's installed plugins + // (e.g. CC Safety Net for blocking destructive commands) + try { + const pluginsSourceExists = await fs + .stat(pluginsSource) + .then(() => true) + .catch(() => false) + const pluginsTargetExists = await fs + .lstat(pluginsTarget) + .then(() => true) + .catch(() => false) + if (pluginsSourceExists && !pluginsTargetExists) { + await fs.symlink(pluginsSource, pluginsTarget, "dir") + } + } catch (symlinkErr) { + // Ignore symlink errors (might already exist or permission issues) + } + symlinksCreated.add(cacheKey) }