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 && (
Binary file diff can't be rendered.
+ ) : isLargeDiff ? (
+
+
+
+ File is too large to display here
+
+ {absolutePath && (
+
+
+
+ Finder
+
+
+
+ {editorMeta.label}
+
+
+ )}
+
+
) : !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 && (
{
- e.stopPropagation()
- handleReview()
- }}
- className="h-6 px-3 text-xs font-medium rounded-md transition-transform duration-150 active:scale-[0.97]"
- >
- Review
-
+ {uncommittedFiles.length > 0 && (
+ {
+ e.stopPropagation()
+ handleReview()
+ }}
+ className="h-6 px-3 text-xs font-medium rounded-md transition-transform duration-150 active:scale-[0.97]"
+ >
+ Review
+
+ )}
@@ -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 && (
+
+ )}
+
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 (
+
+
+
+
+
+
+
+
+
+
+ {chat.name || "Untitled"}
+
+ {/* Archive button - appears on hover */}
+
+
+
+ {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 (
+
+
+
+
+
+
+
+
+ {chat.name || "Untitled"}
+
+
+
+
+
+
+ {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 = (
+
+
+
+
setOrdering(v as "newest" | "oldest")}>
+
+
+
+
+ Newest
+ Oldest
+
+
+
+
+
+ Show read
+
+
+
+ Show unread first
+
+
+
+ )
+
// Mobile layout - fullscreen list or fullscreen chat
if (isMobile) {
return (
@@ -222,32 +512,7 @@ export function InboxView() {
-
-
-
-
setOrdering(v as "newest" | "oldest")}>
-
-
-
-
- Newest
- Oldest
-
-
-
-
-
- Show read
-
-
-
- Show unread first
-
-
-
+ {filterContent}
@@ -277,47 +542,21 @@ export function InboxView() {
) : (
{filteredChats.map((chat) => (
-
handleChatClick(chat)}
- className="w-full text-left py-3 px-3 rounded-lg transition-colors duration-150 cursor-pointer hover:bg-foreground/5 active:bg-foreground/10"
- >
-
-
-
- {!chat.isRead && (
-
- )}
-
-
-
- {chat.name}
-
-
-
- {chat.automationName}
-
- •
-
- {formatDistanceToNow(new Date(chat.createdAt)) + " ago"}
-
-
-
-
- {chat.status}
-
-
-
+ 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 */}
-
-
-
-
setOrdering(v as "newest" | "oldest")}>
-
-
-
-
- Newest
- Oldest
-
-
-
-
-
- Show read
-
-
-
- Show unread first
-
-
-
+ {filterContent}
@@ -432,32 +647,7 @@ export function InboxView() {
-
-
-
-
setOrdering(v as "newest" | "oldest")}>
-
-
-
-
- Newest
- Oldest
-
-
-
-
-
- Show read
-
-
-
- Show unread first
-
-
-
+ {filterContent}
@@ -487,43 +677,19 @@ export function InboxView() {
) : (
- {filteredChats.map((chat) => (
-
(
+ handleChatClick(chat)}
- className={cn(
- "w-full text-left py-1.5 px-2 rounded-md transition-colors duration-150 cursor-pointer group",
- selectedChatId === chat.id
- ? "bg-foreground/5 text-foreground"
- : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
- )}
- >
-
-
-
- {!chat.isRead && (
-
- )}
-
-
-
- {chat.name}
-
-
-
- {chat.automationName}
-
- •
-
- {formatDistanceToNow(new Date(chat.createdAt)) + " ago"}
-
-
-
-
- {chat.status}
-
-
-
+ 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({
onExpand?.()}
>
View Diff
diff --git a/src/renderer/features/layout/agents-layout.tsx b/src/renderer/features/layout/agents-layout.tsx
index 7d651cf16..41699708f 100644
--- a/src/renderer/features/layout/agents-layout.tsx
+++ b/src/renderer/features/layout/agents-layout.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
import { useAtom, useAtomValue, useSetAtom } from "jotai"
+import { toast } from "sonner"
import { isDesktopApp } from "../../lib/utils/platform"
import { useIsMobile } from "../../lib/hooks/use-mobile"
@@ -7,6 +8,7 @@ import {
agentsSidebarOpenAtom,
agentsSidebarWidthAtom,
agentsSettingsDialogActiveTabAtom,
+ agentsSettingsDialogOpenAtom,
isDesktopAtom,
isFullscreenAtom,
anthropicOnboardingCompletedAtom,
@@ -87,6 +89,7 @@ export function AgentsLayout() {
const [sidebarOpen, setSidebarOpen] = useAtom(agentsSidebarOpenAtom)
const [sidebarWidth, setSidebarWidth] = useAtom(agentsSidebarWidthAtom)
const setSettingsActiveTab = useSetAtom(agentsSettingsDialogActiveTabAtom)
+ const setSettingsDialogOpen = useSetAtom(agentsSettingsDialogOpenAtom)
const desktopView = useAtomValue(desktopViewAtom)
const setFileSearchDialogOpen = useSetAtom(fileSearchDialogOpenAtom)
const [selectedChatId, setSelectedChatId] = useAtom(selectedAgentChatIdAtom)
@@ -189,6 +192,39 @@ export function AgentsLayout() {
}
}, [validatedProject, projects, setSidebarOpen])
+ // Worktree setup failures from main process
+ useEffect(() => {
+ if (typeof window === "undefined") return
+ const desktopApi = window.desktopApi as any
+ if (!desktopApi?.onWorktreeSetupFailed) return
+
+ const unsubscribe = desktopApi.onWorktreeSetupFailed((payload: { kind: "create-failed" | "setup-failed"; message: string; projectId: string }) => {
+ const errorMessage = payload.message.replace(/\s+/g, " ").trim()
+ const title =
+ payload.kind === "create-failed"
+ ? "Worktree creation failed"
+ : "Worktree setup failed"
+
+ toast.error(title, {
+ description: errorMessage || undefined,
+ duration: 10000,
+ action: {
+ label: "Open settings",
+ onClick: () => {
+ const projectMatch = projects?.find((project) => project.id === payload.projectId)
+ if (projectMatch) {
+ setSelectedProject(projectMatch as any)
+ }
+ setSettingsActiveTab("projects")
+ setSettingsDialogOpen(true)
+ },
+ },
+ })
+ })
+
+ return unsubscribe
+ }, [projects, setSelectedProject, setSettingsActiveTab, setSettingsDialogOpen])
+
// Handle sign out
const handleSignOut = useCallback(async () => {
// Clear selected project and anthropic onboarding on logout
@@ -200,11 +236,9 @@ export function AgentsLayout() {
}
}, [setSelectedProject, setSelectedChatId, setAnthropicOnboardingCompleted])
- // Initialize sub-chats when chat is selected
+ // Clear sub-chat store when no chat is selected
useEffect(() => {
- if (selectedChatId) {
- setChatId(selectedChatId)
- } else {
+ if (!selectedChatId) {
setChatId(null)
}
}, [selectedChatId, setChatId])
diff --git a/src/renderer/features/onboarding/anthropic-onboarding-page.tsx b/src/renderer/features/onboarding/anthropic-onboarding-page.tsx
index b37b75ca9..ca712916f 100644
--- a/src/renderer/features/onboarding/anthropic-onboarding-page.tsx
+++ b/src/renderer/features/onboarding/anthropic-onboarding-page.tsx
@@ -62,12 +62,14 @@ export function AnthropicOnboardingPage() {
const submitCodeMutation = trpc.claudeCode.submitCode.useMutation()
const openOAuthUrlMutation = trpc.claudeCode.openOAuthUrl.useMutation()
const importSystemTokenMutation = trpc.claudeCode.importSystemToken.useMutation()
- const existingTokenQuery = trpc.claudeCode.getSystemToken.useQuery()
- const existingToken = existingTokenQuery.data?.token ?? null
- const hasExistingToken = !!existingToken
- const checkedExistingToken = existingTokenQuery.isFetched
- const shouldOfferExistingToken =
- checkedExistingToken && hasExistingToken && !ignoredExistingToken
+ // Disabled: importing CLI token is broken — access tokens expire in ~8 hours
+ // and we don't store the refresh token. Always use sandbox OAuth flow instead.
+ // const existingTokenQuery = trpc.claudeCode.getSystemToken.useQuery()
+ // const existingToken = existingTokenQuery.data?.token ?? null
+ const existingToken = null
+ const hasExistingToken = false
+ const checkedExistingToken = true
+ const shouldOfferExistingToken = false
// Poll for OAuth URL
const pollStatusQuery = trpc.claudeCode.pollStatus.useQuery(
diff --git a/src/renderer/features/sidebar/agents-sidebar.tsx b/src/renderer/features/sidebar/agents-sidebar.tsx
index fd0f7245a..1f74c17be 100644
--- a/src/renderer/features/sidebar/agents-sidebar.tsx
+++ b/src/renderer/features/sidebar/agents-sidebar.tsx
@@ -38,6 +38,7 @@ import {
useRestoreRemoteChat,
useRenameRemoteChat,
} from "../../lib/hooks/use-remote-chats"
+import { usePrefetchLocalChat } from "../../lib/hooks/use-prefetch-local-chat"
import { ArchivePopover } from "../agents/ui/archive-popover"
import { ChevronDown, MoreHorizontal, Columns3, ArrowUpRight } from "lucide-react"
import { useQuery } from "@tanstack/react-query"
@@ -968,8 +969,9 @@ const ChatListSection = React.memo(function ChatListSection({
: repoName || (chat.isRemote ? "Remote project" : "Local project")
const isChecked = selectedChatIds.has(chat.id)
- // For remote chats, use remoteStats; for local, use workspaceFileStats
- const stats = chat.isRemote ? chat.remoteStats : workspaceFileStats.get(chat.id)
+ // TODO: remote stats disabled — backend no longer computes them (was causing 50s+ loads)
+ // Will re-enable once stats are precomputed at write time
+ const stats = chat.isRemote ? null : workspaceFileStats.get(chat.id)
const hasPendingPlan = workspacePendingPlans.has(chat.id)
const hasPendingQuestion = workspacePendingQuestions.has(chat.id)
const isLastInFilteredChats = globalIndex === filteredChats.length - 1
@@ -1181,7 +1183,7 @@ const InboxButton = memo(function InboxButton() {
Inbox
{inboxUnreadCount > 0 && (
-
+
{inboxUnreadCount > 99 ? "99+" : inboxUnreadCount}
)}
@@ -1789,6 +1791,7 @@ export function AgentsSidebar({
// Prefetch individual chat data on hover
const prefetchRemoteChat = usePrefetchRemoteChat()
+ const prefetchLocalChat = usePrefetchLocalChat()
// Merge local and remote chats into unified list
const agentChats = useMemo(() => {
@@ -2808,11 +2811,13 @@ export function AgentsSidebar({
// Update hovered index ref
hoveredChatIndexRef.current = globalIndex
- // Prefetch chat data on hover (for remote chats, for instant load on click)
+ // Prefetch chat data on hover for instant load on click
const chat = agentChats?.find((c) => c.id === chatId)
if (chat?.isRemote) {
const originalId = chatId.replace(/^remote_/, '')
prefetchRemoteChat(originalId)
+ } else {
+ prefetchLocalChat(chatId)
}
// Clear any existing timer
@@ -2839,7 +2844,7 @@ export function AgentsSidebar({
tooltip.textContent = name || ""
}, 1000)
},
- [agentChats, prefetchRemoteChat],
+ [agentChats, prefetchRemoteChat, prefetchLocalChat],
)
const handleAgentMouseLeave = useCallback(() => {
diff --git a/src/renderer/lib/hooks/use-file-change-listener.ts b/src/renderer/lib/hooks/use-file-change-listener.ts
index 25a6f805c..6b3b0e636 100644
--- a/src/renderer/lib/hooks/use-file-change-listener.ts
+++ b/src/renderer/lib/hooks/use-file-change-listener.ts
@@ -5,8 +5,18 @@ import { useQueryClient } from "@tanstack/react-query"
* Hook that listens for file changes from Claude Write/Edit tools
* and invalidates the git status query to trigger a refetch
*/
-export function useFileChangeListener(worktreePath: string | null | undefined) {
+export function useFileChangeListener(
+ worktreePath: string | null | undefined,
+ options?: {
+ onChange?: (data: { filePath: string; type: string; subChatId: string }) => void
+ },
+) {
const queryClient = useQueryClient()
+ const onChangeRef = useRef(options?.onChange)
+
+ useEffect(() => {
+ onChangeRef.current = options?.onChange
+ }, [options?.onChange])
useEffect(() => {
if (!worktreePath) return
@@ -18,6 +28,14 @@ export function useFileChangeListener(worktreePath: string | null | undefined) {
queryClient.invalidateQueries({
queryKey: [["changes", "getStatus"]],
})
+ // Invalidate parsed diff caches for both changes + chats routes
+ queryClient.invalidateQueries({
+ queryKey: [["changes", "getParsedDiff"]],
+ })
+ queryClient.invalidateQueries({
+ queryKey: [["chats", "getParsedDiff"]],
+ })
+ onChangeRef.current?.(data)
}
})
@@ -32,9 +50,27 @@ export function useFileChangeListener(worktreePath: string | null | undefined) {
* Uses chokidar on the main process for efficient file watching.
* Automatically invalidates git status queries when files change.
*/
-export function useGitWatcher(worktreePath: string | null | undefined) {
+export function useGitWatcher(
+ worktreePath: string | null | undefined,
+ options?: {
+ onChange?: (data: { worktreePath: string; changes: Array<{ path: string; type: "add" | "change" | "unlink" }> }) => void
+ debounceMs?: number
+ },
+) {
const queryClient = useQueryClient()
const isSubscribedRef = useRef(false)
+ const onChangeRef = useRef(options?.onChange)
+ const debounceMsRef = useRef(options?.debounceMs ?? 0)
+ const debounceTimerRef = useRef(null)
+ const pendingEventRef = useRef<{
+ worktreePath: string
+ changes: Array<{ path: string; type: "add" | "change" | "unlink" }>
+ } | null>(null)
+
+ useEffect(() => {
+ onChangeRef.current = options?.onChange
+ debounceMsRef.current = options?.debounceMs ?? 0
+ }, [options?.onChange, options?.debounceMs])
useEffect(() => {
if (!worktreePath) return
@@ -67,12 +103,40 @@ export function useGitWatcher(worktreePath: string | null | undefined) {
queryClient.invalidateQueries({
queryKey: [["changes", "getParsedDiff"]],
})
+ queryClient.invalidateQueries({
+ queryKey: [["chats", "getParsedDiff"]],
+ })
+ }
+
+ const onChange = onChangeRef.current
+ if (onChange) {
+ const debounceMs = debounceMsRef.current
+ if (debounceMs > 0) {
+ pendingEventRef.current = data
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current)
+ }
+ debounceTimerRef.current = setTimeout(() => {
+ debounceTimerRef.current = null
+ if (pendingEventRef.current) {
+ onChange(pendingEventRef.current)
+ pendingEventRef.current = null
+ }
+ }, debounceMs)
+ } else {
+ onChange(data)
+ }
}
}
})
return () => {
cleanup?.()
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current)
+ debounceTimerRef.current = null
+ }
+ pendingEventRef.current = null
// Unsubscribe from git watcher
if (isSubscribedRef.current) {
diff --git a/src/renderer/lib/hooks/use-prefetch-local-chat.ts b/src/renderer/lib/hooks/use-prefetch-local-chat.ts
new file mode 100644
index 000000000..dc4721634
--- /dev/null
+++ b/src/renderer/lib/hooks/use-prefetch-local-chat.ts
@@ -0,0 +1,13 @@
+import { useCallback } from "react"
+import { trpc } from "../trpc"
+
+export function usePrefetchLocalChat() {
+ const utils = trpc.useUtils()
+
+ return useCallback(
+ (chatId: string) => {
+ utils.chats.get.prefetch({ id: chatId }, { staleTime: 5000 })
+ },
+ [utils]
+ )
+}
From ed5b70a42aa2f6c47f6d130610595e2cf6945616 Mon Sep 17 00:00:00 2001
From: serafim
Date: Thu, 5 Feb 2026 15:22:42 -0800
Subject: [PATCH 3/6] Release v0.0.57
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What's New
### Features
- **Git Activity Badges** — Show git activity badges on agent messages
### Improvements & Fixes
- **Status Card** — Hide expand chevron when no files to show
- **Git Modal** — Fixed crash after git modal close
- **Git Pull** — Fixed git pull functionality
- **Env Config** — Fixed missing comma in env assignment
---
package.json | 2 +-
src/main/lib/git/git-operations.ts | 32 ++-
src/main/lib/trpc/routers/claude.ts | 12 +-
src/renderer/features/agents/atoms/index.ts | 4 +
src/renderer/features/agents/lib/models.ts | 6 +-
.../features/agents/main/active-chat.tsx | 10 +-
.../agents/main/assistant-message-item.tsx | 4 +
.../features/agents/main/chat-input-area.tsx | 10 +-
.../features/agents/main/new-chat-form.tsx | 4 +-
.../agents/ui/git-activity-badges.tsx | 195 ++++++++++++++++++
.../agents/ui/sub-chat-status-card.tsx | 27 ++-
.../features/agents/utils/git-activity.ts | 159 ++++++++++++++
.../features/changes/changes-panel.tsx | 4 +
.../features/changes/changes-view.tsx | 50 +++--
.../components/history-view/history-view.tsx | 13 +-
15 files changed, 480 insertions(+), 52 deletions(-)
create mode 100644 src/renderer/features/agents/ui/git-activity-badges.tsx
create mode 100644 src/renderer/features/agents/utils/git-activity.ts
diff --git a/package.json b/package.json
index 9e424580e..d81fd516b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "21st-desktop",
- "version": "0.0.55",
+ "version": "0.0.57",
"private": true,
"description": "1Code - UI for parallel work with AI agents",
"author": {
diff --git a/src/main/lib/git/git-operations.ts b/src/main/lib/git/git-operations.ts
index b1d542450..e027077a4 100644
--- a/src/main/lib/git/git-operations.ts
+++ b/src/main/lib/git/git-operations.ts
@@ -5,6 +5,7 @@ import { publicProcedure, router } from "../trpc";
import { isUpstreamMissingError } from "./git-utils";
import { assertRegisteredWorktree } from "./security";
import { fetchGitHubPRStatus } from "./github";
+import { gitCache } from "./cache";
import {
createGit,
createGitForNetwork,
@@ -31,6 +32,12 @@ async function hasUpstreamBranch(
/** Protected branches that should not be force-pushed to */
const PROTECTED_BRANCHES = ["main", "master", "develop", "production", "staging"];
+function invalidateGitStateCaches(worktreePath: string): void {
+ gitCache.invalidateStatus(worktreePath);
+ gitCache.invalidateParsedDiff(worktreePath);
+ gitCache.invalidateAllFileContents(worktreePath);
+}
+
export const createGitOperationsRouter = () => {
return router({
// NOTE: saveFile is defined in file-contents.ts with hardened path validation
@@ -50,6 +57,7 @@ export const createGitOperationsRouter = () => {
await withLockRetry(input.worktreePath, () =>
git.fetch(["--all", "--prune"])
);
+ invalidateGitStateCaches(input.worktreePath);
return { success: true };
});
}),
@@ -76,6 +84,7 @@ export const createGitOperationsRouter = () => {
await withLockRetry(input.worktreePath, () =>
git.checkout(input.branch)
);
+ invalidateGitStateCaches(input.worktreePath);
return { success: true };
});
}),
@@ -98,6 +107,7 @@ export const createGitOperationsRouter = () => {
author: string;
email: string;
date: Date;
+ tags: string[];
}>
> => {
assertRegisteredWorktree(input.worktreePath);
@@ -106,7 +116,7 @@ export const createGitOperationsRouter = () => {
const logOutput = await git.raw([
"log",
`-${input.limit}`,
- "--format=%H|%h|%s|%an|%ae|%aI",
+ "--format=%H|%h|%s|%an|%ae|%aI|%D",
]);
if (!logOutput.trim()) return [];
@@ -115,8 +125,14 @@ export const createGitOperationsRouter = () => {
.trim()
.split("\n")
.map((line) => {
- const [hash, shortHash, message, author, email, dateStr] =
+ const [hash, shortHash, message, author, email, dateStr, refs] =
line.split("|");
+ // Extract tags from ref names (format: "HEAD -> main, tag: v1.0.0, origin/main")
+ const tags = (refs || "")
+ .split(",")
+ .map((r) => r.trim())
+ .filter((r) => r.startsWith("tag: "))
+ .map((r) => r.replace("tag: ", ""));
return {
hash: hash || "",
shortHash: shortHash || "",
@@ -124,6 +140,7 @@ export const createGitOperationsRouter = () => {
author: author || "",
email: email || "",
date: new Date(dateStr || ""),
+ tags,
};
});
},
@@ -157,6 +174,7 @@ export const createGitOperationsRouter = () => {
const result = await withLockRetry(input.worktreePath, () =>
git.commit(input.message)
);
+ invalidateGitStateCaches(input.worktreePath);
return { success: true, hash: result.commit };
});
},
@@ -209,6 +227,7 @@ export const createGitOperationsRouter = () => {
git.commit(input.message)
);
+ invalidateGitStateCaches(input.worktreePath);
return { success: true, hash: result.commit };
});
},
@@ -237,6 +256,7 @@ export const createGitOperationsRouter = () => {
await withLockRetry(input.worktreePath, () => git.push());
}
await git.fetch();
+ invalidateGitStateCaches(input.worktreePath);
return { success: true };
});
}),
@@ -309,6 +329,7 @@ export const createGitOperationsRouter = () => {
}
throw error;
}
+ invalidateGitStateCaches(input.worktreePath);
return { success: true };
});
}),
@@ -369,6 +390,7 @@ export const createGitOperationsRouter = () => {
git.push(["--set-upstream", "origin", branch.trim()])
);
await git.fetch();
+ invalidateGitStateCaches(input.worktreePath);
return { success: true };
}
// Check for rebase conflicts
@@ -382,6 +404,7 @@ export const createGitOperationsRouter = () => {
}
await withLockRetry(input.worktreePath, () => git.push());
await git.fetch();
+ invalidateGitStateCaches(input.worktreePath);
return { success: true };
});
}),
@@ -413,6 +436,7 @@ export const createGitOperationsRouter = () => {
git.push(["--force-with-lease"])
);
await git.fetch();
+ invalidateGitStateCaches(input.worktreePath);
return { success: true };
});
}),
@@ -496,6 +520,7 @@ export const createGitOperationsRouter = () => {
throw error;
}
+ invalidateGitStateCaches(input.worktreePath);
return { success: true };
});
}),
@@ -513,6 +538,7 @@ export const createGitOperationsRouter = () => {
return withGitLock(input.worktreePath, async () => {
const git = createGit(input.worktreePath);
await git.rebase(["--abort"]);
+ invalidateGitStateCaches(input.worktreePath);
return { success: true };
});
}),
@@ -530,6 +556,7 @@ export const createGitOperationsRouter = () => {
return withGitLock(input.worktreePath, async () => {
const git = createGit(input.worktreePath);
await git.merge(["--abort"]);
+ invalidateGitStateCaches(input.worktreePath);
return { success: true };
});
}),
@@ -586,6 +613,7 @@ export const createGitOperationsRouter = () => {
await shell.openExternal(url);
await git.fetch();
+ invalidateGitStateCaches(input.worktreePath);
return { success: true, url };
});
diff --git a/src/main/lib/trpc/routers/claude.ts b/src/main/lib/trpc/routers/claude.ts
index f3535d24a..bfe3c1b11 100644
--- a/src/main/lib/trpc/routers/claude.ts
+++ b/src/main/lib/trpc/routers/claude.ts
@@ -1195,10 +1195,10 @@ export const claudeRouter = router({
// 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,
}
@@ -1550,9 +1550,7 @@ ${prompt}
Object.keys(mcpServersFiltered).length > 0 && {
mcpServers: mcpServersFiltered,
}),
- env: {
- ...finalEnv,
- },
+ env: finalEnv,
permissionMode:
input.mode === "plan"
? ("plan" as const)
diff --git a/src/renderer/features/agents/atoms/index.ts b/src/renderer/features/agents/atoms/index.ts
index 0ed6ffa63..9c8d500fc 100644
--- a/src/renderer/features/agents/atoms/index.ts
+++ b/src/renderer/features/agents/atoms/index.ts
@@ -555,6 +555,10 @@ export type SelectedCommit = {
} | null
export const selectedCommitAtom = atom(null)
+// Active tab in diff sidebar (Changes/History)
+// Exposed as atom so external components (e.g. git activity badges) can switch tabs
+export const diffActiveTabAtom = atom<"changes" | "history">("changes")
+
// Pending PR message to send to chat
// Set by ChatView when "Create PR" is clicked, consumed by ChatViewInner
export const pendingPrMessageAtom = atom<{ message: string; subChatId: string } | null>(null)
diff --git a/src/renderer/features/agents/lib/models.ts b/src/renderer/features/agents/lib/models.ts
index 12924b429..e29353292 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 4.6" },
- { id: "sonnet", name: "Sonnet 4.5" },
- { id: "haiku", name: "Haiku 4.5" },
+ { id: "opus", name: "Opus", version: "4.6" },
+ { id: "sonnet", name: "Sonnet", version: "4.5" },
+ { id: "haiku", name: "Haiku", version: "4.5" },
]
diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx
index e3d51f83b..bcb683388 100644
--- a/src/renderer/features/agents/main/active-chat.tsx
+++ b/src/renderer/features/agents/main/active-chat.tsx
@@ -135,6 +135,7 @@ import {
QUESTIONS_SKIPPED_MESSAGE,
selectedAgentChatIdAtom,
selectedCommitAtom,
+ diffActiveTabAtom,
selectedDiffFilePathAtom,
setLoading,
subChatFilesAtom,
@@ -1157,8 +1158,8 @@ const DiffSidebarContent = memo(function DiffSidebarContent({
const [isChangesPanelCollapsed, setIsChangesPanelCollapsed] = useAtom(agentsChangesPanelCollapsedAtom)
const [isResizing, setIsResizing] = useState(false)
- // Active tab state (Changes/History)
- const [activeTab, setActiveTab] = useState<"changes" | "history">("changes")
+ // Active tab state (Changes/History) - atom so external components can switch tabs
+ const [activeTab, setActiveTab] = useAtom(diffActiveTabAtom)
// Register the reset function so handleCloseDiff can reset to "changes" tab before closing
// This prevents React 19 ref cleanup issues with HistoryView's ContextMenu components
@@ -1287,6 +1288,7 @@ const DiffSidebarContent = memo(function DiffSidebarContent({
)}>
{}}
@@ -1401,6 +1403,7 @@ const DiffSidebarContent = memo(function DiffSidebarContent({
>
{}}
@@ -5689,7 +5692,8 @@ Make sure to preserve all functionality from both branches when resolving confli
// Stable callbacks for DiffSidebarHeader to prevent re-renders
const handleRefreshGitStatus = useCallback(() => {
refetchGitStatus()
- }, [refetchGitStatus])
+ scheduleDiffRefresh()
+ }, [refetchGitStatus, scheduleDiffRefresh])
const handleExpandAll = useCallback(() => {
diffViewRef.current?.expandAll()
diff --git a/src/renderer/features/agents/main/assistant-message-item.tsx b/src/renderer/features/agents/main/assistant-message-item.tsx
index c58cf7a03..75cac6c3d 100644
--- a/src/renderer/features/agents/main/assistant-message-item.tsx
+++ b/src/renderer/features/agents/main/assistant-message-item.tsx
@@ -35,6 +35,7 @@ import {
getMessageTextContent,
} from "../ui/message-action-buttons"
import { useFileOpen } from "../mentions"
+import { GitActivityBadges } from "../ui/git-activity-badges"
import { MemoizedTextPart } from "./memoized-text-part"
// Exploring tools - these get grouped when 3+ consecutive
@@ -741,6 +742,9 @@ export const AssistantMessageItem = memo(function AssistantMessageItem({
)}
+ {/* 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 */}
+
+
{
+ e.stopPropagation()
+ setIsExpanded(!isExpanded)
+ }}
+ className="p-1 rounded-md hover:bg-accent transition-[background-color,transform] duration-150 ease-out active:scale-95"
+ >
+
+
+
+
+
+
+
+
+
+ {/* 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.message}
+
+ )}
+
+ {activity?.type === "pr" && (
+
window.desktopApi.openExternal(activity.url)}
+ className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-border bg-muted/30 text-xs text-muted-foreground hover:bg-muted/50 hover:text-foreground transition-colors cursor-pointer overflow-hidden min-w-0"
+ >
+
+ {activity.title}
+
+ )}
+
+ )
+})
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 && (
- onRollback(message)}
- disabled={isStreaming || isRollingBack}
- tabIndex={-1}
- className={cn(
- "p-1.5 rounded-md transition-[background-color,transform] duration-150 ease-out hover:bg-accent active:scale-[0.97]",
- (isStreaming || isRollingBack) && "opacity-50 cursor-not-allowed",
- )}
- >
-
-
- )}
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 && (
+
+
+
+ onRollback(userMsg)}
+ disabled={isRollingBack}
+ tabIndex={-1}
+ className={cn(
+ "p-1 rounded-md transition-all duration-150 ease-out hover:bg-accent/80 active:scale-[0.97] opacity-0 group-hover/user-message:opacity-100",
+ isRollingBack && "!opacity-50 cursor-not-allowed",
+ )}
+ >
+
+
+
+
+ {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 */}
)}
- {/* Image */}
- e.stopPropagation()}
- />
+ {/* Image with context menu */}
+
+
+ e.stopPropagation()}
+ />
+
+
+
+
+ Copy Image
+
+
+
+ Save Image
+
+
+
{/* Next button */}
{hasMultipleImages && (
diff --git a/src/renderer/features/agents/ui/agent-task-tool.tsx b/src/renderer/features/agents/ui/agent-task-tool.tsx
index 3d66adf37..0ed199ef1 100644
--- a/src/renderer/features/agents/ui/agent-task-tool.tsx
+++ b/src/renderer/features/agents/ui/agent-task-tool.tsx
@@ -46,6 +46,15 @@ export const AgentTaskTool = memo(function AgentTaskTool({
// Default: collapsed
const [isExpanded, setIsExpanded] = useState(false)
const scrollRef = useRef(null)
+ const wasPendingRef = useRef(isPending)
+
+ // Auto-collapse when task completes (transition from pending -> done)
+ useEffect(() => {
+ if (wasPendingRef.current && !isPending) {
+ setIsExpanded(false)
+ }
+ wasPendingRef.current = isPending
+ }, [isPending])
// Track elapsed time for running tasks
const [elapsedMs, setElapsedMs] = useState(0)
diff --git a/src/renderer/features/agents/ui/agent-thinking-tool.tsx b/src/renderer/features/agents/ui/agent-thinking-tool.tsx
index 9ccdebe88..39b4a4c97 100644
--- a/src/renderer/features/agents/ui/agent-thinking-tool.tsx
+++ b/src/renderer/features/agents/ui/agent-thinking-tool.tsx
@@ -47,9 +47,18 @@ export const AgentThinkingTool = memo(function AgentThinkingTool({
const isStreaming = isPending && isActivelyStreaming
const isInterrupted = isPending && !isActivelyStreaming && chatStatus !== undefined
- // Always start collapsed — like SubAgent tools
- const [isExpanded, setIsExpanded] = useState(false)
+ // Default: expanded while streaming, collapsed when done
+ const [isExpanded, setIsExpanded] = useState(isStreaming)
const scrollRef = useRef(null)
+ const wasStreamingRef = useRef(isStreaming)
+
+ // Auto-collapse when streaming ends (transition from true -> false)
+ useEffect(() => {
+ if (wasStreamingRef.current && !isStreaming) {
+ setIsExpanded(false)
+ }
+ wasStreamingRef.current = isStreaming
+ }, [isStreaming])
// Elapsed time — ticks every second while streaming
const startedAtRef = useRef(part.startedAt || Date.now())
@@ -126,20 +135,25 @@ export const AgentThinkingTool = memo(function AgentThinkingTool({
- {/* 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 = (
-
-
-
-
setOrdering(v as "newest" | "oldest")}>
-
-
-
-
- Newest
- Oldest
-
-
-
-
-
- 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"
+ >
+
+
+ {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)
}