feat(tasks): round-based grouping — FAB shows current round only

- 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) <noreply@anthropic.com>
This commit is contained in:
kuannnn
2026-03-29 08:02:14 +08:00
parent a1ada37cba
commit b4d55c4de3
4 changed files with 99 additions and 9 deletions
+58 -5
View File
@@ -1,9 +1,9 @@
/** /**
* Pure aggregator: processes tool_use blocks (TaskCreate, TaskUpdate, TodoWrite) * 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. * A new "round" starts when all tasks were completed and a new TaskCreate arrives.
* Call getSnapshot() to read current state (cached, invalidated on changes). * The snapshot exposes both the current round (for FAB) and all tasks (for sheet history).
*/ */
export interface AggregatedTask { export interface AggregatedTask {
@@ -15,12 +15,22 @@ export interface AggregatedTask {
blockedBy?: string[]; blockedBy?: string[];
blocks?: string[]; blocks?: string[];
source: 'task-api' | 'todo-write'; source: 'task-api' | 'todo-write';
round: number;
} }
export interface TaskSnapshot { export interface TaskSnapshot {
/** All tasks across all rounds */
tasks: AggregatedTask[]; tasks: AggregatedTask[];
/** Current round tasks only (for FAB display) */
currentRound: AggregatedTask[];
/** Completed count in current round */
completed: number; completed: number;
/** Total count in current round */
total: number; 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. */ /** Tool names that produce task state changes. */
@@ -32,11 +42,19 @@ export class TaskAggregator {
private taskApiTasks = new Map<string, AggregatedTask>(); private taskApiTasks = new Map<string, AggregatedTask>();
private todoWriteTasks = new Map<string, AggregatedTask>(); private todoWriteTasks = new Map<string, AggregatedTask>();
private cachedSnapshot: TaskSnapshot | null = null; 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. */ /** Process a single tool_use block. Returns true if state changed. */
processToolUse(toolName: string, input: Record<string, unknown>, resultText?: string): boolean { processToolUse(toolName: string, input: Record<string, unknown>, resultText?: string): boolean {
switch (toolName) { switch (toolName) {
case 'TaskCreate': { 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 match = resultText?.match(/Task #(\d+)/);
const id = match?.[1] || String(this.taskApiTasks.size + 1); const id = match?.[1] || String(this.taskApiTasks.size + 1);
this.taskApiTasks.set(id, { this.taskApiTasks.set(id, {
@@ -46,6 +64,7 @@ export class TaskAggregator {
activeForm: (input.activeForm as string) || undefined, activeForm: (input.activeForm as string) || undefined,
status: 'pending', status: 'pending',
source: 'task-api', source: 'task-api',
round: this.currentRound,
}); });
this.cachedSnapshot = null; this.cachedSnapshot = null;
return true; return true;
@@ -59,6 +78,7 @@ export class TaskAggregator {
if (input.status === 'deleted') { if (input.status === 'deleted') {
this.taskApiTasks.delete(taskId); this.taskApiTasks.delete(taskId);
this.cachedSnapshot = null; this.cachedSnapshot = null;
this.updateCompletionState();
return true; return true;
} }
const updated: AggregatedTask = { ...existing }; const updated: AggregatedTask = { ...existing };
@@ -74,10 +94,16 @@ export class TaskAggregator {
} }
this.taskApiTasks.set(taskId, updated); this.taskApiTasks.set(taskId, updated);
this.cachedSnapshot = null; this.cachedSnapshot = null;
this.updateCompletionState();
return true; return true;
} }
case 'TodoWrite': { 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(); this.todoWriteTasks.clear();
const tasks = (input.tasks || input.todos || []) as Array<{ const tasks = (input.tasks || input.todos || []) as Array<{
id: string; id: string;
@@ -90,9 +116,11 @@ export class TaskAggregator {
subject: t.content || '', subject: t.content || '',
status: (t.status as AggregatedTask['status']) || 'pending', status: (t.status as AggregatedTask['status']) || 'pending',
source: 'todo-write', source: 'todo-write',
round: this.currentRound,
}); });
} }
this.cachedSnapshot = null; this.cachedSnapshot = null;
this.updateCompletionState();
return true; return true;
} }
@@ -107,8 +135,16 @@ export class TaskAggregator {
...this.taskApiTasks.values(), ...this.taskApiTasks.values(),
...this.todoWriteTasks.values(), ...this.todoWriteTasks.values(),
]; ];
const completed = tasks.filter(t => t.status === 'completed').length; const currentRound = tasks.filter(t => t.round === this.currentRound);
this.cachedSnapshot = { tasks, completed, total: tasks.length }; 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; return this.cachedSnapshot;
} }
@@ -120,5 +156,22 @@ export class TaskAggregator {
this.taskApiTasks.clear(); this.taskApiTasks.clear();
this.todoWriteTasks.clear(); this.todoWriteTasks.clear();
this.cachedSnapshot = null; 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();
} }
} }
+32 -2
View File
@@ -85,8 +85,28 @@ function TaskRow({ task, taskMap }: { task: AggregatedTask; taskMap: Map<string,
); );
} }
function HistorySection({ tasks, taskMap }: { tasks: AggregatedTask[]; taskMap: Map<string, AggregatedTask> }) {
const [expanded, setExpanded] = useState(false);
const completed = tasks.filter(t => t.status === 'completed').length;
return (
<div className="border-t border-border/30 mt-2 pt-2">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center justify-between w-full text-xs text-text-dim py-1"
>
<span>Previous tasks ({completed}/{tasks.length})</span>
{expanded ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
</button>
{expanded && tasks.map(task => (
<TaskRow key={`${task.source}:${task.id}`} task={task} taskMap={taskMap} />
))}
</div>
);
}
export function TaskBottomSheet({ snapshot, open, onClose }: TaskBottomSheetProps) { 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 pct = total > 0 ? Math.round((completed / total) * 100) : 0;
const taskMap = useMemo(() => { const taskMap = useMemo(() => {
@@ -95,6 +115,12 @@ export function TaskBottomSheet({ snapshot, open, onClose }: TaskBottomSheetProp
return map; return map;
}, [tasks]); }, [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 ( return (
<BottomSheet visible={open} onClose={onClose} className="max-h-[70vh] flex flex-col"> <BottomSheet visible={open} onClose={onClose} className="max-h-[70vh] flex flex-col">
<div className="flex items-center justify-between px-4 pb-2"> <div className="flex items-center justify-between px-4 pb-2">
@@ -107,13 +133,17 @@ export function TaskBottomSheet({ snapshot, open, onClose }: TaskBottomSheetProp
</div> </div>
<div className="flex-1 overflow-y-auto px-4 pb-4"> <div className="flex-1 overflow-y-auto px-4 pb-4">
{tasks.map(task => ( {currentRound.map(task => (
<TaskRow <TaskRow
key={`${task.source}:${task.id}`} key={`${task.source}:${task.id}`}
task={task} task={task}
taskMap={taskMap} taskMap={taskMap}
/> />
))} ))}
{historyTasks.length > 0 && (
<HistorySection tasks={historyTasks} taskMap={taskMap} />
)}
</div> </div>
</BottomSheet> </BottomSheet>
); );
+8 -1
View File
@@ -476,7 +476,14 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
break; break;
case WS.TASK_STATE: 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; break;
} }
}, [drainQueue, enterStreaming, handleTaskState, resetTasks]); }, [drainQueue, enterStreaming, handleTaskState, resetTasks]);
+1 -1
View File
@@ -3,7 +3,7 @@ import type { AggregatedTask, TaskSnapshot } from '../../server/stores/task-aggr
export type { AggregatedTask, TaskSnapshot }; 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() { export function useTaskState() {
const [taskSnapshot, setTaskSnapshot] = useState<TaskSnapshot>(EMPTY_SNAPSHOT); const [taskSnapshot, setTaskSnapshot] = useState<TaskSnapshot>(EMPTY_SNAPSHOT);