From 7183cc85fe2426243ef39aaa35070eb218bdac51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 7 Apr 2026 17:12:05 +0800 Subject: [PATCH] fix: enable task/plan display for WebApp sessions using TodoWrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI subprocess runs in --print (non-interactive) mode, which disabled V2 task tools (TaskCreate/TaskUpdate). Only TodoWrite was available, but the frontend only tracked V2 tool names for task refresh — so the Tasks bar never appeared for new WebApp sessions. Changes: - Set CLAUDE_CODE_ENABLE_TASKS=1 in CLI subprocess env to enable V2 tasks - Add TodoWrite to TASK_TOOL_NAMES for backward compat with V1 sessions - Parse TodoWrite input.todos directly into TaskBar state (no disk read) - Extract last TodoWrite from history on session load for V1 sessions - Inline completed task summary into message flow when user continues chat (sticky TaskBar converts to scrollable inline summary) Co-Authored-By: Claude Opus 4.6 --- .../src/components/chat/InlineTaskSummary.tsx | 58 ++++++++++ desktop/src/components/chat/MessageList.tsx | 3 + desktop/src/stores/chatStore.ts | 104 +++++++++++++++--- desktop/src/stores/cliTaskStore.ts | 24 ++++ desktop/src/types/chat.ts | 8 ++ src/server/services/conversationService.ts | 2 +- 6 files changed, 180 insertions(+), 19 deletions(-) create mode 100644 desktop/src/components/chat/InlineTaskSummary.tsx diff --git a/desktop/src/components/chat/InlineTaskSummary.tsx b/desktop/src/components/chat/InlineTaskSummary.tsx new file mode 100644 index 00000000..0d8f91e8 --- /dev/null +++ b/desktop/src/components/chat/InlineTaskSummary.tsx @@ -0,0 +1,58 @@ +import type { TaskSummaryItem } from '../../types/chat' + +const statusIcon: Record = { + pending: 'radio_button_unchecked', + in_progress: 'pending', + completed: 'check_circle', +} + +const statusColor: Record = { + pending: 'var(--color-text-tertiary)', + in_progress: 'var(--color-warning)', + completed: 'var(--color-success)', +} + +export function InlineTaskSummary({ tasks }: { tasks: TaskSummaryItem[] }) { + const completed = tasks.filter((t) => t.status === 'completed').length + const total = tasks.length + + return ( +
+
+
+ + task_alt + +
+ + Tasks completed + + + {completed}/{total} + +
+
+ {tasks.map((task) => ( +
+ + {statusIcon[task.status]} + + + #{task.id} + + + {task.subject} + +
+ ))} +
+
+ ) +} diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 43597075..53ed8444 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -9,6 +9,7 @@ import { ToolResultBlock } from './ToolResultBlock' import { PermissionDialog } from './PermissionDialog' import { AskUserQuestion } from './AskUserQuestion' import { StreamingIndicator } from './StreamingIndicator' +import { InlineTaskSummary } from './InlineTaskSummary' import type { UIMessage } from '../../types/chat' type ToolCall = Extract @@ -186,6 +187,8 @@ function MessageBlock({ Error: {message.message} ) + case 'task_summary': + return case 'system': return (
diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 94e0f734..a013837c 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -43,7 +43,7 @@ type ChatStore = { handleServerMessage: (msg: ServerMessage) => void } -const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList']) +const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite']) /** Track tool_use IDs for task-related tools, so we can refresh on tool_result */ const pendingTaskToolUseIds = new Set() @@ -136,20 +136,43 @@ export const useChatStore = create((set, get) => ({ })) : undefined - // Add user message to UI - set((s) => ({ - messages: [...s.messages, { + // If all tasks are completed, inline the task summary before the new user message + const taskStore = useCLITaskStore.getState() + const allTasksDone = taskStore.tasks.length > 0 && taskStore.tasks.every((t) => t.status === 'completed') + + // Add user message to UI (with optional task summary before it) + set((s) => { + const newMessages = [...s.messages] + if (allTasksDone) { + newMessages.push({ + id: nextId(), + type: 'task_summary', + tasks: taskStore.tasks.map((t) => ({ + id: t.id, + subject: t.subject, + status: t.status, + activeForm: t.activeForm, + })), + timestamp: Date.now(), + }) + // Clear sticky task bar since we inlined the summary + taskStore.clearTasks() + } + newMessages.push({ id: nextId(), type: 'user_text', content: userFacingContent, attachments: uiAttachments, timestamp: Date.now(), - }], - chatState: 'thinking', - elapsedSeconds: 0, - streamingText: '', - statusVerb: randomSpinnerVerb(), - })) + }) + return { + messages: newMessages, + chatState: 'thinking', + elapsedSeconds: 0, + streamingText: '', + statusVerb: randomSpinnerVerb(), + } + }) // Start elapsed timer if (elapsedTimer) clearInterval(elapsedTimer) @@ -187,6 +210,16 @@ export const useChatStore = create((set, get) => ({ } return { ...state, messages: uiMessages } }) + + // Extract the last TodoWrite input from history so TaskBar shows for V1 sessions + const lastTodos = extractLastTodoWriteFromHistory(messages) + if (lastTodos && lastTodos.length > 0) { + const taskStore = useCLITaskStore.getState() + // Only set if V2 task fetch didn't already populate tasks + if (taskStore.tasks.length === 0) { + taskStore.setTasksFromTodos(lastTodos) + } + } } catch { // Session may not have messages yet } @@ -256,21 +289,30 @@ export const useChatStore = create((set, get) => ({ break case 'thinking': - // Merge consecutive thinking deltas into one message + // Merge consecutive thinking deltas into one message. + // Also flush any pending streamingText first — otherwise the text + // becomes invisible because MessageList only renders streamingText + // when chatState === 'streaming', and we're about to set it to 'thinking'. set((s) => { - const last = s.messages[s.messages.length - 1] + const pendingText = s.streamingText.trim() + const base = pendingText + ? [...s.messages, { id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }] + : s.messages + + const last = base[base.length - 1] if (last && last.type === 'thinking') { // Append to existing thinking message - const updated = [...s.messages] + const updated = [...base] updated[updated.length - 1] = { ...last, content: last.content + msg.text } - return { messages: updated, chatState: 'thinking', activeThinkingId: last.id } + return { messages: updated, chatState: 'thinking', activeThinkingId: last.id, streamingText: '' } } const id = nextId() // Create new thinking message return { - messages: [...s.messages, { id, type: 'thinking', content: msg.text, timestamp: Date.now() }], + messages: [...base, { id, type: 'thinking', content: msg.text, timestamp: Date.now() }], chatState: 'thinking', activeThinkingId: id, + streamingText: '', } }) break @@ -291,9 +333,12 @@ export const useChatStore = create((set, get) => ({ activeThinkingId: null, streamingToolInput: '', })) - // Track task-related tool calls — refresh will happen on tool_result - // when the tool has actually finished executing and written to disk - if (TASK_TOOL_NAMES.has(toolName)) { + // TodoWrite: input contains the full todo list — update tasks immediately + if (toolName === 'TodoWrite' && Array.isArray((msg.input as any)?.todos)) { + useCLITaskStore.getState().setTasksFromTodos((msg.input as any).todos) + } else if (TASK_TOOL_NAMES.has(toolName)) { + // V2 task tools — refresh will happen on tool_result + // when the tool has actually finished executing and written to disk const useId = msg.toolUseId || get().activeToolUseId if (useId) pendingTaskToolUseIds.add(useId) } @@ -529,3 +574,26 @@ export function mapHistoryMessagesToUiMessages(messages: MessageEntry[]): UIMess return uiMessages } + +/** Scan history messages for the last TodoWrite tool_use and return the todos array */ +function extractLastTodoWriteFromHistory( + messages: MessageEntry[], +): Array<{ content: string; status: string; activeForm?: string }> | null { + // Walk backwards to find the most recent TodoWrite + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]! + if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) { + const blocks = msg.content as AssistantHistoryBlock[] + for (let j = blocks.length - 1; j >= 0; j--) { + const block = blocks[j]! + if (block.type === 'tool_use' && block.name === 'TodoWrite') { + const input = block.input as { todos?: unknown } | undefined + if (input && Array.isArray(input.todos)) { + return input.todos as Array<{ content: string; status: string; activeForm?: string }> + } + } + } + } + } + return null +} diff --git a/desktop/src/stores/cliTaskStore.ts b/desktop/src/stores/cliTaskStore.ts index a2cb41d5..dd8b50a0 100644 --- a/desktop/src/stores/cliTaskStore.ts +++ b/desktop/src/stores/cliTaskStore.ts @@ -2,6 +2,12 @@ import { create } from 'zustand' import { cliTasksApi } from '../api/cliTasks' import type { CLITask } from '../types/cliTask' +type TodoItem = { + content: string + status: string + activeForm?: string +} + type CLITaskStore = { /** Current session ID being tracked */ sessionId: string | null @@ -14,6 +20,8 @@ type CLITaskStore = { fetchSessionTasks: (sessionId: string) => Promise /** Refresh tasks for the currently tracked session */ refreshTasks: () => Promise + /** Update tasks from TodoWrite V1 tool input (in-memory, no disk read needed) */ + setTasksFromTodos: (todos: TodoItem[]) => void /** Clear task tracking state */ clearTasks: () => void /** Toggle expanded state */ @@ -54,6 +62,22 @@ export const useCLITaskStore = create((set, get) => ({ } }, + setTasksFromTodos: (todos) => { + const tasks: CLITask[] = todos.map((todo, index) => ({ + id: String(index + 1), + subject: todo.content, + description: '', + activeForm: todo.activeForm, + status: (['pending', 'in_progress', 'completed'].includes(todo.status) + ? todo.status + : 'pending') as CLITask['status'], + blocks: [], + blockedBy: [], + taskListId: get().sessionId || '', + })) + set({ tasks }) + }, + clearTasks: () => { set({ sessionId: null, tasks: [] }) }, diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts index cd08f269..8fb8d799 100644 --- a/desktop/src/types/chat.ts +++ b/desktop/src/types/chat.ts @@ -64,6 +64,13 @@ export type TeamMemberStatus = { // ─── UI Message model (rendered in MessageList) ─────────────────── +export type TaskSummaryItem = { + id: string + subject: string + status: 'pending' | 'in_progress' | 'completed' + activeForm?: string +} + export type UIMessage = | { id: string; type: 'user_text'; content: string; timestamp: number; attachments?: UIAttachment[] } | { id: string; type: 'assistant_text'; content: string; timestamp: number; model?: string } @@ -73,3 +80,4 @@ export type UIMessage = | { id: string; type: 'system'; content: string; timestamp: number } | { id: string; type: 'permission_request'; requestId: string; toolName: string; input: unknown; description?: string; timestamp: number } | { id: string; type: 'error'; message: string; code: string; timestamp: number } + | { id: string; type: 'task_summary'; tasks: TaskSummaryItem[]; timestamp: number } diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index ef8b5a42..f8719a0c 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -112,7 +112,7 @@ export class ConversationService { try { proc = Bun.spawn(args, { cwd: workDir, - env: { ...process.env }, + env: { ...process.env, CLAUDE_CODE_ENABLE_TASKS: '1' }, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe',