fix: enable task/plan display for WebApp sessions using TodoWrite

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 <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-07 17:12:05 +08:00
parent a3e58064cc
commit 7183cc85fe
6 changed files with 180 additions and 19 deletions

View File

@ -0,0 +1,58 @@
import type { TaskSummaryItem } from '../../types/chat'
const statusIcon: Record<TaskSummaryItem['status'], string> = {
pending: 'radio_button_unchecked',
in_progress: 'pending',
completed: 'check_circle',
}
const statusColor: Record<TaskSummaryItem['status'], string> = {
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 (
<div className="mb-3 mx-auto max-w-[860px] rounded-[var(--radius-lg)] border border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-lowest)] overflow-hidden">
<div className="flex items-center gap-3 px-4 py-2 bg-[var(--color-surface-container)]">
<div className="flex items-center justify-center w-5 h-5 rounded-[var(--radius-md)] bg-[var(--color-success)]/10">
<span className="material-symbols-outlined text-[13px] text-[var(--color-success)]" style={{ fontVariationSettings: "'FILL' 1" }}>
task_alt
</span>
</div>
<span className="text-xs font-semibold text-[var(--color-text-primary)]">
Tasks completed
</span>
<span className="text-[10px] text-[var(--color-text-tertiary)] tabular-nums">
{completed}/{total}
</span>
</div>
<div className="px-4 py-2 flex flex-col gap-0.5">
{tasks.map((task) => (
<div key={task.id} className="flex items-center gap-2 py-1 px-1">
<span
className="material-symbols-outlined text-[14px] shrink-0"
style={{ color: statusColor[task.status], fontVariationSettings: "'FILL' 1" }}
>
{statusIcon[task.status]}
</span>
<span className="text-[10px] font-mono text-[var(--color-text-tertiary)]">
#{task.id}
</span>
<span className={`text-xs ${
task.status === 'completed'
? 'text-[var(--color-text-tertiary)] line-through'
: 'text-[var(--color-text-primary)]'
}`}>
{task.subject}
</span>
</div>
))}
</div>
</div>
)
}

View File

@ -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<UIMessage, { type: 'tool_use' }>
@ -186,6 +187,8 @@ function MessageBlock({
<strong>Error:</strong> {message.message}
</div>
)
case 'task_summary':
return <InlineTaskSummary tasks={message.tasks} />
case 'system':
return (
<div className="mb-3 text-center text-xs text-[var(--color-text-tertiary)]">

View File

@ -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<string>()
@ -136,20 +136,43 @@ export const useChatStore = create<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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
}

View File

@ -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<void>
/** Refresh tasks for the currently tracked session */
refreshTasks: () => Promise<void>
/** 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<CLITaskStore>((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: [] })
},

View File

@ -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 }

View File

@ -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',