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:
7418andClaude Opus 4.6 committed 2026-02-27 13:12:59 +08:00
1 parent 2a7dbd45c2
commit c13ff54638
15 files changed
+318 -325

No files matched your search

+4
View File
@@ -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
+90
View File
@@ -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`
+13 -297
View File
@@ -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
View File
@@ -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",
+23 -7
View File
@@ -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);
+24 -1
View File
@@ -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 }
);
}
}
+7
View File
@@ -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;
+34 -7
View File
@@ -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>
);
+7
View File
@@ -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;
+12
View File
@@ -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),
};
+1
View File
@@ -151,6 +151,7 @@ const en = {
// ── Right panel / Files ─────────────────────────────────────
'panel.files': 'Files',
'panel.tasks': 'Tasks',
'panel.openPanel': 'Open panel',
'panel.closePanel': 'Close panel',
+1
View File
@@ -148,6 +148,7 @@ const zh: Record<TranslationKey, string> = {
// ── Right panel / Files ─────────────────────────────────────
'panel.files': '文件',
'panel.tasks': '任务',
'panel.openPanel': '打开面板',
'panel.closePanel': '关闭面板',
+50 -10
View File
@@ -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
View File
@@ -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
// ==========================================
+2
View File
@@ -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 {