From c13ff546382697ab90a4a8814a7ac1fb54a48a80 Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Fri, 27 Feb 2026 13:12:59 +0800 Subject: [PATCH] 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 --- CLAUDE.md | 4 + docs/agent-tooling-todo-bridge.md | 90 ++++++++ package-lock.json | 310 ++------------------------- package.json | 2 +- src/app/api/chat/route.ts | 30 ++- src/app/api/tasks/route.ts | 25 ++- src/components/chat/ChatView.tsx | 7 + src/components/layout/RightPanel.tsx | 41 +++- src/components/project/TaskList.tsx | 7 + src/hooks/useSSEStream.ts | 12 ++ src/i18n/en.ts | 1 + src/i18n/zh.ts | 1 + src/lib/claude-client.ts | 60 +++++- src/lib/db.ts | 51 ++++- src/types/index.ts | 2 + 15 files changed, 318 insertions(+), 325 deletions(-) create mode 100644 docs/agent-tooling-todo-bridge.md diff --git a/CLAUDE.md b/CLAUDE.md index 757f184..b48ab7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/agent-tooling-todo-bridge.md b/docs/agent-tooling-todo-bridge.md new file mode 100644 index 0000000..1470299 --- /dev/null +++ b/docs/agent-tooling-todo-bridge.md @@ -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` diff --git a/package-lock.json b/package-lock.json index 509eb9d..6cac693 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9d49cce..1eaadab 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 8b4ea43..fb6ac85 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -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, 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(); try { while (true) { @@ -270,12 +272,16 @@ async function collectStreamResponse(stream: ReadableStream, 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, 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); diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 5248bea..e0d8ace 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -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( + { error: 'Missing session_id or todos' }, + { status: 400 } + ); + } + + syncSdkTasks(body.session_id, body.todos); + const tasks = getTasksBySession(body.session_id); + return NextResponse.json({ tasks }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to sync tasks' }, + { status: 500 } + ); + } +} diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 85d361a..97c0c76 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -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; diff --git a/src/components/layout/RightPanel.tsx b/src/components/layout/RightPanel.tsx index d62bfa2..351d511 100644 --- a/src/components/layout/RightPanel.tsx +++ b/src/components/layout/RightPanel.tsx @@ -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) { ); diff --git a/src/components/project/TaskList.tsx b/src/components/project/TaskList.tsx index 8ff7acf..f44c4c4 100644 --- a/src/components/project/TaskList.tsx +++ b/src/components/project/TaskList.tsx @@ -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; diff --git a/src/hooks/useSSEStream.ts b/src/hooks/useSSEStream.ts index 417967e..d955dd2 100644 --- a/src/hooks/useSSEStream.ts +++ b/src/hooks/useSSEStream.ts @@ -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), }; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 1e909d7..f359553 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -151,6 +151,7 @@ const en = { // ── Right panel / Files ───────────────────────────────────── 'panel.files': 'Files', + 'panel.tasks': 'Tasks', 'panel.openPanel': 'Open panel', 'panel.closePanel': 'Close panel', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index c352f55..f3d943c 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -148,6 +148,7 @@ const zh: Record = { // ── Right panel / Files ───────────────────────────────────── 'panel.files': '文件', + 'panel.tasks': '任务', 'panel.openPanel': '打开面板', 'panel.closePanel': '关闭面板', diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index 988e716..8e78987 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -451,6 +451,10 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream(); + // 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 { 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 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 // ========================================== diff --git a/src/types/index.ts b/src/types/index.ts index 577ce41..746cd86 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 {