mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-24 00:13:07 +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:
@@ -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