mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
a3e58064cc
commit
7183cc85fe
58
desktop/src/components/chat/InlineTaskSummary.tsx
Normal file
58
desktop/src/components/chat/InlineTaskSummary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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)]">
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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: [] })
|
||||
},
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user