Merge remote-tracking branch 'upstream/v1.0.0rc' into patchcraft

This commit is contained in:
DhanushSantosh
2026-02-26 17:49:45 +05:30
37 changed files with 7164 additions and 163 deletions

View File

@@ -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}
/>
);
});

View File

@@ -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"

View File

@@ -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

View File

@@ -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>

View File

@@ -1246,8 +1246,236 @@ export function extractSummary(rawOutput: string): string | null {
}
/**
* Gets the color classes for a log entry type
* Parses an accumulated summary string into individual phase summaries.
*
* The accumulated summary format uses markdown headers with `###` for phase names
* and `---` as separators between phases:
*
* ```
* ### Implementation
*
* [content]
*
* ---
*
* ### Testing
*
* [content]
* ```
*
* @param summary - The accumulated summary string to parse
* @returns A map of phase names (lowercase) to their content, or empty map if not parseable
*/
const PHASE_SEPARATOR = '\n\n---\n\n';
const PHASE_SEPARATOR_REGEX = /\n\n---\n\n/;
const PHASE_HEADER_REGEX = /^###\s+(.+?)(?:\n|$)/;
const PHASE_HEADER_WITH_PREFIX_REGEX = /^(###\s+)(.+?)(?:\n|$)/;
function getPhaseSections(summary: string): {
sections: string[];
leadingImplementationSection: string | null;
} {
const sections = summary.split(PHASE_SEPARATOR_REGEX);
const hasSeparator = summary.includes(PHASE_SEPARATOR);
const hasAnyHeader = sections.some((section) => PHASE_HEADER_REGEX.test(section.trim()));
const firstSection = sections[0]?.trim() ?? '';
const leadingImplementationSection =
hasSeparator && hasAnyHeader && firstSection && !PHASE_HEADER_REGEX.test(firstSection)
? firstSection
: null;
return { sections, leadingImplementationSection };
}
export function parsePhaseSummaries(summary: string | undefined): Map<string, string> {
const phaseSummaries = new Map<string, string>();
if (!summary || !summary.trim()) {
return phaseSummaries;
}
const { sections, leadingImplementationSection } = getPhaseSections(summary);
// Backward compatibility for mixed format:
// [implementation summary without header] + --- + [### Pipeline Step ...]
// Treat the leading headerless section as "Implementation".
if (leadingImplementationSection) {
phaseSummaries.set('implementation', leadingImplementationSection);
}
for (const section of sections) {
// Match the phase header pattern: ### Phase Name
const headerMatch = section.match(PHASE_HEADER_REGEX);
if (headerMatch) {
const phaseName = headerMatch[1].trim().toLowerCase();
// Extract content after the header (skip the header line and leading newlines)
const content = section.substring(headerMatch[0].length).trim();
phaseSummaries.set(phaseName, content);
}
}
return phaseSummaries;
}
/**
* Extracts a specific phase summary from an accumulated summary string.
*
* @param summary - The accumulated summary string
* @param phaseName - The phase name to extract (case-insensitive, e.g., "Implementation", "implementation")
* @returns The content for the specified phase, or null if not found
*/
export function extractPhaseSummary(summary: string | undefined, phaseName: string): string | null {
const phaseSummaries = parsePhaseSummaries(summary);
const normalizedPhaseName = phaseName.toLowerCase();
return phaseSummaries.get(normalizedPhaseName) || null;
}
/**
* Gets the implementation phase summary from an accumulated summary string.
*
* This is a convenience function that handles various naming conventions:
* - "implementation"
* - "Implementation"
* - Any phase that contains "implement" in its name
*
* @param summary - The accumulated summary string
* @returns The implementation phase content, or null if not found
*/
export function extractImplementationSummary(summary: string | undefined): string | null {
if (!summary || !summary.trim()) {
return null;
}
const phaseSummaries = parsePhaseSummaries(summary);
// Try exact match first
const implementationContent = phaseSummaries.get('implementation');
if (implementationContent) {
return implementationContent;
}
// Fallback: find any phase containing "implement"
for (const [phaseName, content] of phaseSummaries) {
if (phaseName.includes('implement')) {
return content;
}
}
// If no phase summaries found, the summary might not be in accumulated format
// (legacy or non-pipeline feature). In this case, return the whole summary
// if it looks like a single summary (no phase headers).
if (!summary.includes('### ') && !summary.includes(PHASE_SEPARATOR)) {
return summary;
}
return null;
}
/**
* Checks if a summary string is in the accumulated multi-phase format.
*
* @param summary - The summary string to check
* @returns True if the summary has multiple phases, false otherwise
*/
export function isAccumulatedSummary(summary: string | undefined): boolean {
if (!summary || !summary.trim()) {
return false;
}
// Check for the presence of phase headers with separator
const hasMultiplePhases =
summary.includes(PHASE_SEPARATOR) && summary.match(/###\s+.+/g)?.length > 0;
return hasMultiplePhases;
}
/**
* Represents a single phase entry in an accumulated summary.
*/
export interface PhaseSummaryEntry {
/** The phase name (e.g., "Implementation", "Testing", "Code Review") */
phaseName: string;
/** The content of this phase's summary */
content: string;
/** The original header line (e.g., "### Implementation") */
header: string;
}
/** Default phase name used for non-accumulated summaries */
const DEFAULT_PHASE_NAME = 'Summary';
/**
* Parses an accumulated summary into individual phase entries.
* Returns phases in the order they appear in the summary.
*
* The accumulated summary format:
* ```
* ### Implementation
*
* [content]
*
* ---
*
* ### Testing
*
* [content]
* ```
*
* @param summary - The accumulated summary string to parse
* @returns Array of PhaseSummaryEntry objects, or empty array if not parseable
*/
export function parseAllPhaseSummaries(summary: string | undefined): PhaseSummaryEntry[] {
const entries: PhaseSummaryEntry[] = [];
if (!summary || !summary.trim()) {
return entries;
}
// Check if this is an accumulated summary (has phase headers at line starts)
// Use a more precise check: ### must be at the start of a line (not just anywhere in content)
const hasPhaseHeaders = /^###\s+/m.test(summary);
if (!hasPhaseHeaders) {
// Not an accumulated summary - return as single entry with generic name
return [
{ phaseName: DEFAULT_PHASE_NAME, content: summary, header: `### ${DEFAULT_PHASE_NAME}` },
];
}
const { sections, leadingImplementationSection } = getPhaseSections(summary);
// Backward compatibility for mixed format:
// [implementation summary without header] + --- + [### Pipeline Step ...]
if (leadingImplementationSection) {
entries.push({
phaseName: 'Implementation',
content: leadingImplementationSection,
header: '### Implementation',
});
}
for (const section of sections) {
// Match the phase header pattern: ### Phase Name
const headerMatch = section.match(PHASE_HEADER_WITH_PREFIX_REGEX);
if (headerMatch) {
const header = headerMatch[0].trim();
const phaseName = headerMatch[2].trim();
// Extract content after the header (skip the header line and leading newlines)
const content = section.substring(headerMatch[0].length).trim();
entries.push({ phaseName, content, header });
}
}
// Fallback: if we detected phase headers but couldn't parse any entries,
// treat the entire summary as a single entry to avoid showing "No summary available"
if (entries.length === 0) {
return [
{ phaseName: DEFAULT_PHASE_NAME, content: summary, header: `### ${DEFAULT_PHASE_NAME}` },
];
}
return entries;
}
export function getLogTypeColors(type: LogEntryType): {
bg: string;
border: string;

View File

@@ -0,0 +1,14 @@
export type SummaryValue = string | null | undefined;
/**
* Returns the first summary candidate that contains non-whitespace content.
* The original string is returned (without trimming) to preserve formatting.
*/
export function getFirstNonEmptySummary(...candidates: SummaryValue[]): string | null {
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim().length > 0) {
return candidate;
}
}
return null;
}