mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 22:33:08 +00:00
Fix agent output summary for pipeline steps (#812)
* Changes from fix/agent-output-summary-for-pipeline-steps * feat: Optimize pipeline summary extraction and fix regex vulnerability * fix: Use fallback summary for pipeline steps when extraction fails * fix: Strip follow-up session scaffold from pipeline step fallback summaries
This commit is contained in:
@@ -12,6 +12,7 @@ import { SummaryDialog } from './summary-dialog';
|
||||
import { getProviderIconForModel } from '@/components/ui/provider-icon';
|
||||
import { useFeature, useAgentOutput } from '@/hooks/queries';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { getFirstNonEmptySummary } from '@/lib/summary-selection';
|
||||
|
||||
/**
|
||||
* Formats thinking level for compact display
|
||||
@@ -67,6 +68,8 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
const [taskStatusMap, setTaskStatusMap] = useState<
|
||||
Map<string, 'pending' | 'in_progress' | 'completed'>
|
||||
>(new Map());
|
||||
// Track real-time task summary updates from WebSocket events
|
||||
const [taskSummaryMap, setTaskSummaryMap] = useState<Map<string, string>>(new Map());
|
||||
// Track last WebSocket event timestamp to know if we're receiving real-time updates
|
||||
const [lastWsEventTimestamp, setLastWsEventTimestamp] = useState<number | null>(null);
|
||||
|
||||
@@ -163,6 +166,11 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
return null;
|
||||
}, [contextContent, agentOutputContent]);
|
||||
|
||||
// Prefer freshly fetched feature summary over potentially stale list data.
|
||||
const effectiveSummary =
|
||||
getFirstNonEmptySummary(freshFeature?.summary, feature.summary, summary, agentInfo?.summary) ??
|
||||
undefined;
|
||||
|
||||
// Fresh planSpec data from API (more accurate than store data for task progress)
|
||||
const freshPlanSpec = useMemo(() => {
|
||||
if (!freshFeature?.planSpec) return null;
|
||||
@@ -197,11 +205,13 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
return {
|
||||
content: task.description,
|
||||
status: (finalStatus || 'completed') as 'pending' | 'in_progress' | 'completed',
|
||||
summary: task.summary,
|
||||
};
|
||||
}
|
||||
|
||||
// Use real-time status from WebSocket events if available
|
||||
const realtimeStatus = taskStatusMap.get(task.id);
|
||||
const realtimeSummary = taskSummaryMap.get(task.id);
|
||||
|
||||
// Calculate status: WebSocket status > index-based status > task.status
|
||||
let effectiveStatus: 'pending' | 'in_progress' | 'completed';
|
||||
@@ -224,6 +234,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
return {
|
||||
content: task.description,
|
||||
status: effectiveStatus,
|
||||
summary: realtimeSummary ?? task.summary,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -236,6 +247,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
feature.planSpec?.currentTaskId,
|
||||
agentInfo?.todos,
|
||||
taskStatusMap,
|
||||
taskSummaryMap,
|
||||
isFeatureFinished,
|
||||
]);
|
||||
|
||||
@@ -280,6 +292,19 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
newMap.set(taskEvent.taskId, 'completed');
|
||||
return newMap;
|
||||
});
|
||||
|
||||
if ('summary' in event) {
|
||||
setTaskSummaryMap((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
// Allow empty string (reset) or non-empty string to be set
|
||||
const summary =
|
||||
typeof event.summary === 'string' && event.summary.trim().length > 0
|
||||
? event.summary
|
||||
: null;
|
||||
newMap.set(taskEvent.taskId, summary);
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -331,7 +356,13 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
// OR if the feature is actively running (ensures panel stays visible during execution)
|
||||
// Note: hasPlanSpecTasks is already defined above and includes freshPlanSpec
|
||||
// (The backlog case was already handled above and returned early)
|
||||
if (agentInfo || hasPlanSpecTasks || effectiveTodos.length > 0 || isActivelyRunning) {
|
||||
if (
|
||||
agentInfo ||
|
||||
hasPlanSpecTasks ||
|
||||
effectiveTodos.length > 0 ||
|
||||
isActivelyRunning ||
|
||||
effectiveSummary
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 space-y-2 overflow-hidden">
|
||||
@@ -379,24 +410,31 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
>
|
||||
{(isTodosExpanded ? effectiveTodos : effectiveTodos.slice(0, 3)).map(
|
||||
(todo, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
|
||||
{todo.status === 'completed' ? (
|
||||
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
|
||||
) : todo.status === 'in_progress' ? (
|
||||
<Spinner size="xs" className="w-2.5 h-2.5 shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'break-words hyphens-auto line-clamp-2 leading-relaxed',
|
||||
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
|
||||
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
|
||||
todo.status === 'pending' && 'text-muted-foreground/80'
|
||||
<div key={idx} className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1.5 text-[10px]">
|
||||
{todo.status === 'completed' ? (
|
||||
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
|
||||
) : todo.status === 'in_progress' ? (
|
||||
<Spinner size="xs" className="w-2.5 h-2.5 shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
|
||||
)}
|
||||
>
|
||||
{todo.content}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'break-words hyphens-auto line-clamp-2 leading-relaxed',
|
||||
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
|
||||
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
|
||||
todo.status === 'pending' && 'text-muted-foreground/80'
|
||||
)}
|
||||
>
|
||||
{todo.content}
|
||||
</span>
|
||||
</div>
|
||||
{todo.summary && isTodosExpanded && (
|
||||
<div className="pl-4 text-[9px] text-muted-foreground/50 italic break-words line-clamp-2">
|
||||
{todo.summary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
@@ -417,10 +455,12 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary for waiting_approval and verified */}
|
||||
{(feature.status === 'waiting_approval' || feature.status === 'verified') && (
|
||||
<>
|
||||
{(feature.summary || summary || agentInfo?.summary) && (
|
||||
{/* Summary for waiting_approval, verified, and pipeline steps */}
|
||||
{(feature.status === 'waiting_approval' ||
|
||||
feature.status === 'verified' ||
|
||||
(typeof feature.status === 'string' && feature.status.startsWith('pipeline_'))) && (
|
||||
<div className="space-y-1.5">
|
||||
{effectiveSummary && (
|
||||
<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">
|
||||
@@ -446,37 +486,35 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{feature.summary || summary || agentInfo?.summary}
|
||||
{effectiveSummary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!feature.summary &&
|
||||
!summary &&
|
||||
!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">
|
||||
{!effectiveSummary && (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 ?? 0} tool calls
|
||||
</span>
|
||||
{effectiveTodos.length > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Wrench className="w-2.5 h-2.5" />
|
||||
{agentInfo?.toolCallCount ?? 0} tool calls
|
||||
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)]" />
|
||||
{effectiveTodos.filter((t) => t.status === 'completed').length} tasks done
|
||||
</span>
|
||||
{effectiveTodos.length > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)]" />
|
||||
{effectiveTodos.filter((t) => t.status === 'completed').length} tasks done
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* SummaryDialog must be rendered alongside the expand button */}
|
||||
<SummaryDialog
|
||||
feature={feature}
|
||||
agentInfo={agentInfo}
|
||||
summary={summary}
|
||||
summary={effectiveSummary}
|
||||
isOpen={isSummaryDialogOpen}
|
||||
onOpenChange={setIsSummaryDialogOpen}
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -488,9 +526,10 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
<SummaryDialog
|
||||
feature={feature}
|
||||
agentInfo={agentInfo}
|
||||
summary={summary}
|
||||
summary={effectiveSummary}
|
||||
isOpen={isSummaryDialogOpen}
|
||||
onOpenChange={setIsSummaryDialogOpen}
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
// @ts-nocheck - dialog state typing with feature summary extraction
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { AgentTaskInfo } from '@/lib/agent-context-parser';
|
||||
import { useMemo, useState, useRef, useEffect } from 'react';
|
||||
import type { Feature } from '@/store/app-store';
|
||||
import type { AgentTaskInfo } from '@/lib/agent-context-parser';
|
||||
import {
|
||||
parseAllPhaseSummaries,
|
||||
isAccumulatedSummary,
|
||||
type PhaseSummaryEntry,
|
||||
} from '@/lib/log-parser';
|
||||
import { getFirstNonEmptySummary } from '@/lib/summary-selection';
|
||||
import { useAgentOutput } from '@/hooks/queries';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -11,7 +18,10 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { LogViewer } from '@/components/ui/log-viewer';
|
||||
import { Sparkles, Layers, FileText, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SummaryDialogProps {
|
||||
feature: Feature;
|
||||
@@ -19,6 +29,118 @@ interface SummaryDialogProps {
|
||||
summary?: string;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
type ViewMode = 'summary' | 'output';
|
||||
|
||||
/**
|
||||
* Renders a single phase entry card with header and content.
|
||||
* Extracted for better separation of concerns and readability.
|
||||
*/
|
||||
function PhaseEntryCard({
|
||||
entry,
|
||||
index,
|
||||
totalPhases,
|
||||
hasMultiplePhases,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
entry: PhaseSummaryEntry;
|
||||
index: number;
|
||||
totalPhases: number;
|
||||
hasMultiplePhases: boolean;
|
||||
isActive?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (onClick && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'p-4 bg-card rounded-lg border border-border/50 transition-all',
|
||||
isActive && 'ring-2 ring-primary/50 border-primary/50',
|
||||
onClick && 'cursor-pointer'
|
||||
)}
|
||||
onClick={onClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
>
|
||||
{/* Phase header - styled to stand out */}
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/30">
|
||||
<span className="text-sm font-semibold text-primary">{entry.phaseName}</span>
|
||||
{hasMultiplePhases && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Step {index + 1} of {totalPhases}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Phase content */}
|
||||
<Markdown>{entry.content || 'No summary available'}</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step navigator component for multi-phase summaries
|
||||
*/
|
||||
function StepNavigator({
|
||||
phaseEntries,
|
||||
activeIndex,
|
||||
onIndexChange,
|
||||
}: {
|
||||
phaseEntries: PhaseSummaryEntry[];
|
||||
activeIndex: number;
|
||||
onIndexChange: (index: number) => void;
|
||||
}) {
|
||||
if (phaseEntries.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded-lg shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onIndexChange(Math.max(0, activeIndex - 1))}
|
||||
disabled={activeIndex === 0}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1 overflow-x-auto">
|
||||
{phaseEntries.map((entry, index) => (
|
||||
<button
|
||||
key={`step-nav-${index}`}
|
||||
onClick={() => onIndexChange(index)}
|
||||
className={cn(
|
||||
'px-2.5 py-1 rounded-md text-xs font-medium transition-all whitespace-nowrap',
|
||||
index === activeIndex
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{entry.phaseName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onIndexChange(Math.min(phaseEntries.length - 1, activeIndex + 1))}
|
||||
disabled={activeIndex === phaseEntries.length - 1}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SummaryDialog({
|
||||
@@ -27,7 +149,63 @@ export function SummaryDialog({
|
||||
summary,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
projectPath,
|
||||
}: SummaryDialogProps) {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('summary');
|
||||
const [activePhaseIndex, setActivePhaseIndex] = useState(0);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Prefer explicitly provided summary (can come from fresh per-feature query),
|
||||
// then fall back to feature/agent-info summaries.
|
||||
const rawSummary = getFirstNonEmptySummary(summary, feature.summary, agentInfo?.summary);
|
||||
|
||||
// Normalize null to undefined for parser helpers that expect string | undefined
|
||||
const normalizedSummary = rawSummary ?? undefined;
|
||||
|
||||
// Memoize the parsed phases to avoid re-parsing on every render
|
||||
const phaseEntries = useMemo(
|
||||
() => parseAllPhaseSummaries(normalizedSummary),
|
||||
[normalizedSummary]
|
||||
);
|
||||
|
||||
// Memoize the multi-phase check
|
||||
const hasMultiplePhases = useMemo(
|
||||
() => isAccumulatedSummary(normalizedSummary),
|
||||
[normalizedSummary]
|
||||
);
|
||||
|
||||
// Fetch agent output
|
||||
const { data: agentOutput = '', isLoading: isLoadingOutput } = useAgentOutput(
|
||||
projectPath || '',
|
||||
feature.id,
|
||||
{
|
||||
enabled: isOpen && !!projectPath && viewMode === 'output',
|
||||
}
|
||||
);
|
||||
|
||||
// Reset active phase index when summary changes
|
||||
useEffect(() => {
|
||||
setActivePhaseIndex(0);
|
||||
}, [normalizedSummary]);
|
||||
|
||||
// Scroll to active phase when it changes or when normalizedSummary changes
|
||||
useEffect(() => {
|
||||
if (contentRef.current && hasMultiplePhases) {
|
||||
const phaseCards = contentRef.current.querySelectorAll('[data-phase-index]');
|
||||
// Ensure index is within bounds
|
||||
const safeIndex = Math.min(activePhaseIndex, phaseCards.length - 1);
|
||||
const targetCard = phaseCards[safeIndex];
|
||||
if (targetCard) {
|
||||
targetCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
}, [activePhaseIndex, hasMultiplePhases, normalizedSummary]);
|
||||
|
||||
// Determine the dialog title based on number of phases
|
||||
const dialogTitle = hasMultiplePhases
|
||||
? `Pipeline Summary (${phaseEntries.length} steps)`
|
||||
: 'Implementation Summary';
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
@@ -38,10 +216,44 @@ export function SummaryDialog({
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-[var(--status-success)]" />
|
||||
Implementation Summary
|
||||
</DialogTitle>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pr-10">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{hasMultiplePhases ? (
|
||||
<Layers className="w-5 h-5 text-[var(--status-success)]" />
|
||||
) : (
|
||||
<Sparkles className="w-5 h-5 text-[var(--status-success)]" />
|
||||
)}
|
||||
{dialogTitle}
|
||||
</DialogTitle>
|
||||
|
||||
{/* View mode tabs */}
|
||||
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setViewMode('summary')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap',
|
||||
viewMode === 'summary'
|
||||
? 'bg-primary/20 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
Summary
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('output')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap',
|
||||
viewMode === 'output'
|
||||
? 'bg-primary/20 text-primary shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
Output
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogDescription
|
||||
className="text-sm"
|
||||
title={feature.description || feature.summary || ''}
|
||||
@@ -52,11 +264,55 @@ export function SummaryDialog({
|
||||
})()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto p-4 bg-card rounded-lg border border-border/50">
|
||||
<Markdown>
|
||||
{feature.summary || summary || agentInfo?.summary || 'No summary available'}
|
||||
</Markdown>
|
||||
</div>
|
||||
|
||||
{/* Step navigator for multi-phase summaries */}
|
||||
{viewMode === 'summary' && hasMultiplePhases && (
|
||||
<StepNavigator
|
||||
phaseEntries={phaseEntries}
|
||||
activeIndex={activePhaseIndex}
|
||||
onIndexChange={setActivePhaseIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content area */}
|
||||
{viewMode === 'summary' ? (
|
||||
<div ref={contentRef} className="flex-1 overflow-y-auto space-y-4">
|
||||
{phaseEntries.length > 0 ? (
|
||||
phaseEntries.map((entry, index) => (
|
||||
<div key={`phase-${index}-${entry.phaseName}`} data-phase-index={index}>
|
||||
<PhaseEntryCard
|
||||
entry={entry}
|
||||
index={index}
|
||||
totalPhases={phaseEntries.length}
|
||||
hasMultiplePhases={hasMultiplePhases}
|
||||
isActive={hasMultiplePhases && index === activePhaseIndex}
|
||||
onClick={hasMultiplePhases ? () => setActivePhaseIndex(index) : undefined}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-4 bg-card rounded-lg border border-border/50">
|
||||
<Markdown>No summary available</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto bg-popover border border-border/50 rounded-lg p-4 font-mono text-xs">
|
||||
{isLoadingOutput ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<Spinner size="lg" className="mr-2" />
|
||||
Loading output...
|
||||
</div>
|
||||
) : !agentOutput ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
No agent output available.
|
||||
</div>
|
||||
) : (
|
||||
<LogViewer output={agentOutput} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { List, FileText, GitBranch, ClipboardList } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { List, FileText, GitBranch, ClipboardList, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { LogViewer } from '@/components/ui/log-viewer';
|
||||
@@ -14,8 +15,15 @@ import { GitDiffPanel } from '@/components/ui/git-diff-panel';
|
||||
import { TaskProgressPanel } from '@/components/ui/task-progress-panel';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { extractSummary } from '@/lib/log-parser';
|
||||
import { useAgentOutput } from '@/hooks/queries';
|
||||
import {
|
||||
extractSummary,
|
||||
parseAllPhaseSummaries,
|
||||
isAccumulatedSummary,
|
||||
type PhaseSummaryEntry,
|
||||
} from '@/lib/log-parser';
|
||||
import { getFirstNonEmptySummary } from '@/lib/summary-selection';
|
||||
import { useAgentOutput, useFeature } from '@/hooks/queries';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AutoModeEvent } from '@/types/electron';
|
||||
import type { BacklogPlanEvent } from '@automaker/types';
|
||||
|
||||
@@ -36,6 +44,112 @@ interface AgentOutputModalProps {
|
||||
|
||||
type ViewMode = 'summary' | 'parsed' | 'raw' | 'changes';
|
||||
|
||||
/**
|
||||
* Renders a single phase entry card with header and content.
|
||||
*/
|
||||
function PhaseEntryCard({
|
||||
entry,
|
||||
index,
|
||||
totalPhases,
|
||||
hasMultiplePhases,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
entry: PhaseSummaryEntry;
|
||||
index: number;
|
||||
totalPhases: number;
|
||||
hasMultiplePhases: boolean;
|
||||
isActive?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (onClick && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'p-4 bg-card rounded-lg border border-border/50 transition-all',
|
||||
isActive && 'ring-2 ring-primary/50 border-primary/50',
|
||||
onClick && 'cursor-pointer'
|
||||
)}
|
||||
onClick={onClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/30">
|
||||
<span className="text-sm font-semibold text-primary">{entry.phaseName}</span>
|
||||
{hasMultiplePhases && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Step {index + 1} of {totalPhases}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Markdown>{entry.content || 'No summary available'}</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step navigator component for multi-phase summaries
|
||||
*/
|
||||
function StepNavigator({
|
||||
phaseEntries,
|
||||
activeIndex,
|
||||
onIndexChange,
|
||||
}: {
|
||||
phaseEntries: PhaseSummaryEntry[];
|
||||
activeIndex: number;
|
||||
onIndexChange: (index: number) => void;
|
||||
}) {
|
||||
if (phaseEntries.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded-lg shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onIndexChange(Math.max(0, activeIndex - 1))}
|
||||
disabled={activeIndex === 0}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1 overflow-x-auto">
|
||||
{phaseEntries.map((entry, index) => (
|
||||
<button
|
||||
key={`step-nav-${index}`}
|
||||
onClick={() => onIndexChange(index)}
|
||||
className={cn(
|
||||
'px-2.5 py-1 rounded-md text-xs font-medium transition-all whitespace-nowrap',
|
||||
index === activeIndex
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{entry.phaseName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onIndexChange(Math.min(phaseEntries.length - 1, activeIndex + 1))}
|
||||
disabled={activeIndex === phaseEntries.length - 1}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentOutputModal({
|
||||
open,
|
||||
onClose,
|
||||
@@ -56,10 +170,19 @@ export function AgentOutputModal({
|
||||
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
|
||||
|
||||
// Use React Query for initial output loading
|
||||
const { data: initialOutput = '', isLoading } = useAgentOutput(resolvedProjectPath, featureId, {
|
||||
const {
|
||||
data: initialOutput = '',
|
||||
isLoading,
|
||||
refetch: refetchAgentOutput,
|
||||
} = useAgentOutput(resolvedProjectPath, featureId, {
|
||||
enabled: open && !!resolvedProjectPath,
|
||||
});
|
||||
|
||||
// Fetch feature data to access the server-side accumulated summary
|
||||
const { data: feature, refetch: refetchFeature } = useFeature(resolvedProjectPath, featureId, {
|
||||
enabled: open && !!resolvedProjectPath && !isBacklogPlan,
|
||||
});
|
||||
|
||||
// Reset streamed content when modal opens or featureId changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -70,8 +193,31 @@ export function AgentOutputModal({
|
||||
// Combine initial output from query with streamed content from WebSocket
|
||||
const output = initialOutput + streamedContent;
|
||||
|
||||
// Extract summary from output
|
||||
const summary = useMemo(() => extractSummary(output), [output]);
|
||||
// Extract summary from output (client-side fallback)
|
||||
const extractedSummary = useMemo(() => extractSummary(output), [output]);
|
||||
|
||||
// Prefer server-side accumulated summary (handles pipeline step accumulation),
|
||||
// fall back to client-side extraction from raw output.
|
||||
const summary = getFirstNonEmptySummary(feature?.summary, extractedSummary);
|
||||
|
||||
// Normalize null to undefined for parser helpers that expect string | undefined
|
||||
const normalizedSummary = summary ?? undefined;
|
||||
|
||||
// Parse summary into phases for multi-step navigation
|
||||
const phaseEntries = useMemo(
|
||||
() => parseAllPhaseSummaries(normalizedSummary),
|
||||
[normalizedSummary]
|
||||
);
|
||||
const hasMultiplePhases = useMemo(
|
||||
() => isAccumulatedSummary(normalizedSummary),
|
||||
[normalizedSummary]
|
||||
);
|
||||
const [activePhaseIndex, setActivePhaseIndex] = useState(0);
|
||||
|
||||
// Reset active phase index when summary changes
|
||||
useEffect(() => {
|
||||
setActivePhaseIndex(0);
|
||||
}, [normalizedSummary]);
|
||||
|
||||
// Determine the effective view mode - default to summary if available, otherwise parsed
|
||||
const effectiveViewMode = viewMode ?? (summary ? 'summary' : 'parsed');
|
||||
@@ -79,6 +225,15 @@ export function AgentOutputModal({
|
||||
const autoScrollRef = useRef(true);
|
||||
const useWorktrees = useAppStore((state) => state.useWorktrees);
|
||||
|
||||
// Force a fresh fetch when opening to avoid showing stale cached summaries.
|
||||
useEffect(() => {
|
||||
if (!open || !resolvedProjectPath || !featureId) return;
|
||||
if (!isBacklogPlan) {
|
||||
void refetchFeature();
|
||||
}
|
||||
void refetchAgentOutput();
|
||||
}, [open, resolvedProjectPath, featureId, isBacklogPlan, refetchFeature, refetchAgentOutput]);
|
||||
|
||||
// Auto-scroll to bottom when output changes
|
||||
useEffect(() => {
|
||||
if (autoScrollRef.current && scrollRef.current) {
|
||||
@@ -86,6 +241,39 @@ export function AgentOutputModal({
|
||||
}
|
||||
}, [output]);
|
||||
|
||||
// Auto-scroll to bottom when summary changes (for pipeline step accumulation)
|
||||
const summaryScrollRef = useRef<HTMLDivElement>(null);
|
||||
const [summaryAutoScroll, setSummaryAutoScroll] = useState(true);
|
||||
|
||||
// Auto-scroll summary panel to bottom when summary is updated
|
||||
useEffect(() => {
|
||||
if (summaryAutoScroll && summaryScrollRef.current && normalizedSummary) {
|
||||
summaryScrollRef.current.scrollTop = summaryScrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [normalizedSummary, summaryAutoScroll]);
|
||||
|
||||
// Handle scroll to detect if user scrolled up in summary panel
|
||||
const handleSummaryScroll = () => {
|
||||
if (!summaryScrollRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = summaryScrollRef.current;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||
setSummaryAutoScroll(isAtBottom);
|
||||
};
|
||||
|
||||
// Scroll to active phase when it changes or when summary changes
|
||||
useEffect(() => {
|
||||
if (summaryScrollRef.current && hasMultiplePhases) {
|
||||
const phaseCards = summaryScrollRef.current.querySelectorAll('[data-phase-index]');
|
||||
// Ensure index is within bounds
|
||||
const safeIndex = Math.min(activePhaseIndex, phaseCards.length - 1);
|
||||
const targetCard = phaseCards[safeIndex];
|
||||
if (targetCard) {
|
||||
targetCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
}, [activePhaseIndex, hasMultiplePhases, normalizedSummary]);
|
||||
|
||||
// Listen to auto mode events and update output
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -420,9 +608,49 @@ export function AgentOutputModal({
|
||||
)}
|
||||
</div>
|
||||
) : effectiveViewMode === 'summary' && summary ? (
|
||||
<div className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto bg-card border border-border/50 rounded-lg p-4 scrollbar-visible">
|
||||
<Markdown>{summary}</Markdown>
|
||||
</div>
|
||||
<>
|
||||
{/* Step navigator for multi-phase summaries */}
|
||||
{hasMultiplePhases && (
|
||||
<StepNavigator
|
||||
phaseEntries={phaseEntries}
|
||||
activeIndex={activePhaseIndex}
|
||||
onIndexChange={setActivePhaseIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={summaryScrollRef}
|
||||
onScroll={handleSummaryScroll}
|
||||
className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible space-y-4 p-1"
|
||||
>
|
||||
{hasMultiplePhases ? (
|
||||
// Multi-phase: render individual phase cards
|
||||
phaseEntries.map((entry, index) => (
|
||||
<div key={`phase-${index}-${entry.phaseName}`} data-phase-index={index}>
|
||||
<PhaseEntryCard
|
||||
entry={entry}
|
||||
index={index}
|
||||
totalPhases={phaseEntries.length}
|
||||
hasMultiplePhases={hasMultiplePhases}
|
||||
isActive={index === activePhaseIndex}
|
||||
onClick={() => setActivePhaseIndex(index)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
// Single phase: render as markdown
|
||||
<div className="bg-card border border-border/50 rounded-lg p-4">
|
||||
<Markdown>{summary}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground text-center shrink-0">
|
||||
{summaryAutoScroll
|
||||
? 'Auto-scrolling enabled'
|
||||
: 'Scroll to bottom to enable auto-scroll'}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-nocheck - completed features filtering and grouping with status transitions
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -11,6 +10,8 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { ArchiveRestore, Trash2 } from 'lucide-react';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { extractImplementationSummary } from '@/lib/log-parser';
|
||||
import { getFirstNonEmptySummary } from '@/lib/summary-selection';
|
||||
|
||||
interface CompletedFeaturesModalProps {
|
||||
open: boolean;
|
||||
@@ -51,44 +52,54 @@ export function CompletedFeaturesModal({
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{completedFeatures.map((feature) => (
|
||||
<Card
|
||||
key={feature.id}
|
||||
className="flex flex-col"
|
||||
data-testid={`completed-card-${feature.id}`}
|
||||
>
|
||||
<CardHeader className="p-3 pb-2 flex-1">
|
||||
<CardTitle className="text-sm leading-tight line-clamp-3">
|
||||
{feature.description || feature.summary || feature.id}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-1 truncate">
|
||||
{feature.category || 'Uncategorized'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<div className="p-3 pt-0 flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs"
|
||||
onClick={() => onUnarchive(feature)}
|
||||
data-testid={`unarchive-${feature.id}`}
|
||||
>
|
||||
<ArchiveRestore className="w-3 h-3 mr-1" />
|
||||
Restore
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onDelete(feature)}
|
||||
data-testid={`delete-completed-${feature.id}`}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{completedFeatures.map((feature) => {
|
||||
const implementationSummary = extractImplementationSummary(feature.summary);
|
||||
const displayText = getFirstNonEmptySummary(
|
||||
implementationSummary,
|
||||
feature.summary,
|
||||
feature.description,
|
||||
feature.id
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={feature.id}
|
||||
className="flex flex-col"
|
||||
data-testid={`completed-card-${feature.id}`}
|
||||
>
|
||||
<CardHeader className="p-3 pb-2 flex-1">
|
||||
<CardTitle className="text-sm leading-tight line-clamp-3">
|
||||
{displayText ?? feature.id}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-1 truncate">
|
||||
{feature.category || 'Uncategorized'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<div className="p-3 pt-0 flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs"
|
||||
onClick={() => onUnarchive(feature)}
|
||||
data-testid={`unarchive-${feature.id}`}
|
||||
>
|
||||
<ArchiveRestore className="w-3 h-3 mr-1" />
|
||||
Restore
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onDelete(feature)}
|
||||
data-testid={`delete-completed-${feature.id}`}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user