From b4d55c4de3e8344c1635271e16b23976572430dc Mon Sep 17 00:00:00 2001 From: kuannnn Date: Sun, 29 Mar 2026 08:02:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(tasks):=20round-based=20grouping=20?= =?UTF-8?q?=E2=80=94=20FAB=20shows=20current=20round=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New round starts when all tasks complete before next TaskCreate - FAB counts only current round (e.g. 0/4 instead of 20/24) - Bottom sheet: current round on top, collapsible "Previous tasks" history - O(1) Set lookup for history filtering Co-Authored-By: Claude Opus 4.6 (1M context) --- server/stores/task-aggregator.ts | 63 +++++++++++++++++++++++++++--- src/components/TaskBottomSheet.tsx | 34 +++++++++++++++- src/hooks/useChat.ts | 9 ++++- src/hooks/useTaskState.ts | 2 +- 4 files changed, 99 insertions(+), 9 deletions(-) diff --git a/server/stores/task-aggregator.ts b/server/stores/task-aggregator.ts index 83c7bbb..8dd7f34 100644 --- a/server/stores/task-aggregator.ts +++ b/server/stores/task-aggregator.ts @@ -1,9 +1,9 @@ /** * Pure aggregator: processes tool_use blocks (TaskCreate, TaskUpdate, TodoWrite) - * and produces a unified AggregatedTask[] snapshot. + * and produces a unified AggregatedTask[] snapshot with round-based grouping. * - * Stateful — call processToolUse() for each relevant tool call in order. - * Call getSnapshot() to read current state (cached, invalidated on changes). + * A new "round" starts when all tasks were completed and a new TaskCreate arrives. + * The snapshot exposes both the current round (for FAB) and all tasks (for sheet history). */ export interface AggregatedTask { @@ -15,12 +15,22 @@ export interface AggregatedTask { blockedBy?: string[]; blocks?: string[]; source: 'task-api' | 'todo-write'; + round: number; } export interface TaskSnapshot { + /** All tasks across all rounds */ tasks: AggregatedTask[]; + /** Current round tasks only (for FAB display) */ + currentRound: AggregatedTask[]; + /** Completed count in current round */ completed: number; + /** Total count in current round */ total: number; + /** Current round number (0-based) */ + round: number; + /** Whether there are previous rounds (for sheet "history" section) */ + hasHistory: boolean; } /** Tool names that produce task state changes. */ @@ -32,11 +42,19 @@ export class TaskAggregator { private taskApiTasks = new Map(); private todoWriteTasks = new Map(); private cachedSnapshot: TaskSnapshot | null = null; + private currentRound = 0; + private allCompletedBeforeCreate = true; // tracks if all tasks were done before last TaskCreate /** Process a single tool_use block. Returns true if state changed. */ processToolUse(toolName: string, input: Record, resultText?: string): boolean { switch (toolName) { case 'TaskCreate': { + // Start a new round if all previous tasks were completed + if (this.taskApiTasks.size > 0 && this.allCompletedBeforeCreate) { + this.currentRound++; + } + this.allCompletedBeforeCreate = false; + const match = resultText?.match(/Task #(\d+)/); const id = match?.[1] || String(this.taskApiTasks.size + 1); this.taskApiTasks.set(id, { @@ -46,6 +64,7 @@ export class TaskAggregator { activeForm: (input.activeForm as string) || undefined, status: 'pending', source: 'task-api', + round: this.currentRound, }); this.cachedSnapshot = null; return true; @@ -59,6 +78,7 @@ export class TaskAggregator { if (input.status === 'deleted') { this.taskApiTasks.delete(taskId); this.cachedSnapshot = null; + this.updateCompletionState(); return true; } const updated: AggregatedTask = { ...existing }; @@ -74,10 +94,16 @@ export class TaskAggregator { } this.taskApiTasks.set(taskId, updated); this.cachedSnapshot = null; + this.updateCompletionState(); return true; } case 'TodoWrite': { + // TodoWrite always starts a new round (replaces entire todo list) + if (this.todoWriteTasks.size > 0 || this.taskApiTasks.size > 0) { + const allDone = this.allTasksCompleted(); + if (allDone) this.currentRound++; + } this.todoWriteTasks.clear(); const tasks = (input.tasks || input.todos || []) as Array<{ id: string; @@ -90,9 +116,11 @@ export class TaskAggregator { subject: t.content || '', status: (t.status as AggregatedTask['status']) || 'pending', source: 'todo-write', + round: this.currentRound, }); } this.cachedSnapshot = null; + this.updateCompletionState(); return true; } @@ -107,8 +135,16 @@ export class TaskAggregator { ...this.taskApiTasks.values(), ...this.todoWriteTasks.values(), ]; - const completed = tasks.filter(t => t.status === 'completed').length; - this.cachedSnapshot = { tasks, completed, total: tasks.length }; + const currentRound = tasks.filter(t => t.round === this.currentRound); + const completed = currentRound.filter(t => t.status === 'completed').length; + this.cachedSnapshot = { + tasks, + currentRound, + completed, + total: currentRound.length, + round: this.currentRound, + hasHistory: this.currentRound > 0, + }; return this.cachedSnapshot; } @@ -120,5 +156,22 @@ export class TaskAggregator { this.taskApiTasks.clear(); this.todoWriteTasks.clear(); this.cachedSnapshot = null; + this.currentRound = 0; + this.allCompletedBeforeCreate = true; + } + + private allTasksCompleted(): boolean { + for (const t of this.taskApiTasks.values()) { + if (t.status !== 'completed') return false; + } + for (const t of this.todoWriteTasks.values()) { + if (t.status !== 'completed') return false; + } + return true; + } + + /** Update the flag that tracks whether all tasks are done (for round detection). */ + private updateCompletionState(): void { + this.allCompletedBeforeCreate = this.allTasksCompleted(); } } diff --git a/src/components/TaskBottomSheet.tsx b/src/components/TaskBottomSheet.tsx index 9e25e21..e04e673 100644 --- a/src/components/TaskBottomSheet.tsx +++ b/src/components/TaskBottomSheet.tsx @@ -85,8 +85,28 @@ function TaskRow({ task, taskMap }: { task: AggregatedTask; taskMap: Map }) { + const [expanded, setExpanded] = useState(false); + const completed = tasks.filter(t => t.status === 'completed').length; + + return ( +
+ + {expanded && tasks.map(task => ( + + ))} +
+ ); +} + export function TaskBottomSheet({ snapshot, open, onClose }: TaskBottomSheetProps) { - const { tasks, completed, total } = snapshot; + const { tasks, currentRound, completed, total, hasHistory } = snapshot; const pct = total > 0 ? Math.round((completed / total) * 100) : 0; const taskMap = useMemo(() => { @@ -95,6 +115,12 @@ export function TaskBottomSheet({ snapshot, open, onClose }: TaskBottomSheetProp return map; }, [tasks]); + const historyTasks = useMemo(() => { + if (!hasHistory) return []; + const currentIds = new Set(currentRound.map(t => `${t.source}:${t.id}`)); + return tasks.filter(t => !currentIds.has(`${t.source}:${t.id}`)); + }, [tasks, currentRound, hasHistory]); + return (
@@ -107,13 +133,17 @@ export function TaskBottomSheet({ snapshot, open, onClose }: TaskBottomSheetProp
- {tasks.map(task => ( + {currentRound.map(task => ( ))} + + {historyTasks.length > 0 && ( + + )}
); diff --git a/src/hooks/useChat.ts b/src/hooks/useChat.ts index 550b069..16d052e 100644 --- a/src/hooks/useChat.ts +++ b/src/hooks/useChat.ts @@ -476,7 +476,14 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter? break; case WS.TASK_STATE: - handleTaskState({ tasks: msg.tasks, completed: msg.completed, total: msg.total }); + handleTaskState({ + tasks: msg.tasks, + currentRound: msg.currentRound, + completed: msg.completed, + total: msg.total, + round: msg.round, + hasHistory: msg.hasHistory, + }); break; } }, [drainQueue, enterStreaming, handleTaskState, resetTasks]); diff --git a/src/hooks/useTaskState.ts b/src/hooks/useTaskState.ts index fd0d633..569c947 100644 --- a/src/hooks/useTaskState.ts +++ b/src/hooks/useTaskState.ts @@ -3,7 +3,7 @@ import type { AggregatedTask, TaskSnapshot } from '../../server/stores/task-aggr export type { AggregatedTask, TaskSnapshot }; -const EMPTY_SNAPSHOT: TaskSnapshot = { tasks: [], completed: 0, total: 0 }; +const EMPTY_SNAPSHOT: TaskSnapshot = { tasks: [], currentRound: [], completed: 0, total: 0, round: 0, hasHistory: false }; export function useTaskState() { const [taskSnapshot, setTaskSnapshot] = useState(EMPTY_SNAPSHOT);