cc-haha/desktop/src/stores/cliTaskStore.ts
程序员阿江(Relakkes) 16d716e773 fix: keep completed task lists from leaking into the next turn
Completed desktop task bars were only being dismissed locally, which let the
persisted task list resurface on refresh or bleed into the next user turn.
This wires the existing server-side reset path into the desktop stores and
session flow so a finished task cycle is summarized once, cleared locally,
and removed remotely before the next round starts.

Constraint: Existing task persistence already lives behind the server task-list API and must stay compatible with persisted JSON task files
Rejected: Only hide the completed task bar in UI state | left stale persisted tasks behind and reintroduced them on reload
Rejected: Clear desktop state without a task summary | dropped useful completion context from the chat transcript
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep desktop task dismissal and task-list persistence behavior aligned; do not reintroduce local-only clearing without covering reload and next-turn flows
Tested: `cd desktop && bun run lint`
Tested: `cd desktop && bun run test src/stores/cliTaskStore.test.ts src/stores/chatStore.test.ts src/components/chat/SessionTaskBar.test.tsx src/components/chat/ComputerUsePermissionModal.test.tsx`
Tested: `bun test src/server/__tests__/e2e/business-flow.test.ts --test-name-pattern "Task Lists API"`
Not-tested: Full `bun test src/server/__tests__/e2e/business-flow.test.ts` suite still has unrelated pre-existing failures in Models and Sessions sections
2026-04-21 00:46:29 +08:00

191 lines
5.3 KiB
TypeScript

import { create } from 'zustand'
import { cliTasksApi } from '../api/cliTasks'
import type { CLITask, TaskStatus } from '../types/cliTask'
type TodoItem = {
content: string
status: string
activeForm?: string
}
type CLITaskStore = {
/** Current session ID being tracked */
sessionId: string | null
/** Tasks for the current session */
tasks: CLITask[]
/** True while the persisted task list is being cleared remotely */
resetting: boolean
/** Whether the task bar is expanded */
expanded: boolean
/** True when all tasks completed and the user already continued chatting.
* Set during history load so the sticky bar is suppressed on page refresh. */
completedAndDismissed: boolean
/** Snapshot of the completed task set that was dismissed */
dismissedCompletionKey: string | null
/** Fetch tasks for a given session (uses sessionId as taskListId) */
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
/** Mark that completed tasks were already dismissed (conversation continued) */
markCompletedAndDismissed: () => void
/** Clear a completed task list locally and remotely so the next cycle starts clean */
resetCompletedTasks: () => Promise<void>
/** Clear task tracking state */
clearTasks: () => void
/** Toggle expanded state */
toggleExpanded: () => void
}
function buildCompletedTaskKey(tasks: CLITask[]): string | null {
if (tasks.length === 0 || tasks.some((task) => task.status !== 'completed')) return null
return tasks
.map((task) => [
task.taskListId,
task.id,
task.subject,
task.status,
task.activeForm ?? '',
task.owner ?? '',
].join('::'))
.join('|')
}
function resolveDismissState(tasks: CLITask[], dismissedCompletionKey: string | null) {
const completionKey = buildCompletedTaskKey(tasks)
const keepDismissed = completionKey !== null && completionKey === dismissedCompletionKey
return {
completedAndDismissed: keepDismissed,
dismissedCompletionKey: keepDismissed ? completionKey : null,
}
}
function mapTodosToTasks(todos: TodoItem[], sessionId: string | null): CLITask[] {
return 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 TaskStatus,
blocks: [],
blockedBy: [],
taskListId: sessionId || '',
}))
}
export const useCLITaskStore = create<CLITaskStore>((set, get) => ({
sessionId: null,
tasks: [],
resetting: false,
expanded: false,
completedAndDismissed: false,
dismissedCompletionKey: null,
fetchSessionTasks: async (sessionId) => {
if (get().sessionId !== sessionId) {
set({
sessionId,
tasks: [],
resetting: false,
completedAndDismissed: false,
dismissedCompletionKey: null,
expanded: false,
})
}
try {
const { tasks } = await cliTasksApi.getTasksForList(sessionId)
// Only update if still tracking the same session
if (get().sessionId === sessionId && !get().resetting) {
set((state) => ({
tasks,
...resolveDismissState(tasks, state.dismissedCompletionKey),
}))
}
} catch {
// No tasks for this session — that's fine
if (get().sessionId === sessionId && !get().resetting) {
set({ tasks: [], completedAndDismissed: false, dismissedCompletionKey: null, expanded: false })
}
}
},
refreshTasks: async () => {
const { sessionId } = get()
if (!sessionId) return
try {
const { tasks } = await cliTasksApi.getTasksForList(sessionId)
if (get().sessionId === sessionId && !get().resetting) {
set((state) => ({
tasks,
...resolveDismissState(tasks, state.dismissedCompletionKey),
}))
}
} catch {
// ignore
}
},
setTasksFromTodos: (todos) => {
const tasks = mapTodosToTasks(todos, get().sessionId)
set((state) => ({
tasks,
...resolveDismissState(tasks, state.dismissedCompletionKey),
}))
},
markCompletedAndDismissed: () => {
const completionKey = buildCompletedTaskKey(get().tasks)
if (!completionKey) return
set({
completedAndDismissed: true,
dismissedCompletionKey: completionKey,
expanded: false,
})
},
resetCompletedTasks: async () => {
const { sessionId, tasks } = get()
const completionKey = buildCompletedTaskKey(tasks)
if (!sessionId || !completionKey) return
set({
tasks: [],
resetting: true,
completedAndDismissed: false,
dismissedCompletionKey: null,
expanded: false,
})
try {
await cliTasksApi.resetTaskList(sessionId)
} finally {
if (get().sessionId === sessionId) {
set({ resetting: false })
}
}
},
clearTasks: () => {
set({
sessionId: null,
tasks: [],
resetting: false,
completedAndDismissed: false,
dismissedCompletionKey: null,
expanded: false,
})
},
toggleExpanded: () => {
set((s) => ({ expanded: !s.expanded }))
},
}))