feat: fix tool call chain and Tasks panel integration
Phase 1: RightPanel Files/Tasks tab toggle - RightPanel.tsx: add Files/Tasks toggle buttons, conditionally render FileTree vs TaskList based on panelContent state - en.ts/zh.ts: add panel.tasks translation Phase 2: TodoWrite SDK mapping - types/index.ts: add source field to TaskItem, add task_update SSE event type - db.ts: add source column migration, syncSdkTasks() replace-all function that syncs SDK TodoWrite output to local tasks table (user tasks untouched) - claude-client.ts: PostToolUse hook detects TodoWrite tool, emits task_update SSE with mapped todo items - route.ts: collectStreamResponse handles task_update, calls syncSdkTasks() - useSSEStream.ts: add onTaskUpdate callback and task_update case - ChatView.tsx: onTaskUpdate dispatches tasks-updated CustomEvent - TaskList.tsx: listens for tasks-updated event to auto-refresh - tasks/route.ts: add PUT bulk sync endpoint for testing Phase 3: tool_result three-layer deduplication - claude-client.ts: emittedToolResultIds Set prevents PostToolUse and user message handler from emitting duplicate tool_result for same tool_use_id - route.ts: seenToolResultIds Set in collectStreamResponse skips duplicate DB writes - ChatView.tsx: onToolResult checks existing tool_use_id before state update Phase 4: Agent SDK upgrade - Upgrade @anthropic-ai/claude-agent-sdk from v0.2.33 to v0.2.62 (29 versions, zero TypeScript errors, all integration points compatible) Phase 5: Documentation - docs/agent-tooling-todo-bridge.md: architecture overview, TodoWrite field mapping, status mapping, idempotency strategy, three-layer dedup, SSE event types, troubleshooting guide - CLAUDE.md: add Architecture Docs section with reference Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
2a7dbd45c2
commit
c13ff54638
15 files changed
+318
-325
No files matched your search
@@ -65,6 +65,10 @@ CodePilot — Claude Code 的桌面 GUI 客户端,基于 Electron + Next.js。
|
||||
- **Requirements**(系统要求、依赖说明)
|
||||
- **Changelog**(自上一版本以来的 commit 列表)
|
||||
|
||||
## Architecture Docs
|
||||
|
||||
- [Agent Tooling & TodoWrite Bridge](docs/agent-tooling-todo-bridge.md) — SDK → SSE → DB 事件流、TodoWrite 字段映射、tool_result 三层去重策略
|
||||
|
||||
## Build Notes
|
||||
|
||||
- macOS 构建产出 DMG(arm64 + x64),Windows 产出 NSIS 安装包或 zip
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Agent Tooling & TodoWrite Bridge
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
SDK TodoWrite tool_use
|
||||
→ PostToolUse hook (claude-client.ts)
|
||||
→ Emits tool_result SSE (deduped)
|
||||
→ Emits task_update SSE { session_id, todos[] }
|
||||
→ collectStreamResponse (route.ts)
|
||||
→ syncSdkTasks() persists to DB
|
||||
→ Frontend SSE consumer (useSSEStream.ts)
|
||||
→ onTaskUpdate dispatches 'tasks-updated' event
|
||||
→ TaskList re-fetches from /api/tasks
|
||||
```
|
||||
|
||||
## TodoWrite Field Mapping
|
||||
|
||||
| SDK TodoWrite field | Local tasks table column | Notes |
|
||||
|---------------------|-------------------------|-------|
|
||||
| `id` | `id` (prefixed `sdk-`) | Prefixed to avoid collision with user tasks |
|
||||
| `content` | `title` | Main display text |
|
||||
| `status` | `status` | Mapped via `mapStatus()` |
|
||||
| `priority` | `description` | Optional context |
|
||||
|
||||
### Status Mapping
|
||||
|
||||
| SDK status | Local TaskStatus |
|
||||
|---------------|------------------|
|
||||
| `pending` | `pending` |
|
||||
| `in_progress` | `in_progress` |
|
||||
| `completed` | `completed` |
|
||||
| Other | `pending` |
|
||||
|
||||
## Idempotency
|
||||
|
||||
`syncSdkTasks()` uses a **replace-all** strategy within a transaction:
|
||||
1. `DELETE FROM tasks WHERE session_id = ? AND source = 'sdk'`
|
||||
2. `INSERT` all todos from the latest TodoWrite payload
|
||||
|
||||
This is naturally idempotent — calling it multiple times with the same data produces identical results. User-created tasks (`source = 'user'`) are never affected.
|
||||
|
||||
## tool_result Three-Layer Dedup
|
||||
|
||||
Both `PostToolUse` hook and `user` message handler in the SDK can emit the same `tool_result`. Three layers prevent duplicates:
|
||||
|
||||
### Layer 1: claude-client.ts (SSE emission)
|
||||
- `emittedToolResultIds` Set — checks before enqueueing any `tool_result` SSE event
|
||||
- Both PostToolUse and user message handler check this Set
|
||||
|
||||
### Layer 2: route.ts (DB persistence)
|
||||
- `seenToolResultIds` Set in `collectStreamResponse()` — skips duplicate `tool_use_id` before writing to `contentBlocks`
|
||||
|
||||
### Layer 3: ChatView.tsx (UI state)
|
||||
- `onToolResult` checks `prev.some(r => r.tool_use_id === id)` before adding to state
|
||||
|
||||
## SSE Event Types
|
||||
|
||||
| Event | Source | Purpose |
|
||||
|-------------------|-------------------|---------|
|
||||
| `text` | stream_event | Streaming text delta |
|
||||
| `tool_use` | assistant message | Tool invocation |
|
||||
| `tool_result` | PostToolUse / user | Tool execution result (deduped) |
|
||||
| `tool_output` | stderr callback | Raw tool output |
|
||||
| `tool_timeout` | tool_progress | Tool execution timeout |
|
||||
| `task_update` | PostToolUse | TodoWrite sync trigger |
|
||||
| `status` | system message | Session init, notifications |
|
||||
| `result` | result message | Final result with usage stats |
|
||||
| `permission_request` | canUseTool | Permission approval needed |
|
||||
| `mode_changed` | system status | SDK mode change |
|
||||
| `error` | catch block | Error occurred |
|
||||
| `done` | stream end | Stream complete |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tasks not appearing after TodoWrite
|
||||
1. Check server console for `[claude-client]` logs — ensure PostToolUse fires
|
||||
2. Verify `task_update` SSE event in browser DevTools Network tab
|
||||
3. Check `syncSdkTasks()` runs in `collectStreamResponse` (server-side tee'd stream)
|
||||
4. Verify `tasks-updated` CustomEvent fires in browser console
|
||||
|
||||
### Duplicate tool results
|
||||
1. Check `emittedToolResultIds` Set size in server logs
|
||||
2. Compare SSE event count before/after (should be halved for tools)
|
||||
3. Query `messages` table: `SELECT content FROM messages WHERE content LIKE '%tool_result%'` — each `tool_use_id` should appear once
|
||||
|
||||
### SDK upgrade issues
|
||||
- Current: v0.2.62 (upgraded from v0.2.33)
|
||||
- Rollback: `npm install @anthropic-ai/claude-agent-sdk@0.2.33`
|
||||
- Key integration points: `canUseTool`, `PostToolUse`, `Notification`, `tool_progress`, `system`, `result`
|
||||
Generated
+13
-297
@@ -11,7 +11,7 @@
|
||||
"@ai-sdk/anthropic": "^3.0.47",
|
||||
"@ai-sdk/google": "^3.0.31",
|
||||
"@ai-sdk/openai": "^3.0.34",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.33",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.62",
|
||||
"@google/genai": "^1.43.0",
|
||||
"@hugeicons/core-free-icons": "^3.1.1",
|
||||
"@hugeicons/react": "^1.1.4",
|
||||
@@ -375,312 +375,28 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.2.33",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.33.tgz",
|
||||
"integrity": "sha512-Ox7Vb8hXgnZyq9f5OtZJCmJ4Ag2LtAXmCJ3e/RD4pohYVUZelRaPeMeUHuS5bAykbHYdu4r9hloSaTCSsNiiVw==",
|
||||
"version": "0.2.62",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.62.tgz",
|
||||
"integrity": "sha512-exRJ2djKP6erRpUrtnVf5iXzqeS9dAie9d1ghODZVVXdLqieSqCpaAhZhe0hL1yvP33OL0J5CgT9RAyPzhYY9A==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"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"
|
||||
"@img/sharp-darwin-arm64": "^0.34.2",
|
||||
"@img/sharp-darwin-x64": "^0.34.2",
|
||||
"@img/sharp-linux-arm": "^0.34.2",
|
||||
"@img/sharp-linux-arm64": "^0.34.2",
|
||||
"@img/sharp-linux-x64": "^0.34.2",
|
||||
"@img/sharp-linuxmusl-arm64": "^0.34.2",
|
||||
"@img/sharp-linuxmusl-x64": "^0.34.2",
|
||||
"@img/sharp-win32-arm64": "^0.34.2",
|
||||
"@img/sharp-win32-x64": "^0.34.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
|
||||
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
|
||||
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
|
||||
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
|
||||
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
|
||||
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
|
||||
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
|
||||
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
|
||||
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
|
||||
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
|
||||
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
|
||||
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
|
||||
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
|
||||
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
|
||||
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
|
||||
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@
|
||||
"@ai-sdk/anthropic": "^3.0.47",
|
||||
"@ai-sdk/google": "^3.0.31",
|
||||
"@ai-sdk/openai": "^3.0.34",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.33",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.62",
|
||||
"@google/genai": "^1.43.0",
|
||||
"@hugeicons/core-free-icons": "^3.1.1",
|
||||
"@hugeicons/react": "^1.1.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { streamClaude } from '@/lib/claude-client';
|
||||
import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, getProvider, getDefaultProviderId, acquireSessionLock, releaseSessionLock, setSessionRuntimeStatus } from '@/lib/db';
|
||||
import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, getProvider, getDefaultProviderId, acquireSessionLock, releaseSessionLock, setSessionRuntimeStatus, syncSdkTasks } from '@/lib/db';
|
||||
import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment } from '@/types';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
@@ -235,6 +235,8 @@ async function collectStreamResponse(stream: ReadableStream<string>, sessionId:
|
||||
const contentBlocks: MessageContentBlock[] = [];
|
||||
let currentText = '';
|
||||
let tokenUsage: TokenUsage | null = null;
|
||||
// Dedup layer: skip duplicate tool_result events by tool_use_id
|
||||
const seenToolResultIds = new Set<string>();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
@@ -270,12 +272,16 @@ async function collectStreamResponse(stream: ReadableStream<string>, sessionId:
|
||||
} else if (event.type === 'tool_result') {
|
||||
try {
|
||||
const resultData = JSON.parse(event.data);
|
||||
contentBlocks.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: resultData.tool_use_id,
|
||||
content: resultData.content,
|
||||
is_error: resultData.is_error || false,
|
||||
});
|
||||
// Dedup: skip if already seen
|
||||
if (!seenToolResultIds.has(resultData.tool_use_id)) {
|
||||
seenToolResultIds.add(resultData.tool_use_id);
|
||||
contentBlocks.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: resultData.tool_use_id,
|
||||
content: resultData.content,
|
||||
is_error: resultData.is_error || false,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// skip malformed tool_result data
|
||||
}
|
||||
@@ -292,6 +298,16 @@ async function collectStreamResponse(stream: ReadableStream<string>, sessionId:
|
||||
} catch {
|
||||
// skip malformed status data
|
||||
}
|
||||
} else if (event.type === 'task_update') {
|
||||
// Sync SDK TodoWrite tasks to local DB
|
||||
try {
|
||||
const taskData = JSON.parse(event.data);
|
||||
if (taskData.session_id && taskData.todos) {
|
||||
syncSdkTasks(taskData.session_id, taskData.todos);
|
||||
}
|
||||
} catch {
|
||||
// skip malformed task_update data
|
||||
}
|
||||
} else if (event.type === 'result') {
|
||||
try {
|
||||
const resultData = JSON.parse(event.data);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getTasksBySession, createTask } from '@/lib/db';
|
||||
import { getTasksBySession, createTask, syncSdkTasks } from '@/lib/db';
|
||||
import type { TasksResponse, TaskResponse, ErrorResponse, CreateTaskRequest } from '@/types';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -44,3 +44,26 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Bulk sync SDK tasks (replace-all for source='sdk') */
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body: { session_id: string; todos: Array<{ id: string; content: string; status: string; activeForm?: string }> } = await request.json();
|
||||
|
||||
if (!body.session_id || !body.todos) {
|
||||
return NextResponse.json<ErrorResponse>(
|
||||
{ error: 'Missing session_id or todos' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
syncSdkTasks(body.session_id, body.todos);
|
||||
const tasks = getTasksBySession(body.session_id);
|
||||
return NextResponse.json<TasksResponse>({ tasks });
|
||||
} catch (error) {
|
||||
return NextResponse.json<ErrorResponse>(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to sync tasks' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -326,6 +326,8 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
markActive();
|
||||
setStreamingToolOutput('');
|
||||
setToolResults((prev) => {
|
||||
// Dedup: skip if already received for this tool_use_id
|
||||
if (prev.some(r => r.tool_use_id === res.tool_use_id)) return prev;
|
||||
const next = [...prev, res];
|
||||
toolResultsRef.current = next;
|
||||
return next;
|
||||
@@ -374,6 +376,11 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
const uiMode = sdkMode === 'plan' ? 'plan' : 'code';
|
||||
handleModeChange(uiMode);
|
||||
},
|
||||
onTaskUpdate: () => {
|
||||
markActive();
|
||||
// Notify TaskList to refresh its data
|
||||
window.dispatchEvent(new CustomEvent('tasks-updated'));
|
||||
},
|
||||
onError: (acc) => {
|
||||
markActive();
|
||||
accumulated = acc;
|
||||
|
||||
@@ -12,13 +12,15 @@ import {
|
||||
import { usePanel } from "@/hooks/usePanel";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
import { FileTree } from "@/components/project/FileTree";
|
||||
import { TaskList } from "@/components/project/TaskList";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface RightPanelProps {
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export function RightPanel({ width }: RightPanelProps) {
|
||||
const { panelOpen, setPanelOpen, workingDirectory, previewFile, setPreviewFile } = usePanel();
|
||||
const { panelOpen, setPanelOpen, panelContent, setPanelContent, workingDirectory, sessionId, previewFile, setPreviewFile } = usePanel();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleFileAdd = useCallback((path: string) => {
|
||||
@@ -71,7 +73,26 @@ export function RightPanel({ width }: RightPanelProps) {
|
||||
<aside className="hidden h-full shrink-0 flex-col overflow-hidden bg-background lg:flex" style={{ width: width ?? 288 }}>
|
||||
{/* Header */}
|
||||
<div className="flex h-12 mt-5 shrink-0 items-center justify-between px-4">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('panel.files')}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className={cn(
|
||||
"text-[11px] font-semibold uppercase tracking-wider px-2 py-1 rounded",
|
||||
panelContent === "files" ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setPanelContent("files")}
|
||||
>
|
||||
{t('panel.files')}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"text-[11px] font-semibold uppercase tracking-wider px-2 py-1 rounded",
|
||||
panelContent === "tasks" ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setPanelContent("tasks")}
|
||||
>
|
||||
{t('panel.tasks')}
|
||||
</button>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -89,11 +110,17 @@ export function RightPanel({ width }: RightPanelProps) {
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
<FileTree
|
||||
workingDirectory={workingDirectory}
|
||||
onFileSelect={handleFileSelect}
|
||||
onFileAdd={handleFileAdd}
|
||||
/>
|
||||
{panelContent === "files" ? (
|
||||
<FileTree
|
||||
workingDirectory={workingDirectory}
|
||||
onFileSelect={handleFileSelect}
|
||||
onFileAdd={handleFileAdd}
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 pt-1 h-full">
|
||||
<TaskList sessionId={sessionId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -44,6 +44,13 @@ export function TaskList({ sessionId }: TaskListProps) {
|
||||
fetchTasks();
|
||||
}, [fetchTasks]);
|
||||
|
||||
// Auto-refresh when SDK TodoWrite syncs tasks
|
||||
useEffect(() => {
|
||||
const handler = () => { fetchTasks(); };
|
||||
window.addEventListener('tasks-updated', handler);
|
||||
return () => window.removeEventListener('tasks-updated', handler);
|
||||
}, [fetchTasks]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const title = newTitle.trim();
|
||||
if (!title || !sessionId) return;
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface SSECallbacks {
|
||||
onPermissionRequest: (data: PermissionRequestEvent) => void;
|
||||
onToolTimeout: (toolName: string, elapsedSeconds: number) => void;
|
||||
onModeChanged: (mode: string) => void;
|
||||
onTaskUpdate: (sessionId: string) => void;
|
||||
onError: (accumulated: string) => void;
|
||||
}
|
||||
|
||||
@@ -135,6 +136,16 @@ function handleSSEEvent(
|
||||
return accumulated;
|
||||
}
|
||||
|
||||
case 'task_update': {
|
||||
try {
|
||||
const taskData = JSON.parse(event.data);
|
||||
callbacks.onTaskUpdate(taskData.session_id);
|
||||
} catch {
|
||||
// skip malformed task_update data
|
||||
}
|
||||
return accumulated;
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
const next = accumulated + '\n\n**Error:** ' + event.data;
|
||||
callbacks.onError(next);
|
||||
@@ -220,6 +231,7 @@ export function useSSEStream() {
|
||||
onPermissionRequest: (d) => callbacksRef.current?.onPermissionRequest(d),
|
||||
onToolTimeout: (n, s) => callbacksRef.current?.onToolTimeout(n, s),
|
||||
onModeChanged: (m) => callbacksRef.current?.onModeChanged(m),
|
||||
onTaskUpdate: (s) => callbacksRef.current?.onTaskUpdate(s),
|
||||
onError: (a) => callbacksRef.current?.onError(a),
|
||||
};
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ const en = {
|
||||
|
||||
// ── Right panel / Files ─────────────────────────────────────
|
||||
'panel.files': 'Files',
|
||||
'panel.tasks': 'Tasks',
|
||||
'panel.openPanel': 'Open panel',
|
||||
'panel.closePanel': 'Close panel',
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@ const zh: Record<TranslationKey, string> = {
|
||||
|
||||
// ── Right panel / Files ─────────────────────────────────────
|
||||
'panel.files': '文件',
|
||||
'panel.tasks': '任务',
|
||||
'panel.openPanel': '打开面板',
|
||||
'panel.closePanel': '关闭面板',
|
||||
|
||||
|
||||
+50
-10
@@ -451,6 +451,10 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
|
||||
queryOptions.resume = sdkSessionId;
|
||||
}
|
||||
|
||||
// Track emitted tool_result IDs to prevent duplicates
|
||||
// (PostToolUse hook and user message handler can both emit for the same tool_use_id)
|
||||
const emittedToolResultIds = new Set<string>();
|
||||
|
||||
// Permission handler: sends SSE event and waits for user response
|
||||
queryOptions.canUseTool = async (toolName, input, opts) => {
|
||||
const permissionRequestId = `perm-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -520,16 +524,49 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
|
||||
PostToolUse: [{
|
||||
hooks: [async (input) => {
|
||||
const toolEvent = input as PostToolUseHookInput;
|
||||
controller.enqueue(formatSSE({
|
||||
type: 'tool_result',
|
||||
data: JSON.stringify({
|
||||
tool_use_id: toolEvent.tool_use_id,
|
||||
content: typeof toolEvent.tool_response === 'string'
|
||||
? toolEvent.tool_response
|
||||
: JSON.stringify(toolEvent.tool_response),
|
||||
is_error: false,
|
||||
}),
|
||||
}));
|
||||
console.log('[claude-client] PostToolUse:', toolEvent.tool_name, 'id:', toolEvent.tool_use_id);
|
||||
// Dedup: only emit if not already seen
|
||||
if (!emittedToolResultIds.has(toolEvent.tool_use_id)) {
|
||||
emittedToolResultIds.add(toolEvent.tool_use_id);
|
||||
controller.enqueue(formatSSE({
|
||||
type: 'tool_result',
|
||||
data: JSON.stringify({
|
||||
tool_use_id: toolEvent.tool_use_id,
|
||||
content: typeof toolEvent.tool_response === 'string'
|
||||
? toolEvent.tool_response
|
||||
: JSON.stringify(toolEvent.tool_response),
|
||||
is_error: false,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
// Detect TodoWrite tool and emit task_update SSE for frontend sync
|
||||
if (toolEvent.tool_name === 'TodoWrite') {
|
||||
try {
|
||||
// SDK TodoWriteInput: { todos: { content, status, activeForm }[] }
|
||||
const toolInput = toolEvent.tool_input as {
|
||||
todos?: Array<{ content: string; status: string; activeForm?: string }>;
|
||||
};
|
||||
if (toolInput?.todos && Array.isArray(toolInput.todos)) {
|
||||
console.log('[claude-client] TodoWrite detected, syncing', toolInput.todos.length, 'tasks');
|
||||
controller.enqueue(formatSSE({
|
||||
type: 'task_update',
|
||||
data: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
todos: toolInput.todos.map((t, i) => ({
|
||||
id: String(i),
|
||||
content: t.content,
|
||||
status: t.status,
|
||||
activeForm: t.activeForm || '',
|
||||
})),
|
||||
}),
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[claude-client] Failed to parse TodoWrite input:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}],
|
||||
}],
|
||||
@@ -722,6 +759,9 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === 'tool_result') {
|
||||
// Dedup: skip if already emitted by PostToolUse hook
|
||||
if (emittedToolResultIds.has(block.tool_use_id)) continue;
|
||||
emittedToolResultIds.add(block.tool_use_id);
|
||||
const resultContent = typeof block.content === 'string'
|
||||
? block.content
|
||||
: Array.isArray(block.content)
|
||||
|
||||
+49
-2
@@ -292,6 +292,13 @@ function migrateDb(db: Database.Database): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_session_id ON tasks(session_id);
|
||||
`);
|
||||
|
||||
// Add source column to tasks table (user vs sdk)
|
||||
const taskColumns = db.prepare("PRAGMA table_info(tasks)").all() as { name: string }[];
|
||||
const taskColNames = taskColumns.map(c => c.name);
|
||||
if (!taskColNames.includes('source')) {
|
||||
db.exec("ALTER TABLE tasks ADD COLUMN source TEXT NOT NULL DEFAULT 'user'");
|
||||
}
|
||||
|
||||
// Ensure api_providers table exists for databases created before this migration
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS api_providers (
|
||||
@@ -724,8 +731,8 @@ export function createTask(sessionId: string, title: string, description?: strin
|
||||
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
|
||||
|
||||
db.prepare(
|
||||
'INSERT INTO tasks (id, session_id, title, status, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(id, sessionId, title, 'pending', description || null, now, now);
|
||||
'INSERT INTO tasks (id, session_id, title, status, description, source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(id, sessionId, title, 'pending', description || null, 'user', now, now);
|
||||
|
||||
return getTask(id)!;
|
||||
}
|
||||
@@ -753,6 +760,46 @@ export function deleteTask(id: string): boolean {
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync SDK tasks (from TodoWrite tool) into the tasks table.
|
||||
* Replace-all strategy: delete all source='sdk' tasks for this session,
|
||||
* then insert the new list. User-created tasks (source='user') are untouched.
|
||||
*/
|
||||
export function syncSdkTasks(
|
||||
sessionId: string,
|
||||
todos: Array<{ id: string; content: string; status: string; activeForm?: string }>
|
||||
): void {
|
||||
const db = getDb();
|
||||
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
|
||||
|
||||
// Map SDK status to local TaskStatus
|
||||
const mapStatus = (s: string): TaskStatus => {
|
||||
switch (s) {
|
||||
case 'completed': return 'completed';
|
||||
case 'in_progress': return 'in_progress';
|
||||
case 'pending': return 'pending';
|
||||
default: return 'pending';
|
||||
}
|
||||
};
|
||||
|
||||
console.log('[db] syncSdkTasks:', sessionId, 'todos count:', todos.length);
|
||||
|
||||
const txn = db.transaction(() => {
|
||||
// Delete all SDK-sourced tasks for this session
|
||||
db.prepare("DELETE FROM tasks WHERE session_id = ? AND source = 'sdk'").run(sessionId);
|
||||
|
||||
// Insert new SDK tasks
|
||||
const insert = db.prepare(
|
||||
'INSERT INTO tasks (id, session_id, title, status, description, source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
for (const todo of todos) {
|
||||
const taskId = `sdk-${sessionId.slice(0, 8)}-${todo.id}`;
|
||||
insert.run(taskId, sessionId, todo.content, mapStatus(todo.status), todo.activeForm || null, 'sdk', now, now);
|
||||
}
|
||||
});
|
||||
txn();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// API Provider Operations
|
||||
// ==========================================
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface TaskItem {
|
||||
title: string;
|
||||
status: TaskStatus;
|
||||
description: string | null;
|
||||
source: 'user' | 'sdk';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -326,6 +327,7 @@ export type SSEEventType =
|
||||
| 'error' // error occurred
|
||||
| 'permission_request' // permission approval needed
|
||||
| 'mode_changed' // SDK permission mode changed (e.g. plan → code)
|
||||
| 'task_update' // SDK TodoWrite task sync
|
||||
| 'done'; // stream complete
|
||||
|
||||
export interface SSEEvent {
|
||||
|
||||
Reference in new issue
Block a user