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:
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user