cc-haha/desktop/src/stores/cliTaskStore.ts
程序员阿江(Relakkes) ef818f8d8c fix(desktop): keep dismissed task progress hidden
Dismissed completed task lists are a user visibility choice for that completed cycle, so polling the same persisted tasks during the next chat turn should not resurrect the bar. The store now keeps the dismissed completion key while the remote reset is in flight, and the markdown renderer gives default lists enough inset so bullets stay inside message cards.

Constraint: Preserve future task cycles when new TodoWrite or task updates arrive.
Rejected: Clear only the visible task array | polling can rehydrate the same completed list before remote reset finishes
Confidence: high
Scope-risk: narrow
Tested: cd desktop && bun run test -- run src/stores/cliTaskStore.test.ts src/components/markdown/MarkdownRenderer.test.tsx
Tested: bun run check:desktop
Tested: git diff --check
Not-tested: Live Tauri chat session task creation and dismissal
2026-05-31 17:42:30 +08:00

198 lines
5.9 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, or a specific session if provided */
refreshTasks: (sessionId?: string) => Promise<void>
/** Update tasks from TodoWrite V1 tool input (in-memory, no disk read needed) */
setTasksFromTodos: (todos: TodoItem[], sessionId?: string) => void
/** Mark that completed tasks were already dismissed (conversation continued) */
markCompletedAndDismissed: (sessionId?: string) => void
/** Clear a completed task list locally and remotely so the next cycle starts clean */
resetCompletedTasks: (sessionId?: string) => Promise<void>
/** Clear task tracking state */
clearTasks: (sessionId?: string) => 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 (targetSessionId) => {
const sessionId = targetSessionId ?? get().sessionId
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, targetSessionId) => {
const sessionId = targetSessionId ?? get().sessionId
if (!sessionId || get().sessionId !== sessionId) return
const tasks = mapTodosToTasks(todos, sessionId)
set((state) => ({
tasks,
...resolveDismissState(tasks, state.dismissedCompletionKey),
}))
},
markCompletedAndDismissed: (targetSessionId) => {
const sessionId = targetSessionId ?? get().sessionId
if (!sessionId || get().sessionId !== sessionId) return
const completionKey = buildCompletedTaskKey(get().tasks)
if (!completionKey) return
set({
completedAndDismissed: true,
dismissedCompletionKey: completionKey,
expanded: false,
})
},
resetCompletedTasks: async (targetSessionId) => {
const sessionId = targetSessionId ?? get().sessionId
if (!sessionId || get().sessionId !== sessionId) return
const { tasks } = get()
const completionKey = buildCompletedTaskKey(tasks)
if (!completionKey) return
set({
tasks: [],
resetting: true,
completedAndDismissed: true,
dismissedCompletionKey: completionKey,
expanded: false,
})
try {
await cliTasksApi.resetTaskList(sessionId)
} finally {
if (get().sessionId === sessionId) {
set({ resetting: false })
}
}
},
clearTasks: (targetSessionId) => {
if (targetSessionId && get().sessionId !== targetSessionId) return
set({
sessionId: null,
tasks: [],
resetting: false,
completedAndDismissed: false,
dismissedCompletionKey: null,
expanded: false,
})
},
toggleExpanded: () => {
set((s) => ({ expanded: !s.expanded }))
},
}))