feat: enhance agent info panel with real-time task status updates and fresh planSpec integration

- Added support for real-time task status updates using WebSocket events, allowing the Kanban card to reflect current task progress accurately.
- Introduced a new state for fresh planSpec data fetched from the API to ensure the agent info panel displays up-to-date task information.
- Updated the effectiveTodos calculation to prioritize fresh planSpec data and incorporate real-time status, improving task display accuracy.
- Enhanced the logic to listen for relevant WebSocket events and update task statuses accordingly, ensuring synchronization with the agent output modal.
This commit is contained in:
Shirone
2026-01-14 22:39:47 +01:00
parent 724858d215
commit f6bda66ed4

View File

@@ -10,6 +10,7 @@ import {
DEFAULT_MODEL,
} from '@/lib/agent-context-parser';
import { cn } from '@/lib/utils';
import type { AutoModeEvent } from '@/types/electron';
import {
Brain,
ListTodo,
@@ -71,22 +72,66 @@ export function AgentInfoPanel({
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
// Track real-time task status updates from WebSocket events
const [taskStatusMap, setTaskStatusMap] = useState<
Map<string, 'pending' | 'in_progress' | 'completed'>
>(new Map());
// Fresh planSpec data fetched from API (store data is stale for task progress)
const [freshPlanSpec, setFreshPlanSpec] = useState<{
tasks?: ParsedTask[];
tasksCompleted?: number;
currentTaskId?: string;
} | null>(null);
// Derive effective todos from planSpec.tasks when available, fallback to agentInfo.todos
// This fixes the issue where Kanban cards show "0/0 tasks" when the agent uses planSpec
// instead of emitting TodoWrite tool calls in the output log
// Uses freshPlanSpec (from API) for accurate progress, with taskStatusMap for real-time updates
const effectiveTodos = useMemo(() => {
// Use freshPlanSpec if available (fetched from API), fallback to store's feature.planSpec
const planSpec = freshPlanSpec?.tasks?.length ? freshPlanSpec : feature.planSpec;
// First priority: use planSpec.tasks if available (modern approach)
if (feature.planSpec?.tasks && feature.planSpec.tasks.length > 0) {
return feature.planSpec.tasks.map((task: ParsedTask) => ({
content: task.description,
// Map 'failed' status to 'pending' since todo display doesn't support 'failed'
status: task.status === 'failed' ? 'pending' : task.status,
}));
if (planSpec?.tasks && planSpec.tasks.length > 0) {
const completedCount = planSpec.tasksCompleted || 0;
const currentTaskId = planSpec.currentTaskId;
return planSpec.tasks.map((task: ParsedTask, index: number) => {
// Use real-time status from WebSocket events if available
const realtimeStatus = taskStatusMap.get(task.id);
// Calculate status: WebSocket status > index-based status > task.status
let effectiveStatus: 'pending' | 'in_progress' | 'completed';
if (realtimeStatus) {
effectiveStatus = realtimeStatus;
} else if (index < completedCount) {
effectiveStatus = 'completed';
} else if (task.id === currentTaskId) {
effectiveStatus = 'in_progress';
} else {
// Fallback to task.status if available, otherwise pending
effectiveStatus =
task.status === 'completed'
? 'completed'
: task.status === 'in_progress'
? 'in_progress'
: 'pending';
}
return {
content: task.description,
status: effectiveStatus,
};
});
}
// Fallback: use parsed agentInfo.todos from agent-output.md
return agentInfo?.todos || [];
}, [feature.planSpec?.tasks, agentInfo?.todos]);
}, [
freshPlanSpec,
feature.planSpec?.tasks,
feature.planSpec?.tasksCompleted,
feature.planSpec?.currentTaskId,
agentInfo?.todos,
taskStatusMap,
]);
useEffect(() => {
const loadContext = async () => {
@@ -98,6 +143,7 @@ export function AgentInfoPanel({
if (feature.status === 'backlog') {
setAgentInfo(null);
setFreshPlanSpec(null);
return;
}
@@ -107,6 +153,21 @@ export function AgentInfoPanel({
if (!currentProject?.path) return;
if (api.features) {
// Fetch fresh feature data to get up-to-date planSpec (store data is stale)
try {
const featureResult = await api.features.get(currentProject.path, feature.id);
const freshFeature: any = (featureResult as any).feature;
if (featureResult.success && freshFeature?.planSpec) {
setFreshPlanSpec({
tasks: freshFeature.planSpec.tasks,
tasksCompleted: freshFeature.planSpec.tasksCompleted || 0,
currentTaskId: freshFeature.planSpec.currentTaskId,
});
}
} catch {
// Ignore errors fetching fresh planSpec
}
const result = await api.features.getAgentOutput(currentProject.path, feature.id);
if (result.success && result.content) {
@@ -129,13 +190,62 @@ export function AgentInfoPanel({
loadContext();
if (isCurrentAutoTask) {
// Poll for updates when feature is in_progress (not just isCurrentAutoTask)
// This ensures planSpec progress stays in sync
if (isCurrentAutoTask || feature.status === 'in_progress') {
const interval = setInterval(loadContext, 3000);
return () => {
clearInterval(interval);
};
}
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
// Listen to WebSocket events for real-time task status updates
// This ensures the Kanban card shows the same progress as the Agent Output modal
// Listen for ANY in-progress feature with planSpec tasks, not just isCurrentAutoTask
const hasPlanSpecTasks =
(freshPlanSpec?.tasks?.length ?? 0) > 0 || (feature.planSpec?.tasks?.length ?? 0) > 0;
const shouldListenToEvents = feature.status === 'in_progress' && hasPlanSpecTasks;
useEffect(() => {
if (!shouldListenToEvents) return;
const api = getElectronAPI();
if (!api?.autoMode) return;
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
// Only handle events for this feature
if (!('featureId' in event) || event.featureId !== feature.id) return;
switch (event.type) {
case 'auto_mode_task_started':
if ('taskId' in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_started' }>;
setTaskStatusMap((prev) => {
const newMap = new Map(prev);
// Mark current task as in_progress
newMap.set(taskEvent.taskId, 'in_progress');
return newMap;
});
}
break;
case 'auto_mode_task_complete':
if ('taskId' in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_complete' }>;
setTaskStatusMap((prev) => {
const newMap = new Map(prev);
newMap.set(taskEvent.taskId, 'completed');
return newMap;
});
}
break;
}
});
return unsubscribe;
}, [feature.id, shouldListenToEvents]);
// Model/Preset Info for Backlog Cards
if (feature.status === 'backlog') {
const provider = getProviderFromModel(feature.model);
@@ -174,7 +284,9 @@ export function AgentInfoPanel({
}
// Agent Info Panel for non-backlog cards
if (feature.status !== 'backlog' && agentInfo) {
// Show panel if we have agentInfo OR planSpec.tasks (for spec/full mode)
// Note: hasPlanSpecTasks is already defined above and includes freshPlanSpec
if (feature.status !== 'backlog' && (agentInfo || hasPlanSpecTasks)) {
return (
<>
<div className="mb-3 space-y-2 overflow-hidden">
@@ -187,7 +299,7 @@ export function AgentInfoPanel({
})()}
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
{agentInfo.currentPhase && (
{agentInfo?.currentPhase && (
<div
className={cn(
'px-1.5 py-0.5 rounded-md text-[10px] font-medium',
@@ -263,7 +375,7 @@ export function AgentInfoPanel({
{/* Summary for waiting_approval and verified */}
{(feature.status === 'waiting_approval' || feature.status === 'verified') && (
<>
{(feature.summary || summary || agentInfo.summary) && (
{(feature.summary || summary || agentInfo?.summary) && (
<div className="space-y-1.5 pt-2 border-t border-border/30 overflow-hidden">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1 text-[10px] text-[var(--status-success)] min-w-0">
@@ -289,18 +401,18 @@ export function AgentInfoPanel({
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{feature.summary || summary || agentInfo.summary}
{feature.summary || summary || agentInfo?.summary}
</p>
</div>
)}
{!feature.summary &&
!summary &&
!agentInfo.summary &&
agentInfo.toolCallCount > 0 && (
!agentInfo?.summary &&
(agentInfo?.toolCallCount ?? 0) > 0 && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
<span className="flex items-center gap-1">
<Wrench className="w-2.5 h-2.5" />
{agentInfo.toolCallCount} tool calls
{agentInfo?.toolCallCount ?? 0} tool calls
</span>
{effectiveTodos.length > 0 && (
<span className="flex items-center gap-1">