cc-haha/desktop/src/stores/cliTaskStore.ts
程序员阿江(Relakkes) fd5dc097a7 fix: isolate desktop session streaming state
Desktop chat sessions can stream and reconnect concurrently, so shared in-process buffers must not be keyed only by process lifetime. This change scopes streaming delta buffers, task-tool refresh bookkeeping, and CLI task mutations to the session that produced the event. It also ignores stale client socket closes after a newer socket has replaced the active connection for the same session.

Constraint: Desktop users can keep multiple sessions and tabs active at the same time.
Rejected: Serialize all desktop streaming through one active session | would hide the race instead of preserving multi-session behavior.
Confidence: high
Scope-risk: moderate
Directive: Do not reintroduce process-global chat/task pending state without session keys and multi-session regression tests.
Tested: cd desktop && bun run test -- src/stores/chatStore.test.ts src/stores/cliTaskStore.test.ts
Tested: bun test src/server/__tests__/websocket-handler.test.ts
Tested: cd desktop && bun run lint
Tested: bun run check:desktop
Tested: bun run check:server
Tested: bun run check:coverage | changed-lines 100%, fails only existing agent-utils baseline
Tested: bun run verify | 8 passed, 1 failed on existing agent-utils coverage baseline, 2 skipped
Not-tested: Real desktop dual-window manual smoke.
Related: https://github.com/NanmiCoder/cc-haha/issues/302
Related: https://github.com/NanmiCoder/cc-haha/issues/303
2026-05-07 18:33:57 +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: false,
dismissedCompletionKey: null,
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 }))
},
}))