From ed006cd1391625386ca3dc8358cf1cd4491c60c4 Mon Sep 17 00:00:00 2001 From: kevin rajan Date: Fri, 26 Dec 2025 19:54:34 -0600 Subject: [PATCH 01/11] feat(subtask-actions): add intelligent action summarization UI Implement cognitively-optimized expandable view for subtasks that intelligently surfaces the 5 most relevant agent actions from logs containing 1000+ actions. Add comprehensive action scoring algorithm with tests to create a middle layer between raw logs and user interface for better information clarity. --- .../task-detail/SubtaskActionList.tsx | 489 +++++++ .../task-detail/TaskDetailModal.tsx | 6 +- .../components/task-detail/TaskSubtasks.tsx | 362 ++++- .../lib/__tests__/actionScoring.test.ts | 1188 +++++++++++++++++ .../src/renderer/lib/actionScoring.ts | 829 ++++++++++++ 5 files changed, 2803 insertions(+), 71 deletions(-) create mode 100644 apps/frontend/src/renderer/components/task-detail/SubtaskActionList.tsx create mode 100644 apps/frontend/src/renderer/lib/__tests__/actionScoring.test.ts create mode 100644 apps/frontend/src/renderer/lib/actionScoring.ts diff --git a/apps/frontend/src/renderer/components/task-detail/SubtaskActionList.tsx b/apps/frontend/src/renderer/components/task-detail/SubtaskActionList.tsx new file mode 100644 index 0000000000..7972e2c5c5 --- /dev/null +++ b/apps/frontend/src/renderer/components/task-detail/SubtaskActionList.tsx @@ -0,0 +1,489 @@ +import { useState, useMemo } from 'react'; +import { + ChevronDown, + ChevronRight, + FileText, + Search, + FolderSearch, + Pencil, + FileCode, + Terminal, + Wrench, + CheckCircle2, + XCircle, + Info, + AlertTriangle, + Sparkles, + Layers, + FileEdit, + Eye, + FolderOpen, +} from 'lucide-react'; +import { Badge } from '../ui/badge'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../ui/collapsible'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; +import { cn } from '../../lib/utils'; +import type { ScoredAction } from '../../lib/actionScoring'; +import { groupActionsBySubphase, getScoreReason } from '../../lib/actionScoring'; + +interface SubtaskActionListProps { + /** Scored actions to display (already filtered to top N) */ + actions: ScoredAction[]; + /** Maximum number of actions to show (for display purposes) */ + maxActions?: number; + /** Whether to show subphase grouping headers */ + showSubphaseGrouping?: boolean; + /** Optional callback when "View all logs" is clicked */ + onViewAllLogs?: () => void; + /** Files that were modified (edited/written) during this subtask */ + modifiedFiles?: string[]; + /** Files that were read (but not modified) during this subtask */ + readFiles?: string[]; +} + +/** + * Get tool info including icon, label, and color styling + * Mirrors the pattern from TaskLogs.tsx + */ +function getToolInfo(toolName: string) { + switch (toolName?.toLowerCase()) { + case 'read': + return { icon: FileText, label: 'Reading', color: 'text-blue-500 bg-blue-500/10' }; + case 'glob': + return { icon: FolderSearch, label: 'Searching files', color: 'text-amber-500 bg-amber-500/10' }; + case 'grep': + return { icon: Search, label: 'Searching code', color: 'text-green-500 bg-green-500/10' }; + case 'edit': + return { icon: Pencil, label: 'Editing', color: 'text-purple-500 bg-purple-500/10' }; + case 'write': + return { icon: FileCode, label: 'Writing', color: 'text-cyan-500 bg-cyan-500/10' }; + case 'bash': + return { icon: Terminal, label: 'Running', color: 'text-orange-500 bg-orange-500/10' }; + default: + return { icon: Wrench, label: toolName || 'Action', color: 'text-muted-foreground bg-muted' }; + } +} + +/** + * Format timestamp into a human-readable time + */ +function formatTime(timestamp: string): string { + try { + const date = new Date(timestamp); + return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + } catch { + return ''; + } +} + +/** + * Get entry type styling + */ +function getEntryTypeStyles(type: string): { bgColor: string; textColor: string; borderColor: string; Icon: typeof XCircle | null } { + switch (type) { + case 'error': + return { bgColor: 'bg-destructive/10', textColor: 'text-destructive', borderColor: 'border-destructive/30', Icon: XCircle }; + case 'success': + return { bgColor: 'bg-success/10', textColor: 'text-success', borderColor: 'border-success/30', Icon: CheckCircle2 }; + case 'info': + return { bgColor: 'bg-info/10', textColor: 'text-info', borderColor: 'border-info/30', Icon: Info }; + default: + return { bgColor: 'bg-secondary/30', textColor: 'text-muted-foreground', borderColor: 'border-border/50', Icon: null }; + } +} + +/** + * Individual scored action item + */ +interface ScoredActionItemProps { + scoredAction: ScoredAction; + showScore?: boolean; +} + +function ScoredActionItem({ scoredAction, showScore = true }: ScoredActionItemProps) { + const [isExpanded, setIsExpanded] = useState(false); + const { action, score, scoreBreakdown } = scoredAction; + const hasDetail = Boolean(action.detail); + const entryStyles = getEntryTypeStyles(action.type); + + // Determine if this is a tool action or a regular entry + const isToolAction = action.type === 'tool_start' || action.type === 'tool_end'; + const toolInfo = action.tool_name ? getToolInfo(action.tool_name) : null; + + // Render tool action + if (isToolAction && toolInfo) { + const { icon: ToolIcon, label, color } = toolInfo; + const isStart = action.type === 'tool_start'; + + return ( +
+
+
+ + {label} + {isStart && action.tool_input && ( + + {action.tool_input} + + )} + {!isStart && ( + + )} +
+ {showScore && score > 0 && ( + + )} + {hasDetail && ( + + )} +
+ {hasDetail && isExpanded && ( +
+
+              {action.detail}
+            
+
+ )} +
+ ); + } + + // Render typed entry (error, success, info, text) + const { bgColor, textColor, borderColor, Icon } = entryStyles; + + return ( +
+
+ {Icon && } + + {formatTime(action.timestamp)} + + {action.content} + {action.subphase && ( + + {action.subphase} + + )} + {showScore && score > 0 && ( + + )} + {hasDetail && ( + + )} +
+ {hasDetail && isExpanded && ( +
+
+            {action.detail}
+          
+
+ )} +
+ ); +} + +/** + * Score badge with tooltip showing breakdown + */ +interface ScoreBadgeProps { + score: number; + scoreBreakdown: { + error: number; + decision: number; + fileChange: number; + timeAnomaly: number; + novelty: number; + }; +} + +function ScoreBadge({ score, scoreBreakdown }: ScoreBadgeProps) { + const reason = getScoreReason({ score, scoreBreakdown } as ScoredAction); + + return ( + + + = 40 ? 'border-destructive/50 text-destructive bg-destructive/5' : + score >= 25 ? 'border-amber-500/50 text-amber-500 bg-amber-500/5' : + 'border-info/50 text-info bg-info/5' + )} + > + + {score} + + + +
+

Relevance Score: {score}

+

{reason}

+
+
+
+ ); +} + +/** + * Subphase group header + */ +interface SubphaseGroupProps { + subphase: string; + actions: ScoredAction[]; + defaultExpanded?: boolean; +} + +function SubphaseGroup({ subphase, actions, defaultExpanded = true }: SubphaseGroupProps) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + + return ( + + + + + +
+ {actions.map((scoredAction, idx) => ( + + ))} +
+
+
+ ); +} + +/** + * Get filename from a path + */ +function getFilenameFromPath(path: string): string { + const parts = path.split('/'); + return parts[parts.length - 1] || path; +} + +/** + * Files Section Component + * Displays modified and read files in a compact format + */ +interface FilesSectionProps { + modifiedFiles?: string[]; + readFiles?: string[]; +} + +function FilesSection({ modifiedFiles = [], readFiles = [] }: FilesSectionProps) { + const hasFiles = modifiedFiles.length > 0 || readFiles.length > 0; + + if (!hasFiles) { + return null; + } + + return ( +
+
+ + Files Touched +
+
+ {/* Modified Files */} + {modifiedFiles.length > 0 && ( +
+
+ + Modified +
+
+ {modifiedFiles.map((file) => ( + + + + + {getFilenameFromPath(file)} + + + + {file} + + + ))} +
+
+ )} + {/* Read Files */} + {readFiles.length > 0 && ( +
+
+ + Read +
+
+ {readFiles.map((file) => ( + + + + + {getFilenameFromPath(file)} + + + + {file} + + + ))} +
+
+ )} +
+
+ ); +} + +/** + * SubtaskActionList Component + * + * Displays the top N most relevant actions for a subtask, grouped by subphase. + * Uses cognitive science-based scoring to highlight the most important actions. + */ +export function SubtaskActionList({ + actions, + maxActions = 5, + showSubphaseGrouping = true, + onViewAllLogs, + modifiedFiles, + readFiles, +}: SubtaskActionListProps) { + // Limit to maxActions and group by subphase + const displayActions = useMemo(() => { + return actions.slice(0, maxActions); + }, [actions, maxActions]); + + const groupedActions = useMemo(() => { + if (!showSubphaseGrouping) return null; + return groupActionsBySubphase(displayActions); + }, [displayActions, showSubphaseGrouping]); + + // Empty state + if (actions.length === 0) { + return ( +
+ +

No actions recorded

+
+ ); + } + + return ( +
+ {/* Files Section - shows modified and read files */} + + + {/* Header with action count */} +
+ + + Top {displayActions.length} of {actions.length} actions + + {onViewAllLogs && ( + + )} +
+ + {/* Actions list */} +
+ {showSubphaseGrouping && groupedActions ? ( + // Grouped by subphase + Array.from(groupedActions.entries()).map(([subphase, subphaseActions]) => ( + + )) + ) : ( + // Flat list + displayActions.map((scoredAction, idx) => ( + + )) + )} +
+ + {/* Show warning if many actions were filtered */} + {actions.length > 100 && ( +
+ + Showing top {displayActions.length} most relevant from {actions.length} total actions +
+ )} +
+ ); +} + +export default SubtaskActionList; diff --git a/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx b/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx index 6113454dd2..6907985275 100644 --- a/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx +++ b/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx @@ -423,7 +423,11 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals, {/* Subtasks Tab */} - + state.setActiveTab('logs')} + /> {/* Logs Tab */} diff --git a/apps/frontend/src/renderer/components/task-detail/TaskSubtasks.tsx b/apps/frontend/src/renderer/components/task-detail/TaskSubtasks.tsx index b7355c39cf..9c308b45bb 100644 --- a/apps/frontend/src/renderer/components/task-detail/TaskSubtasks.tsx +++ b/apps/frontend/src/renderer/components/task-detail/TaskSubtasks.tsx @@ -1,12 +1,23 @@ -import { CheckCircle2, Clock, XCircle, AlertCircle, ListChecks, FileCode } from 'lucide-react'; +import { useState, useMemo, useCallback } from 'react'; +import { CheckCircle2, Clock, XCircle, AlertCircle, ListChecks, FileCode, ChevronDown, ChevronRight, Sparkles, ArrowUpDown, ArrowDownWideNarrow, ListOrdered } from 'lucide-react'; import { Badge } from '../ui/badge'; import { ScrollArea } from '../ui/scroll-area'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../ui/collapsible'; import { cn, calculateProgress } from '../../lib/utils'; -import type { Task } from '../../../shared/types'; +import { filterTopActions, calculateSubtaskRelevanceScores, sortSubtasksByRelevance, getImportantFiles } from '../../lib/actionScoring'; +import { SubtaskActionList } from './SubtaskActionList'; +import type { Task, TaskLogs, TaskLogEntry } from '../../../shared/types'; + +/** Sort mode for subtasks */ +type SortMode = 'default' | 'relevance'; interface TaskSubtasksProps { task: Task; + /** Phase logs containing actions - used to show top 5 actions per subtask */ + phaseLogs?: TaskLogs | null; + /** Optional callback when "View all logs" is clicked from a subtask */ + onViewAllLogs?: () => void; } function getSubtaskStatusIcon(status: string) { @@ -22,9 +33,91 @@ function getSubtaskStatusIcon(status: string) { } } -export function TaskSubtasks({ task }: TaskSubtasksProps) { +/** + * Extract all log entries from phase logs into a flat array + */ +function getAllEntriesFromPhaseLogs(phaseLogs: TaskLogs): TaskLogEntry[] { + const entries: TaskLogEntry[] = []; + + // Collect entries from all phases + for (const phase of ['planning', 'coding', 'validation'] as const) { + const phaseLog = phaseLogs.phases[phase]; + if (phaseLog && phaseLog.entries) { + entries.push(...phaseLog.entries); + } + } + + return entries; +} + +/** + * Get action count for a subtask from phase logs + */ +function getSubtaskActionCount(phaseLogs: TaskLogs | null | undefined, subtaskId: string): number { + if (!phaseLogs) return 0; + + const allEntries = getAllEntriesFromPhaseLogs(phaseLogs); + return allEntries.filter(entry => entry.subtask_id === subtaskId).length; +} + +export function TaskSubtasks({ task, phaseLogs, onViewAllLogs }: TaskSubtasksProps) { const progress = calculateProgress(task.subtasks); + // Track which subtasks are expanded (store by subtask id) + const [expandedSubtasks, setExpandedSubtasks] = useState>(new Set()); + + // Sort mode for subtasks + const [sortMode, setSortMode] = useState('default'); + + // Toggle expansion state for a subtask + const toggleSubtask = useCallback((subtaskId: string) => { + setExpandedSubtasks(prev => { + const next = new Set(prev); + if (next.has(subtaskId)) { + next.delete(subtaskId); + } else { + next.add(subtaskId); + } + return next; + }); + }, []); + + // Toggle sort mode + const toggleSortMode = useCallback(() => { + setSortMode(prev => prev === 'default' ? 'relevance' : 'default'); + }, []); + + // Memoize all entries from phase logs + const allEntries = useMemo(() => { + if (!phaseLogs) return []; + return getAllEntriesFromPhaseLogs(phaseLogs); + }, [phaseLogs]); + + // Memoize scored actions per subtask (only compute when expanded) + const getSubtaskActions = useCallback((subtaskId: string) => { + if (allEntries.length === 0) return []; + return filterTopActions(allEntries, 5, subtaskId); + }, [allEntries]); + + // Calculate relevance scores for all subtasks (memoized) + const relevanceScores = useMemo(() => { + if (allEntries.length === 0) return new Map(); + const subtaskIds = task.subtasks.map(s => s.id); + return calculateSubtaskRelevanceScores(allEntries, subtaskIds); + }, [allEntries, task.subtasks]); + + // Get sorted subtasks based on sort mode + const sortedSubtasks = useMemo(() => { + if (sortMode === 'default') { + return task.subtasks; + } + // Sort by relevance + const subtaskIds = task.subtasks.map(s => s.id); + const sortedIds = sortSubtasksByRelevance(subtaskIds, relevanceScores); + // Map sorted IDs back to subtask objects + return sortedIds.map(id => task.subtasks.find(s => s.id === id)!).filter(Boolean); + }, [task.subtasks, sortMode, relevanceScores]); + return (
@@ -38,81 +131,210 @@ export function TaskSubtasks({ task }: TaskSubtasksProps) {
) : ( <> - {/* Progress summary */} + {/* Progress summary with sort toggle */}
{task.subtasks.filter(c => c.status === 'completed').length} of {task.subtasks.length} completed - {progress}% -
- {task.subtasks.map((subtask, index) => ( -
+ {/* Sort toggle button */} + {allEntries.length > 0 && ( + + + + + +

+ {sortMode === 'relevance' + ? 'Sorted by relevance (errors and key actions first)' + : 'Click to sort by relevance score'} +

+
+
)} - > -
- {getSubtaskStatusIcon(subtask.status)} -
-
- - #{index + 1} - - - - - {subtask.id} - - - -

{subtask.id}

-
-
-
- - -

- {subtask.description} -

-
- {subtask.description && subtask.description.length > 80 && ( - -

{subtask.description}

-
- )} -
- {subtask.files && subtask.files.length > 0 && ( -
- {subtask.files.map((file) => ( - + {progress}% +
+
+ {sortedSubtasks.map((subtask, index) => { + const isExpanded = expandedSubtasks.has(subtask.id); + const actionCount = getSubtaskActionCount(phaseLogs, subtask.id); + const hasActions = actionCount > 0; + // Only compute scored actions when expanded (memoized via useCallback) + const scoredActions = isExpanded ? getSubtaskActions(subtask.id) : []; + // Get files touched during this subtask (only when expanded) + const importantFiles = isExpanded && hasActions + ? getImportantFiles(allEntries, subtask.id, 5) + : { modified: [], read: [] }; + // Get relevance score for this subtask + const relevanceScore = relevanceScores.get(subtask.id); + // Original index (1-based) for display + const originalIndex = task.subtasks.findIndex(s => s.id === subtask.id) + 1; + + return ( + toggleSubtask(subtask.id)} + > +
+ {/* Subtask Header - always visible */} +
+
+ {getSubtaskStatusIcon(subtask.status)} +
+
+ + #{originalIndex} + + {/* Show relevance score when sorting by relevance */} + {sortMode === 'relevance' && relevanceScore && relevanceScore.totalScore > 0 && ( + + + + + {Math.round(relevanceScore.totalScore)} + + + +
+

Relevance Score: {Math.round(relevanceScore.totalScore)}

+

+ {relevanceScore.actionCount} actions • + Avg: {Math.round(relevanceScore.averageScore)} • + Top: {Math.round(relevanceScore.topScore)} +

+ {relevanceScore.hasErrors && ( +

Contains errors

+ )} +
+
+
+ )} + + + + {subtask.id} + + + +

{subtask.id}

+
+
+
+ - - - {file.split('/').pop()} - +

+ {subtask.description} +

- - {file} - + {subtask.description && subtask.description.length > 80 && ( + +

{subtask.description}

+
+ )}
- ))} + {subtask.files && subtask.files.length > 0 && ( +
+ {subtask.files.map((file) => ( + + + + + {file.split('/').pop()} + + + + {file} + + + ))} +
+ )} +
+ + {/* Expand/Collapse trigger - only show when there are actions */} + {hasActions && ( + + + + )}
- )} +
+ + {/* Expandable Action Section */} + +
+
+ +
+
+
-
-
- ))} + + ); + })} )} diff --git a/apps/frontend/src/renderer/lib/__tests__/actionScoring.test.ts b/apps/frontend/src/renderer/lib/__tests__/actionScoring.test.ts new file mode 100644 index 0000000000..61c1552f30 --- /dev/null +++ b/apps/frontend/src/renderer/lib/__tests__/actionScoring.test.ts @@ -0,0 +1,1188 @@ +/** + * Unit tests for Action Scoring Algorithm + * + * Tests weighted scoring algorithm for intelligent subtask action summarization. + * Verifies: + * - Scoring weights (Error=40, Warning=20, Decision=25, FileChange=20, TimeAnomaly=10, Novelty=5) + * - Top 5 filtering functionality + * - Edge cases (empty arrays, fewer than 5 actions, missing metadata) + * - Tiebreaker logic (earlier actions win when scores equal) + * - Performance (<100ms for 1000 actions) + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + SCORING_WEIGHTS, + DEFAULT_TOP_N, + scoreAction, + filterTopActions, + groupActionsBySubphase, + getScoreReason, + isErrorAction, + isWarningAction, + isDecisionAction, + isFileChangeAction, + isNovelty, + countFilesChanged, + calculateSubtaskRelevance, + calculateSubtaskRelevanceScores, + sortSubtasksByRelevance, + extractFilesFromAction, + extractFilesFromActions, + getFilesSummary, + getImportantFiles, + type ScoredAction, + type ScoringContext, + type SubtaskRelevanceScore, + type ExtractedFile, + type FilesSummary, +} from '../actionScoring'; +import type { TaskLogEntry, TaskLogEntryType, TaskLogPhase } from '../../../shared/types/task'; + +/** + * Helper to create a test TaskLogEntry with sensible defaults + */ +function createTestAction(overrides: Partial = {}): TaskLogEntry { + return { + timestamp: new Date().toISOString(), + type: 'text' as TaskLogEntryType, + content: 'Test action content', + phase: 'coding' as TaskLogPhase, + ...overrides, + }; +} + +/** + * Helper to create a fresh scoring context + */ +function createScoringContext(): ScoringContext { + return { + averageDuration: undefined, + seenToolTypes: new Set(), + }; +} + +describe('Action Scoring Algorithm', () => { + describe('SCORING_WEIGHTS constants', () => { + it('should have correct weight values based on cognitive science principles', () => { + expect(SCORING_WEIGHTS.ERROR).toBe(40); + expect(SCORING_WEIGHTS.WARNING).toBe(20); + expect(SCORING_WEIGHTS.DECISION).toBe(25); + expect(SCORING_WEIGHTS.FILE_CHANGE).toBe(20); + expect(SCORING_WEIGHTS.TIME_ANOMALY).toBe(10); + expect(SCORING_WEIGHTS.NOVELTY).toBe(5); + }); + + it('should have DEFAULT_TOP_N set to 5', () => { + expect(DEFAULT_TOP_N).toBe(5); + }); + }); + + describe('isErrorAction', () => { + it('should detect error type entries', () => { + const action = createTestAction({ type: 'error' }); + expect(isErrorAction(action)).toBe(true); + }); + + it('should detect error keywords in content', () => { + const errorKeywords = ['error', 'failed', 'failure', 'exception', 'crash']; + + errorKeywords.forEach((keyword) => { + const action = createTestAction({ content: `Something ${keyword} here` }); + expect(isErrorAction(action)).toBe(true); + }); + }); + + it('should return false for non-error actions', () => { + const action = createTestAction({ type: 'text', content: 'All good' }); + expect(isErrorAction(action)).toBe(false); + }); + + it('should handle missing content gracefully', () => { + const action = createTestAction({ content: undefined }); + expect(isErrorAction(action)).toBe(false); + }); + }); + + describe('isWarningAction', () => { + it('should detect warning keywords in content', () => { + const warningKeywords = ['warning', 'deprecated', 'caution']; + + warningKeywords.forEach((keyword) => { + const action = createTestAction({ content: `Something ${keyword} here` }); + expect(isWarningAction(action)).toBe(true); + }); + }); + + it('should return false for non-warning actions', () => { + const action = createTestAction({ content: 'Normal message' }); + expect(isWarningAction(action)).toBe(false); + }); + }); + + describe('isDecisionAction', () => { + it('should detect decision-related tool names', () => { + const decisionTools = ['Edit', 'Write', 'Create', 'Bash', 'Delete', 'Rename']; + + decisionTools.forEach((tool) => { + const action = createTestAction({ tool_name: tool }); + expect(isDecisionAction(action)).toBe(true); + }); + }); + + it('should detect decision keywords in content', () => { + const decisionKeywords = [ + 'decided', + 'choosing', + 'selected', + 'implementing', + 'creating', + 'adding', + 'modifying', + ]; + + decisionKeywords.forEach((keyword) => { + const action = createTestAction({ content: `${keyword} a new feature` }); + expect(isDecisionAction(action)).toBe(true); + }); + }); + + it('should return false for non-decision actions', () => { + const action = createTestAction({ tool_name: 'Read', content: 'Just reading' }); + expect(isDecisionAction(action)).toBe(false); + }); + }); + + describe('isFileChangeAction', () => { + it('should detect file change tool names', () => { + const fileChangeTools = ['Edit', 'Write', 'Create', 'Delete', 'Rename', 'Move', 'NotebookEdit']; + + fileChangeTools.forEach((tool) => { + const action = createTestAction({ tool_name: tool }); + expect(isFileChangeAction(action)).toBe(true); + }); + }); + + it('should detect file change keywords in content', () => { + const fileChangeKeywords = ['wrote', 'edited', 'created', 'modified', 'deleted']; + + fileChangeKeywords.forEach((keyword) => { + const action = createTestAction({ content: `${keyword} file.ts` }); + expect(isFileChangeAction(action)).toBe(true); + }); + }); + + it('should return false for non-file-change actions', () => { + const action = createTestAction({ tool_name: 'Read', content: 'Reading content' }); + expect(isFileChangeAction(action)).toBe(false); + }); + }); + + describe('countFilesChanged', () => { + it('should count file paths in tool input', () => { + const action = createTestAction({ + tool_input: '/src/component.tsx', + detail: 'Modified file', + }); + expect(countFilesChanged(action)).toBe(1); + }); + + it('should count multiple file paths', () => { + const action = createTestAction({ + tool_input: '/src/component.tsx', + detail: 'Also changed /src/utils.ts and /src/types.ts', + }); + expect(countFilesChanged(action)).toBe(3); + }); + + it('should cap at 3 files for scoring purposes', () => { + const action = createTestAction({ + detail: + '/a.ts /b.ts /c.ts /d.ts /e.ts /f.ts', + }); + expect(countFilesChanged(action)).toBe(3); + }); + + it('should handle missing tool_input and detail', () => { + const action = createTestAction({}); + expect(countFilesChanged(action)).toBe(0); + }); + }); + + describe('isNovelty', () => { + it('should return true for first occurrence of tool type', () => { + const action = createTestAction({ tool_name: 'NewTool' }); + const seenTypes = new Set(); + + expect(isNovelty(action, seenTypes)).toBe(true); + }); + + it('should return false for repeated tool type', () => { + const action = createTestAction({ tool_name: 'Edit' }); + const seenTypes = new Set(['edit']); + + expect(isNovelty(action, seenTypes)).toBe(false); + }); + + it('should return false for actions without tool_name', () => { + const action = createTestAction({ tool_name: undefined }); + const seenTypes = new Set(); + + expect(isNovelty(action, seenTypes)).toBe(false); + }); + }); + + describe('scoreAction', () => { + let context: ScoringContext; + + beforeEach(() => { + context = createScoringContext(); + }); + + it('should score error actions with ERROR weight (40)', () => { + const action = createTestAction({ type: 'error', content: 'Test error' }); + const scored = scoreAction(action, 0, context); + + expect(scored.scoreBreakdown.error).toBe(SCORING_WEIGHTS.ERROR); + expect(scored.score).toBeGreaterThanOrEqual(SCORING_WEIGHTS.ERROR); + }); + + it('should score warning actions with WARNING weight (20)', () => { + const action = createTestAction({ content: 'Warning: deprecated method' }); + const scored = scoreAction(action, 0, context); + + expect(scored.scoreBreakdown.error).toBe(SCORING_WEIGHTS.WARNING); + }); + + it('should score decision actions with DECISION weight (25)', () => { + const action = createTestAction({ tool_name: 'Edit' }); + const scored = scoreAction(action, 0, context); + + expect(scored.scoreBreakdown.decision).toBe(SCORING_WEIGHTS.DECISION); + }); + + it('should score file change actions with FILE_CHANGE weight (20)', () => { + const action = createTestAction({ + tool_name: 'Write', + tool_input: '/src/file.ts', + }); + const scored = scoreAction(action, 0, context); + + expect(scored.scoreBreakdown.fileChange).toBeGreaterThanOrEqual(SCORING_WEIGHTS.FILE_CHANGE); + }); + + it('should score novel tool types with NOVELTY weight (5)', () => { + const action = createTestAction({ tool_name: 'NewTool' }); + const scored = scoreAction(action, 0, context); + + expect(scored.scoreBreakdown.novelty).toBe(SCORING_WEIGHTS.NOVELTY); + }); + + it('should track seen tool types for novelty scoring', () => { + const action1 = createTestAction({ tool_name: 'Edit' }); + const action2 = createTestAction({ tool_name: 'Edit' }); + + scoreAction(action1, 0, context); + const scored2 = scoreAction(action2, 1, context); + + expect(scored2.scoreBreakdown.novelty).toBe(0); + }); + + it('should combine multiple scoring criteria', () => { + const action = createTestAction({ + type: 'error', + tool_name: 'Edit', + content: 'Error while implementing feature', + tool_input: '/src/file.ts', + }); + + const scored = scoreAction(action, 0, context); + + // Should have error (40) + decision (25) + file change (20+) + novelty (5) + expect(scored.score).toBeGreaterThan( + SCORING_WEIGHTS.ERROR + SCORING_WEIGHTS.DECISION + ); + }); + + it('should include original index for tiebreaking', () => { + const action = createTestAction({}); + const scored = scoreAction(action, 42, context); + + expect(scored.index).toBe(42); + }); + + it('should include the original action in result', () => { + const action = createTestAction({ content: 'Original content' }); + const scored = scoreAction(action, 0, context); + + expect(scored.action).toBe(action); + expect(scored.action.content).toBe('Original content'); + }); + }); + + describe('filterTopActions', () => { + it('should return empty array for null/undefined input', () => { + expect(filterTopActions(null as unknown as TaskLogEntry[])).toEqual([]); + expect(filterTopActions(undefined as unknown as TaskLogEntry[])).toEqual([]); + }); + + it('should return empty array for empty input', () => { + expect(filterTopActions([])).toEqual([]); + }); + + it('should return all actions when fewer than N', () => { + const actions = [ + createTestAction({ content: 'Action 1' }), + createTestAction({ content: 'Action 2' }), + createTestAction({ content: 'Action 3' }), + ]; + + const result = filterTopActions(actions, 5); + expect(result).toHaveLength(3); + }); + + it('should return exactly N actions when more than N exist', () => { + const actions = Array.from({ length: 20 }, (_, i) => + createTestAction({ content: `Action ${i}` }) + ); + + const result = filterTopActions(actions, 5); + expect(result).toHaveLength(5); + }); + + it('should prioritize error actions over normal actions', () => { + const actions = [ + createTestAction({ content: 'Normal action 1' }), + createTestAction({ content: 'Normal action 2' }), + createTestAction({ type: 'error', content: 'Error occurred' }), + createTestAction({ content: 'Normal action 3' }), + createTestAction({ content: 'Normal action 4' }), + ]; + + const result = filterTopActions(actions, 3); + + // Error action should be first (highest score) + expect(result[0].action.type).toBe('error'); + }); + + it('should prioritize file changes over simple text actions', () => { + const actions = [ + createTestAction({ type: 'text', content: 'Just text' }), + createTestAction({ tool_name: 'Write', tool_input: '/src/file.ts' }), + createTestAction({ type: 'text', content: 'More text' }), + ]; + + const result = filterTopActions(actions, 2); + + // Write action should score higher + const writeAction = result.find((r) => r.action.tool_name === 'Write'); + expect(writeAction).toBeDefined(); + }); + + it('should use index as tiebreaker when scores are equal', () => { + // Create 10 identical actions (same score) + const actions = Array.from({ length: 10 }, (_, i) => + createTestAction({ content: `Identical action`, tool_name: undefined }) + ); + + const result = filterTopActions(actions, 5); + + // Earlier actions should win tiebreaker + result.forEach((scored, resultIndex) => { + if (resultIndex > 0) { + expect(scored.index).toBeGreaterThanOrEqual(result[resultIndex - 1].index); + } + }); + }); + + it('should filter by subtask_id when provided', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', content: 'Subtask 1 action' }), + createTestAction({ subtask_id: 'subtask-2', content: 'Subtask 2 action' }), + createTestAction({ subtask_id: 'subtask-1', content: 'Another subtask 1 action' }), + ]; + + const result = filterTopActions(actions, 5, 'subtask-1'); + + expect(result).toHaveLength(2); + result.forEach((scored) => { + expect(scored.action.subtask_id).toBe('subtask-1'); + }); + }); + + it('should return sorted results by score (descending)', () => { + const actions = [ + createTestAction({ content: 'Low score text' }), + createTestAction({ type: 'error', content: 'High score error' }), + createTestAction({ tool_name: 'Edit', content: 'Medium score edit' }), + ]; + + const result = filterTopActions(actions, 3); + + // Verify descending order + for (let i = 1; i < result.length; i++) { + expect(result[i].score).toBeLessThanOrEqual(result[i - 1].score); + } + }); + }); + + describe('groupActionsBySubphase', () => { + it('should group actions by subphase', () => { + const context = createScoringContext(); + const scoredActions: ScoredAction[] = [ + scoreAction(createTestAction({ subphase: 'Planning' }), 0, context), + scoreAction(createTestAction({ subphase: 'Implementation' }), 1, context), + scoreAction(createTestAction({ subphase: 'Planning' }), 2, context), + scoreAction(createTestAction({ subphase: 'Testing' }), 3, context), + ]; + + const groups = groupActionsBySubphase(scoredActions); + + expect(groups.size).toBe(3); + expect(groups.get('Planning')).toHaveLength(2); + expect(groups.get('Implementation')).toHaveLength(1); + expect(groups.get('Testing')).toHaveLength(1); + }); + + it('should use "Other" for actions without subphase', () => { + const context = createScoringContext(); + const scoredActions: ScoredAction[] = [ + scoreAction(createTestAction({ subphase: undefined }), 0, context), + scoreAction(createTestAction({ subphase: 'Planning' }), 1, context), + ]; + + const groups = groupActionsBySubphase(scoredActions); + + expect(groups.get('Other')).toHaveLength(1); + expect(groups.get('Planning')).toHaveLength(1); + }); + + it('should handle empty array', () => { + const groups = groupActionsBySubphase([]); + expect(groups.size).toBe(0); + }); + }); + + describe('getScoreReason', () => { + it('should return "Error detected" for error actions', () => { + const context = createScoringContext(); + const scored = scoreAction(createTestAction({ type: 'error' }), 0, context); + + expect(getScoreReason(scored)).toContain('Error detected'); + }); + + it('should return "Warning detected" for warning actions', () => { + const context = createScoringContext(); + const scored = scoreAction(createTestAction({ content: 'Warning: deprecated' }), 0, context); + + expect(getScoreReason(scored)).toContain('Warning detected'); + }); + + it('should return "Key decision" for decision actions', () => { + const context = createScoringContext(); + const scored = scoreAction(createTestAction({ tool_name: 'Edit' }), 0, context); + + expect(getScoreReason(scored)).toContain('Key decision'); + }); + + it('should return "File modification" for file change actions', () => { + const context = createScoringContext(); + const scored = scoreAction( + createTestAction({ tool_name: 'Write', tool_input: '/src/file.ts' }), + 0, + context + ); + + expect(getScoreReason(scored)).toContain('File modification'); + }); + + it('should return "New action type" for novel actions', () => { + const context = createScoringContext(); + const scored = scoreAction(createTestAction({ tool_name: 'BrandNewTool' }), 0, context); + + expect(getScoreReason(scored)).toContain('New action type'); + }); + + it('should return "Standard action" for low-score actions', () => { + const context = createScoringContext(); + context.seenToolTypes.add('read'); // Mark Read as seen + const scored = scoreAction( + createTestAction({ tool_name: 'Read', content: 'Just reading' }), + 0, + context + ); + + expect(getScoreReason(scored)).toBe('Standard action'); + }); + + it('should combine multiple reasons', () => { + const context = createScoringContext(); + const scored = scoreAction( + createTestAction({ + type: 'error', + tool_name: 'Edit', + }), + 0, + context + ); + + const reason = getScoreReason(scored); + expect(reason).toContain('Error detected'); + expect(reason).toContain('Key decision'); + }); + }); + + describe('Edge Cases', () => { + it('should handle actions with all undefined optional fields', () => { + const action: TaskLogEntry = { + timestamp: new Date().toISOString(), + type: 'text', + content: 'Minimal action', + phase: 'coding', + }; + + const context = createScoringContext(); + const scored = scoreAction(action, 0, context); + + expect(scored.score).toBeGreaterThanOrEqual(0); + expect(scored.action).toBe(action); + }); + + it('should handle actions with empty string content', () => { + const action = createTestAction({ content: '' }); + const context = createScoringContext(); + const scored = scoreAction(action, 0, context); + + expect(scored.score).toBeGreaterThanOrEqual(0); + }); + + it('should handle all actions having the same score', () => { + const actions = Array.from({ length: 10 }, () => + createTestAction({ content: 'Same content' }) + ); + + const result = filterTopActions(actions, 5); + + expect(result).toHaveLength(5); + // All should have same score + const scores = result.map((r) => r.score); + expect(new Set(scores).size).toBe(1); + }); + + it('should handle single action', () => { + const actions = [createTestAction({ content: 'Only action' })]; + const result = filterTopActions(actions, 5); + + expect(result).toHaveLength(1); + }); + + it('should handle N=0 (request for zero actions)', () => { + const actions = [createTestAction({ content: 'Action' })]; + const result = filterTopActions(actions, 0); + + expect(result).toHaveLength(0); + }); + + it('should handle negative N gracefully', () => { + const actions = [createTestAction({ content: 'Action' })]; + const result = filterTopActions(actions, -1); + + expect(result).toHaveLength(0); + }); + }); + + describe('Performance', () => { + it('should process 1000 actions in less than 100ms', () => { + const actions: TaskLogEntry[] = Array.from({ length: 1000 }, (_, i) => { + // Create variety of action types + const types: TaskLogEntryType[] = ['text', 'tool_start', 'tool_end', 'error', 'success']; + const tools = ['Edit', 'Write', 'Read', 'Bash', 'Glob', 'Grep', undefined]; + + return createTestAction({ + type: types[i % types.length], + tool_name: tools[i % tools.length], + content: `Action ${i} with some content for variety`, + subtask_id: `subtask-${i % 5}`, + subphase: i % 3 === 0 ? 'Planning' : i % 3 === 1 ? 'Implementation' : 'Testing', + }); + }); + + const startTime = performance.now(); + const result = filterTopActions(actions, 5); + const endTime = performance.now(); + + const duration = endTime - startTime; + + expect(result).toHaveLength(5); + expect(duration).toBeLessThan(100); + }); + + it('should process 5000 actions in less than 500ms', () => { + const actions: TaskLogEntry[] = Array.from({ length: 5000 }, (_, i) => + createTestAction({ + content: `Action ${i}`, + tool_name: i % 10 === 0 ? 'Edit' : undefined, + }) + ); + + const startTime = performance.now(); + const result = filterTopActions(actions, 5); + const endTime = performance.now(); + + const duration = endTime - startTime; + + expect(result).toHaveLength(5); + expect(duration).toBeLessThan(500); + }); + + it('should not cause memory issues with large datasets', () => { + // Create large dataset + const actions: TaskLogEntry[] = Array.from({ length: 1000 }, (_, i) => + createTestAction({ content: `Action ${i}`.repeat(100) }) // Large content + ); + + // Should complete without throwing + expect(() => { + const result = filterTopActions(actions, 5); + expect(result).toHaveLength(5); + }).not.toThrow(); + }); + }); + + describe('Integration scenarios', () => { + it('should correctly rank a realistic mix of actions', () => { + const actions = [ + createTestAction({ type: 'text', content: 'Reading file structure' }), + createTestAction({ tool_name: 'Read', content: 'Read package.json' }), + createTestAction({ type: 'error', content: 'Failed to parse JSON' }), + createTestAction({ tool_name: 'Edit', content: 'Fixed JSON syntax', tool_input: '/package.json' }), + createTestAction({ content: 'warning: deprecated package' }), + createTestAction({ tool_name: 'Bash', content: 'npm install' }), + createTestAction({ type: 'success', content: 'Installation complete' }), + createTestAction({ tool_name: 'Write', content: 'Creating new file', tool_input: '/src/new-file.ts' }), + createTestAction({ type: 'text', content: 'Analysis complete' }), + createTestAction({ tool_name: 'Read', content: 'Checking results' }), + ]; + + const result = filterTopActions(actions, 5); + + // Error should be in top 5 + const hasError = result.some((r) => r.action.type === 'error'); + expect(hasError).toBe(true); + + // File modifications should be in top 5 + const hasFileChange = result.some( + (r) => r.action.tool_name === 'Edit' || r.action.tool_name === 'Write' + ); + expect(hasFileChange).toBe(true); + }); + + it('should handle subtask-specific filtering with scoring', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', type: 'error', content: 'Error in subtask 1' }), + createTestAction({ subtask_id: 'subtask-2', type: 'error', content: 'Error in subtask 2' }), + createTestAction({ subtask_id: 'subtask-1', tool_name: 'Edit', content: 'Fixed issue' }), + createTestAction({ subtask_id: 'subtask-2', tool_name: 'Edit', content: 'Fixed other issue' }), + ]; + + const subtask1Result = filterTopActions(actions, 5, 'subtask-1'); + const subtask2Result = filterTopActions(actions, 5, 'subtask-2'); + + expect(subtask1Result).toHaveLength(2); + expect(subtask2Result).toHaveLength(2); + + subtask1Result.forEach((r) => expect(r.action.subtask_id).toBe('subtask-1')); + subtask2Result.forEach((r) => expect(r.action.subtask_id).toBe('subtask-2')); + }); + }); + + describe('Subtask Relevance Scoring', () => { + describe('calculateSubtaskRelevance', () => { + it('should return zero scores for subtask with no actions', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', content: 'Action 1' }), + ]; + + const result = calculateSubtaskRelevance(actions, 'subtask-2'); + + expect(result.subtaskId).toBe('subtask-2'); + expect(result.totalScore).toBe(0); + expect(result.actionCount).toBe(0); + expect(result.averageScore).toBe(0); + expect(result.hasErrors).toBe(false); + expect(result.hasDecisions).toBe(false); + expect(result.topScore).toBe(0); + }); + + it('should calculate correct scores for subtask with actions', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', type: 'error', content: 'Error occurred' }), + createTestAction({ subtask_id: 'subtask-1', tool_name: 'Edit', content: 'Fixed issue' }), + createTestAction({ subtask_id: 'subtask-1', content: 'Some text' }), + ]; + + const result = calculateSubtaskRelevance(actions, 'subtask-1'); + + expect(result.subtaskId).toBe('subtask-1'); + expect(result.actionCount).toBe(3); + expect(result.totalScore).toBeGreaterThan(0); + expect(result.hasErrors).toBe(true); + expect(result.hasDecisions).toBe(true); + expect(result.topScore).toBeGreaterThanOrEqual(SCORING_WEIGHTS.ERROR); + }); + + it('should detect subtasks with errors', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', type: 'error', content: 'Error occurred' }), + ]; + + const result = calculateSubtaskRelevance(actions, 'subtask-1'); + + expect(result.hasErrors).toBe(true); + }); + + it('should detect subtasks without errors', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', content: 'Some text' }), + ]; + + const result = calculateSubtaskRelevance(actions, 'subtask-1'); + + expect(result.hasErrors).toBe(false); + }); + + it('should detect subtasks with decisions', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', tool_name: 'Edit', content: 'Editing file' }), + ]; + + const result = calculateSubtaskRelevance(actions, 'subtask-1'); + + expect(result.hasDecisions).toBe(true); + }); + }); + + describe('calculateSubtaskRelevanceScores', () => { + it('should calculate scores for multiple subtasks', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', type: 'error', content: 'Error' }), + createTestAction({ subtask_id: 'subtask-2', tool_name: 'Edit', content: 'Edit' }), + createTestAction({ subtask_id: 'subtask-3', content: 'Text' }), + ]; + + const result = calculateSubtaskRelevanceScores(actions, ['subtask-1', 'subtask-2', 'subtask-3']); + + expect(result.size).toBe(3); + expect(result.get('subtask-1')?.hasErrors).toBe(true); + expect(result.get('subtask-2')?.hasDecisions).toBe(true); + expect(result.get('subtask-3')?.hasErrors).toBe(false); + }); + + it('should return empty map for empty subtask list', () => { + const actions = [createTestAction({ subtask_id: 'subtask-1' })]; + const result = calculateSubtaskRelevanceScores(actions, []); + + expect(result.size).toBe(0); + }); + }); + + describe('sortSubtasksByRelevance', () => { + it('should sort subtasks by relevance score (highest first)', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', content: 'Text only' }), + createTestAction({ subtask_id: 'subtask-2', type: 'error', content: 'Error occurred' }), + createTestAction({ subtask_id: 'subtask-3', tool_name: 'Edit', content: 'Edit' }), + ]; + + const subtaskIds = ['subtask-1', 'subtask-2', 'subtask-3']; + const scores = calculateSubtaskRelevanceScores(actions, subtaskIds); + const sorted = sortSubtasksByRelevance(subtaskIds, scores); + + // subtask-2 should be first (has error, highest priority) + expect(sorted[0]).toBe('subtask-2'); + }); + + it('should prioritize subtasks with errors', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', tool_name: 'Edit', content: 'Edit', tool_input: '/file.ts' }), + createTestAction({ subtask_id: 'subtask-1', tool_name: 'Write', content: 'Write', tool_input: '/file2.ts' }), + createTestAction({ subtask_id: 'subtask-2', type: 'error', content: 'Error' }), + ]; + + const subtaskIds = ['subtask-1', 'subtask-2']; + const scores = calculateSubtaskRelevanceScores(actions, subtaskIds); + const sorted = sortSubtasksByRelevance(subtaskIds, scores); + + // subtask-2 should be first due to error even though subtask-1 has more actions + expect(sorted[0]).toBe('subtask-2'); + }); + + it('should use action count as secondary sort when scores are similar', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', content: 'Text 1' }), + createTestAction({ subtask_id: 'subtask-2', content: 'Text 2' }), + createTestAction({ subtask_id: 'subtask-2', content: 'Text 3' }), + createTestAction({ subtask_id: 'subtask-2', content: 'Text 4' }), + ]; + + const subtaskIds = ['subtask-1', 'subtask-2']; + const scores = calculateSubtaskRelevanceScores(actions, subtaskIds); + const sorted = sortSubtasksByRelevance(subtaskIds, scores); + + // subtask-2 should be first (more actions) + expect(sorted[0]).toBe('subtask-2'); + }); + + it('should handle empty relevance scores gracefully', () => { + const subtaskIds = ['subtask-1', 'subtask-2']; + const scores = new Map(); + + const sorted = sortSubtasksByRelevance(subtaskIds, scores); + + // Should return same order when no scores available + expect(sorted).toEqual(subtaskIds); + }); + + it('should handle partial relevance scores', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', type: 'error', content: 'Error' }), + ]; + + const subtaskIds = ['subtask-1', 'subtask-2']; + const scores = calculateSubtaskRelevanceScores(actions, ['subtask-1']); // Only score subtask-1 + const sorted = sortSubtasksByRelevance(subtaskIds, scores); + + // subtask-1 should be first (has score), subtask-2 at end (no score) + expect(sorted[0]).toBe('subtask-1'); + }); + + it('should not mutate original array', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-2', type: 'error', content: 'Error' }), + ]; + + const subtaskIds = ['subtask-1', 'subtask-2']; + const originalOrder = [...subtaskIds]; + const scores = calculateSubtaskRelevanceScores(actions, subtaskIds); + + sortSubtasksByRelevance(subtaskIds, scores); + + expect(subtaskIds).toEqual(originalOrder); + }); + }); + }); + + /** + * File Extraction Tests + */ + describe('File Extraction', () => { + describe('extractFilesFromAction', () => { + it('should extract file path from Read tool action', () => { + const action = createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: '/src/components/App.tsx', + }); + + const files = extractFilesFromAction(action); + + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/src/components/App.tsx'); + expect(files[0].filename).toBe('App.tsx'); + expect(files[0].operation).toBe('read'); + }); + + it('should extract file path from Edit tool action', () => { + const action = createTestAction({ + type: 'tool_start', + tool_name: 'Edit', + tool_input: '/src/lib/utils.ts', + }); + + const files = extractFilesFromAction(action); + + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/src/lib/utils.ts'); + expect(files[0].operation).toBe('edit'); + }); + + it('should extract file path from Write tool action', () => { + const action = createTestAction({ + type: 'tool_start', + tool_name: 'Write', + tool_input: '/src/new-file.ts', + }); + + const files = extractFilesFromAction(action); + + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/src/new-file.ts'); + expect(files[0].operation).toBe('write'); + }); + + it('should skip tool_end actions to avoid duplicates', () => { + const action = createTestAction({ + type: 'tool_end', + tool_name: 'Read', + tool_input: '/src/file.ts', + }); + + const files = extractFilesFromAction(action); + + expect(files).toHaveLength(0); + }); + + it('should skip non-tool actions', () => { + const action = createTestAction({ + type: 'text', + content: 'Some text about /src/file.ts', + }); + + const files = extractFilesFromAction(action); + + expect(files).toHaveLength(0); + }); + + it('should handle relative paths', () => { + const action = createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: './src/components/Button.tsx', + }); + + const files = extractFilesFromAction(action); + + expect(files).toHaveLength(1); + expect(files[0].path).toBe('./src/components/Button.tsx'); + expect(files[0].filename).toBe('Button.tsx'); + }); + + it('should extract file from detail when tool_input is empty', () => { + const action = createTestAction({ + type: 'tool_start', + tool_name: 'Edit', + tool_input: '', + detail: 'Editing /src/config.json', + }); + + const files = extractFilesFromAction(action); + + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/src/config.json'); + }); + }); + + describe('extractFilesFromActions', () => { + it('should extract files from multiple actions', () => { + const actions = [ + createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: '/src/file1.ts', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Edit', + tool_input: '/src/file2.ts', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Write', + tool_input: '/src/file3.ts', + }), + ]; + + const files = extractFilesFromActions(actions); + + expect(files).toHaveLength(3); + expect(files.map(f => f.path)).toEqual([ + '/src/file1.ts', + '/src/file2.ts', + '/src/file3.ts', + ]); + }); + + it('should skip Grep and Glob tools (search operations)', () => { + const actions = [ + createTestAction({ + type: 'tool_start', + tool_name: 'Grep', + tool_input: 'searchPattern', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Glob', + tool_input: '**/*.ts', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: '/src/file.ts', + }), + ]; + + const files = extractFilesFromActions(actions); + + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/src/file.ts'); + }); + }); + + describe('getFilesSummary', () => { + it('should categorize files by operation type', () => { + const actions = [ + createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: '/src/read-only.ts', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Edit', + tool_input: '/src/modified.ts', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Write', + tool_input: '/src/written.ts', + }), + ]; + + const summary = getFilesSummary(actions); + + expect(summary.modifiedFiles).toContain('/src/modified.ts'); + expect(summary.modifiedFiles).toContain('/src/written.ts'); + expect(summary.readFiles).toContain('/src/read-only.ts'); + }); + + it('should not include read file in readFiles if it was also modified', () => { + const actions = [ + createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: '/src/file.ts', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Edit', + tool_input: '/src/file.ts', + }), + ]; + + const summary = getFilesSummary(actions); + + expect(summary.modifiedFiles).toContain('/src/file.ts'); + expect(summary.readFiles).not.toContain('/src/file.ts'); + }); + + it('should filter by subtask_id when specified', () => { + const actions = [ + createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: '/src/subtask1-file.ts', + subtask_id: 'subtask-1', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: '/src/subtask2-file.ts', + subtask_id: 'subtask-2', + }), + ]; + + const summary = getFilesSummary(actions, 'subtask-1'); + + expect(summary.uniqueFiles).toHaveLength(1); + expect(summary.uniqueFiles).toContain('/src/subtask1-file.ts'); + expect(summary.uniqueFiles).not.toContain('/src/subtask2-file.ts'); + }); + + it('should return unique file paths', () => { + const actions = [ + createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: '/src/file.ts', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: '/src/file.ts', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Edit', + tool_input: '/src/file.ts', + }), + ]; + + const summary = getFilesSummary(actions); + + expect(summary.uniqueFiles).toHaveLength(1); + expect(summary.modifiedFiles).toHaveLength(1); + }); + }); + + describe('getImportantFiles', () => { + it('should prioritize modified files over read files', () => { + const actions = [ + createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: '/src/read1.ts', + subtask_id: 'subtask-1', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: '/src/read2.ts', + subtask_id: 'subtask-1', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Edit', + tool_input: '/src/edited.ts', + subtask_id: 'subtask-1', + }), + ]; + + const result = getImportantFiles(actions, 'subtask-1', 2); + + // Modified file should come first + expect(result.modified).toContain('/src/edited.ts'); + // Read files fill remaining slots + expect(result.read).toHaveLength(1); + }); + + it('should limit to maxFiles parameter', () => { + const actions = Array.from({ length: 10 }, (_, i) => + createTestAction({ + type: 'tool_start', + tool_name: 'Edit', + tool_input: `/src/file${i}.ts`, + subtask_id: 'subtask-1', + }) + ); + + const result = getImportantFiles(actions, 'subtask-1', 3); + + expect(result.modified).toHaveLength(3); + }); + + it('should return empty arrays when no files found', () => { + const actions = [ + createTestAction({ + type: 'text', + content: 'Some text', + subtask_id: 'subtask-1', + }), + ]; + + const result = getImportantFiles(actions, 'subtask-1'); + + expect(result.modified).toHaveLength(0); + expect(result.read).toHaveLength(0); + }); + }); + }); +}); diff --git a/apps/frontend/src/renderer/lib/actionScoring.ts b/apps/frontend/src/renderer/lib/actionScoring.ts new file mode 100644 index 0000000000..f3aeea60d4 --- /dev/null +++ b/apps/frontend/src/renderer/lib/actionScoring.ts @@ -0,0 +1,829 @@ +/** + * Action Scoring Algorithm for Intelligent Subtask Action Summarization + * + * Implements a weighted scoring algorithm based on cognitive science principles + * to identify the most relevant actions from potentially 1000+ entries. + * + * Scoring Weights (research-backed): + * - Error: 40 - Flow blockers demand immediate attention (Csikszentmihalyi) + * - Decision: 25 - Mental model building (Justin Sung's conceptual frameworks) + * - FileChange: 20 - Concrete anchors for active recall + * - TimeAnomaly: 10 - Attention signals for complexity (Kahneman's System 1/2) + * - Novelty: 5 - Learning moments, first exposures (Bjork's desirable difficulties) + */ + +import type { TaskLogEntry, TaskLogEntryType } from '@shared/types/task'; + +/** Scoring weight constants based on cognitive science principles */ +export const SCORING_WEIGHTS = { + ERROR: 40, + WARNING: 20, + DECISION: 25, + FILE_CHANGE: 20, + TIME_ANOMALY: 10, + NOVELTY: 5, +} as const; + +/** Default number of top actions to return */ +export const DEFAULT_TOP_N = 5; + +/** Time anomaly threshold multiplier (2x average duration) */ +export const TIME_ANOMALY_THRESHOLD = 2; + +/** + * Scored action with computed relevance score + */ +export interface ScoredAction { + action: TaskLogEntry; + score: number; + index: number; // Original index for tiebreaking + scoreBreakdown: ScoreBreakdown; +} + +/** + * Breakdown of how an action's score was calculated + */ +export interface ScoreBreakdown { + error: number; + decision: number; + fileChange: number; + timeAnomaly: number; + novelty: number; +} + +/** + * Context for scoring actions (e.g., average duration, seen types) + */ +export interface ScoringContext { + averageDuration?: number; + seenToolTypes: Set; +} + +/** + * Tool names that indicate decision-making actions + * These represent key architectural or implementation choices + */ +const DECISION_TOOL_NAMES = [ + 'edit', + 'write', + 'create', + 'bash', // Commands can represent decisions + 'delete', + 'rename', + 'move', + 'refactor', +]; + +/** + * Tool names that indicate file changes + */ +const FILE_CHANGE_TOOL_NAMES = [ + 'edit', + 'write', + 'create', + 'delete', + 'rename', + 'move', + 'notebookedit', +]; + +/** + * Entry types that indicate errors or warnings + */ +const ERROR_TYPES: TaskLogEntryType[] = ['error']; + +/** + * Entry types that indicate success (lower priority than errors) + */ +const SUCCESS_TYPES: TaskLogEntryType[] = ['success']; + +/** + * Check if an action represents an error + */ +export function isErrorAction(action: TaskLogEntry): boolean { + // Check entry type + if (ERROR_TYPES.includes(action.type)) { + return true; + } + + // Check content for error keywords + const content = action.content?.toLowerCase() ?? ''; + const hasErrorKeywords = + content.includes('error') || + content.includes('failed') || + content.includes('failure') || + content.includes('exception') || + content.includes('crash'); + + return hasErrorKeywords; +} + +/** + * Check if an action represents a warning + */ +export function isWarningAction(action: TaskLogEntry): boolean { + const content = action.content?.toLowerCase() ?? ''; + return ( + content.includes('warning') || + content.includes('deprecated') || + content.includes('caution') + ); +} + +/** + * Check if an action represents a decision point + * Decision points are key moments that affect the implementation direction + */ +export function isDecisionAction(action: TaskLogEntry): boolean { + // Tool-based decision detection + if (action.tool_name) { + const toolLower = action.tool_name.toLowerCase(); + if (DECISION_TOOL_NAMES.some((name) => toolLower.includes(name))) { + return true; + } + } + + // Content-based decision detection + const content = action.content?.toLowerCase() ?? ''; + const hasDecisionKeywords = + content.includes('decided') || + content.includes('choosing') || + content.includes('selected') || + content.includes('implementing') || + content.includes('creating') || + content.includes('adding') || + content.includes('modifying'); + + return hasDecisionKeywords; +} + +/** + * Check if an action involves file changes + */ +export function isFileChangeAction(action: TaskLogEntry): boolean { + // Tool-based file change detection + if (action.tool_name) { + const toolLower = action.tool_name.toLowerCase(); + if (FILE_CHANGE_TOOL_NAMES.some((name) => toolLower.includes(name))) { + return true; + } + } + + // Content-based file change detection + const content = action.content?.toLowerCase() ?? ''; + return ( + content.includes('wrote') || + content.includes('edited') || + content.includes('created') || + content.includes('modified') || + content.includes('deleted') + ); +} + +/** + * Count the number of files affected by an action + * Returns a rough estimate based on content analysis + */ +export function countFilesChanged(action: TaskLogEntry): number { + // Check tool input for file paths + const input = action.tool_input ?? ''; + const detail = action.detail ?? ''; + const combined = input + detail; + + // Count file path patterns (simplified heuristic) + const filePatterns = combined.match(/[\/\\][\w\-\.]+\.[a-z]{1,4}/gi) ?? []; + + // Cap at 3 for scoring purposes (to prevent outliers) + return Math.min(filePatterns.length, 3); +} + +/** + * Parse duration from action timestamp or detail + * Returns undefined if duration cannot be determined + */ +export function parseDuration(action: TaskLogEntry): number | undefined { + // Duration is not directly available in TaskLogEntry + // This could be enhanced if duration tracking is added to the type + return undefined; +} + +/** + * Calculate average duration from a list of actions + */ +export function calculateAverageDuration(actions: TaskLogEntry[]): number | undefined { + const durations = actions + .map(parseDuration) + .filter((d): d is number => d !== undefined); + + if (durations.length === 0) { + return undefined; + } + + return durations.reduce((sum, d) => sum + d, 0) / durations.length; +} + +/** + * Check if action duration is anomalous (significantly longer than average) + */ +export function isTimeAnomaly(action: TaskLogEntry, averageDuration?: number): boolean { + if (averageDuration === undefined) { + return false; + } + + const duration = parseDuration(action); + if (duration === undefined) { + return false; + } + + return duration > averageDuration * TIME_ANOMALY_THRESHOLD; +} + +/** + * Check if this is the first occurrence of a tool type + */ +export function isNovelty(action: TaskLogEntry, seenTypes: Set): boolean { + if (!action.tool_name) { + return false; + } + + const toolType = action.tool_name.toLowerCase(); + return !seenTypes.has(toolType); +} + +/** + * Score a single action based on cognitive science principles + * + * @param action - The action to score + * @param index - Original index in the actions array (for tiebreaking) + * @param context - Scoring context with average duration and seen types + * @returns ScoredAction with computed score and breakdown + */ +export function scoreAction( + action: TaskLogEntry, + index: number, + context: ScoringContext +): ScoredAction { + const breakdown: ScoreBreakdown = { + error: 0, + decision: 0, + fileChange: 0, + timeAnomaly: 0, + novelty: 0, + }; + + // Error/warning signals (cognitive priority) + if (isErrorAction(action)) { + breakdown.error = SCORING_WEIGHTS.ERROR; + } else if (isWarningAction(action)) { + breakdown.error = SCORING_WEIGHTS.WARNING; + } + + // Decision points (mental model building) + if (isDecisionAction(action)) { + breakdown.decision = SCORING_WEIGHTS.DECISION; + } + + // File changes (concrete anchors) + if (isFileChangeAction(action)) { + const filesChanged = countFilesChanged(action); + // Scale by number of files changed, cap at 3x base weight + breakdown.fileChange = SCORING_WEIGHTS.FILE_CHANGE * Math.max(1, filesChanged); + } + + // Time anomalies (attention signals) + if (isTimeAnomaly(action, context.averageDuration)) { + breakdown.timeAnomaly = SCORING_WEIGHTS.TIME_ANOMALY; + } + + // Novelty (learning moments) + if (isNovelty(action, context.seenToolTypes)) { + breakdown.novelty = SCORING_WEIGHTS.NOVELTY; + // Track this type as seen + if (action.tool_name) { + context.seenToolTypes.add(action.tool_name.toLowerCase()); + } + } + + const score = + breakdown.error + + breakdown.decision + + breakdown.fileChange + + breakdown.timeAnomaly + + breakdown.novelty; + + return { + action, + score, + index, + scoreBreakdown: breakdown, + }; +} + +/** + * Compare function for sorting scored actions + * Primary: descending by score + * Secondary: ascending by index (earlier actions win ties) + */ +function compareScoredActions(a: ScoredAction, b: ScoredAction): number { + // Higher score comes first + if (b.score !== a.score) { + return b.score - a.score; + } + // For equal scores, earlier action wins (ascending index) + return a.index - b.index; +} + +/** + * Filter and return the top N most relevant actions + * + * @param actions - Array of all actions to filter + * @param n - Number of top actions to return (default: 5) + * @param subtaskId - Optional subtask ID to filter by + * @returns Array of scored actions, sorted by relevance + */ +export function filterTopActions( + actions: TaskLogEntry[], + n: number = DEFAULT_TOP_N, + subtaskId?: string +): ScoredAction[] { + // Handle empty or small arrays + if (!actions || actions.length === 0) { + return []; + } + + // Filter by subtask if specified + let filteredActions = subtaskId + ? actions.filter((a) => a.subtask_id === subtaskId) + : actions; + + // If fewer actions than requested, process all of them + if (filteredActions.length <= n) { + const context: ScoringContext = { + averageDuration: calculateAverageDuration(filteredActions), + seenToolTypes: new Set(), + }; + + return filteredActions + .map((action, index) => scoreAction(action, index, context)) + .sort(compareScoredActions); + } + + // Create scoring context + const context: ScoringContext = { + averageDuration: calculateAverageDuration(filteredActions), + seenToolTypes: new Set(), + }; + + // Score all actions + const scoredActions = filteredActions.map((action, index) => + scoreAction(action, index, context) + ); + + // Sort by score (descending) with tiebreaker + scoredActions.sort(compareScoredActions); + + // Return top N + return scoredActions.slice(0, n); +} + +/** + * Group actions by subphase for hierarchical display + * + * @param actions - Array of scored actions + * @returns Map of subphase -> actions + */ +export function groupActionsBySubphase( + actions: ScoredAction[] +): Map { + const groups = new Map(); + + for (const action of actions) { + const subphase = action.action.subphase ?? 'Other'; + const existing = groups.get(subphase) ?? []; + existing.push(action); + groups.set(subphase, existing); + } + + return groups; +} + +/** + * Get a human-readable description of why an action scored highly + * + * @param scoredAction - The scored action to describe + * @returns String describing the scoring reasons + */ +export function getScoreReason(scoredAction: ScoredAction): string { + const reasons: string[] = []; + const { scoreBreakdown } = scoredAction; + + if (scoreBreakdown.error > 0) { + if (scoreBreakdown.error >= SCORING_WEIGHTS.ERROR) { + reasons.push('Error detected'); + } else { + reasons.push('Warning detected'); + } + } + + if (scoreBreakdown.decision > 0) { + reasons.push('Key decision'); + } + + if (scoreBreakdown.fileChange > 0) { + reasons.push('File modification'); + } + + if (scoreBreakdown.timeAnomaly > 0) { + reasons.push('Long duration'); + } + + if (scoreBreakdown.novelty > 0) { + reasons.push('New action type'); + } + + return reasons.length > 0 ? reasons.join(', ') : 'Standard action'; +} + +/** + * Subtask relevance score with aggregate data + */ +export interface SubtaskRelevanceScore { + subtaskId: string; + totalScore: number; + actionCount: number; + averageScore: number; + hasErrors: boolean; + hasDecisions: boolean; + topScore: number; // Highest single action score +} + +/** + * Calculate the relevance score for a subtask based on its actions + * + * The relevance score is a combination of: + * - Total aggregate score of all actions + * - Top action score (to prioritize subtasks with critical actions) + * - Presence of errors (higher priority) + * + * @param actions - All actions from phase logs + * @param subtaskId - The subtask ID to calculate relevance for + * @returns SubtaskRelevanceScore with aggregated scoring data + */ +export function calculateSubtaskRelevance( + actions: TaskLogEntry[], + subtaskId: string +): SubtaskRelevanceScore { + const subtaskActions = actions.filter((a) => a.subtask_id === subtaskId); + + if (subtaskActions.length === 0) { + return { + subtaskId, + totalScore: 0, + actionCount: 0, + averageScore: 0, + hasErrors: false, + hasDecisions: false, + topScore: 0, + }; + } + + // Score all subtask actions + const context: ScoringContext = { + averageDuration: calculateAverageDuration(subtaskActions), + seenToolTypes: new Set(), + }; + + const scoredActions = subtaskActions.map((action, index) => + scoreAction(action, index, context) + ); + + // Aggregate scores + const totalScore = scoredActions.reduce((sum, sa) => sum + sa.score, 0); + const topScore = Math.max(...scoredActions.map((sa) => sa.score)); + const hasErrors = scoredActions.some((sa) => sa.scoreBreakdown.error >= SCORING_WEIGHTS.ERROR); + const hasDecisions = scoredActions.some((sa) => sa.scoreBreakdown.decision > 0); + + return { + subtaskId, + totalScore, + actionCount: subtaskActions.length, + averageScore: totalScore / subtaskActions.length, + hasErrors, + hasDecisions, + topScore, + }; +} + +/** + * Calculate relevance scores for multiple subtasks + * + * @param actions - All actions from phase logs + * @param subtaskIds - Array of subtask IDs to calculate relevance for + * @returns Map of subtask ID to relevance score + */ +export function calculateSubtaskRelevanceScores( + actions: TaskLogEntry[], + subtaskIds: string[] +): Map { + const scores = new Map(); + + for (const subtaskId of subtaskIds) { + scores.set(subtaskId, calculateSubtaskRelevance(actions, subtaskId)); + } + + return scores; +} + +/** + * Sort subtasks by relevance score + * + * Primary sort: By composite relevance score (errors weighted heavily) + * Secondary sort: By action count (more actions = more activity) + * Tertiary sort: By original order (for stable sorting) + * + * @param subtaskIds - Array of subtask IDs + * @param relevanceScores - Map of subtask ID to relevance score + * @returns Sorted array of subtask IDs (most relevant first) + */ +export function sortSubtasksByRelevance( + subtaskIds: string[], + relevanceScores: Map +): string[] { + return [...subtaskIds].sort((a, b) => { + const scoreA = relevanceScores.get(a); + const scoreB = relevanceScores.get(b); + + // Handle missing scores (put at end) + if (!scoreA && !scoreB) return 0; + if (!scoreA) return 1; + if (!scoreB) return -1; + + // Composite score: prioritize errors, then top score, then total + const compositeA = + (scoreA.hasErrors ? 1000 : 0) + scoreA.topScore * 10 + scoreA.totalScore; + const compositeB = + (scoreB.hasErrors ? 1000 : 0) + scoreB.topScore * 10 + scoreB.totalScore; + + if (compositeB !== compositeA) { + return compositeB - compositeA; // Descending + } + + // Secondary: action count (more activity = more relevant) + if (scoreB.actionCount !== scoreA.actionCount) { + return scoreB.actionCount - scoreA.actionCount; + } + + // Tertiary: stable sort by original index + return subtaskIds.indexOf(a) - subtaskIds.indexOf(b); + }); +} + +/** + * File operation type for categorizing file interactions + */ +export type FileOperationType = 'read' | 'edit' | 'write' | 'delete' | 'search' | 'bash'; + +/** + * Extracted file information from log actions + */ +export interface ExtractedFile { + path: string; + filename: string; + operation: FileOperationType; + timestamp: string; + toolName?: string; +} + +/** + * Summary of files touched during a subtask + */ +export interface FilesSummary { + files: ExtractedFile[]; + uniqueFiles: string[]; + byOperation: Map; + modifiedFiles: string[]; // Files that were edited/written (most important) + readFiles: string[]; // Files that were only read +} + +/** + * Extract file path from tool_input string + * Handles various formats: direct paths, JSON inputs, etc. + */ +function extractFilePathFromInput(input: string): string | null { + if (!input) return null; + + // Trim whitespace + const trimmed = input.trim(); + + // Direct file path (starts with / or ./ or contains typical file extensions) + if (trimmed.startsWith('/') || trimmed.startsWith('./')) { + // Extract just the path part (before any space or newline) + const pathMatch = trimmed.match(/^([^\s\n]+)/); + if (pathMatch) { + return pathMatch[1]; + } + } + + // Try to parse as JSON and extract file_path or path + try { + const parsed = JSON.parse(trimmed); + if (parsed.file_path) return parsed.file_path; + if (parsed.path) return parsed.path; + if (parsed.filename) return parsed.filename; + } catch { + // Not JSON, try regex patterns + } + + // Look for file path patterns in the string + const pathPatterns = [ + // Absolute paths + /\/[\w\-./]+\.[a-z]{1,6}/gi, + // Relative paths with extension + /\.\/[\w\-./]+\.[a-z]{1,6}/gi, + // Paths in quotes + /"([^"]+\.[a-z]{1,6})"/gi, + /'([^']+\.[a-z]{1,6})'/gi, + ]; + + for (const pattern of pathPatterns) { + const match = trimmed.match(pattern); + if (match && match.length > 0) { + // Clean up the match (remove quotes if present) + const cleanPath = match[0].replace(/^["']|["']$/g, ''); + return cleanPath; + } + } + + return null; +} + +/** + * Get filename from a path + */ +function getFilename(path: string): string { + const parts = path.split('/'); + return parts[parts.length - 1] || path; +} + +/** + * Map tool name to operation type + */ +function getOperationType(toolName: string | undefined): FileOperationType { + if (!toolName) return 'bash'; + + const tool = toolName.toLowerCase(); + + if (tool === 'read') return 'read'; + if (tool === 'edit' || tool === 'notebookedit') return 'edit'; + if (tool === 'write') return 'write'; + if (tool === 'delete') return 'delete'; + if (tool === 'grep' || tool === 'glob') return 'search'; + if (tool === 'bash') return 'bash'; + + // Default for unknown tools + return 'bash'; +} + +/** + * Extract files from a single action + */ +export function extractFilesFromAction(action: TaskLogEntry): ExtractedFile[] { + const files: ExtractedFile[] = []; + + // Only process tool actions + if (action.type !== 'tool_start' && action.type !== 'tool_end') { + return files; + } + + // Skip tool_end to avoid duplicates + if (action.type === 'tool_end') { + return files; + } + + const operation = getOperationType(action.tool_name); + + // Skip search operations for now (they don't represent specific files) + if (operation === 'search') { + return files; + } + + // Try to extract file path from tool_input + if (action.tool_input) { + const filePath = extractFilePathFromInput(action.tool_input); + if (filePath) { + files.push({ + path: filePath, + filename: getFilename(filePath), + operation, + timestamp: action.timestamp, + toolName: action.tool_name, + }); + } + } + + // Also check detail field for additional file references + if (action.detail && files.length === 0) { + const filePath = extractFilePathFromInput(action.detail); + if (filePath) { + files.push({ + path: filePath, + filename: getFilename(filePath), + operation, + timestamp: action.timestamp, + toolName: action.tool_name, + }); + } + } + + return files; +} + +/** + * Extract all files from a list of actions + */ +export function extractFilesFromActions(actions: TaskLogEntry[]): ExtractedFile[] { + const files: ExtractedFile[] = []; + + for (const action of actions) { + const extracted = extractFilesFromAction(action); + files.push(...extracted); + } + + return files; +} + +/** + * Get a summary of files touched during a subtask + */ +export function getFilesSummary(actions: TaskLogEntry[], subtaskId?: string): FilesSummary { + // Filter by subtask if specified + const filteredActions = subtaskId + ? actions.filter(a => a.subtask_id === subtaskId) + : actions; + + // Extract all files + const files = extractFilesFromActions(filteredActions); + + // Group by operation type + const byOperation = new Map(); + for (const file of files) { + const existing = byOperation.get(file.operation) ?? []; + existing.push(file); + byOperation.set(file.operation, existing); + } + + // Get unique file paths + const uniqueFilesSet = new Set(); + for (const file of files) { + uniqueFilesSet.add(file.path); + } + const uniqueFiles = Array.from(uniqueFilesSet); + + // Determine modified vs read-only files + const modifiedFilesSet = new Set(); + const allFilesSet = new Set(); + + for (const file of files) { + allFilesSet.add(file.path); + if (file.operation === 'edit' || file.operation === 'write' || file.operation === 'delete') { + modifiedFilesSet.add(file.path); + } + } + + // Read-only files are those that were read but never modified + const readFilesSet = new Set(); + for (const file of files) { + if (file.operation === 'read' && !modifiedFilesSet.has(file.path)) { + readFilesSet.add(file.path); + } + } + + return { + files, + uniqueFiles, + byOperation, + modifiedFiles: Array.from(modifiedFilesSet), + readFiles: Array.from(readFilesSet), + }; +} + +/** + * Get the most important files for a subtask (modified files first, then read files) + * Limited to a maximum number for display purposes + */ +export function getImportantFiles( + actions: TaskLogEntry[], + subtaskId: string, + maxFiles: number = 5 +): { modified: string[]; read: string[] } { + const summary = getFilesSummary(actions, subtaskId); + + // Prioritize modified files + const modified = summary.modifiedFiles.slice(0, maxFiles); + + // Fill remaining slots with read-only files + const remainingSlots = Math.max(0, maxFiles - modified.length); + const read = summary.readFiles.slice(0, remainingSlots); + + return { modified, read }; +} From 3dae5bdec3f9cd41fe5af942be2dd5a3ff7cc359 Mon Sep 17 00:00:00 2001 From: kevin rajan Date: Sat, 27 Dec 2025 15:37:28 -0600 Subject: [PATCH 02/11] auto-claude: subtask-1-1 - Extend useGitHubPRs hook to fetch both open and closed PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add state parameter to listPRs API (supports 'open', 'closed', 'all') - Update IPC handler to pass state to GitHub API - Extend useGitHubPRs hook with: - closedPRs state for closed PRs - openCount and closedCount computed values - getPRsByStatus helper function - Parallel fetching of open and closed PRs - Update browser mock to accept state parameter 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../main/ipc-handlers/github/pr-handlers.ts | 8 ++-- .../src/preload/api/modules/github-api.ts | 6 +-- .../github-prs/hooks/useGitHubPRs.ts | 43 ++++++++++++++++--- .../frontend/src/renderer/lib/browser-mock.ts | 2 +- 4 files changed, 44 insertions(+), 15 deletions(-) diff --git a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts index 7a0b06f3f2..2468e949ba 100644 --- a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts @@ -331,11 +331,11 @@ export function registerPRHandlers( ): void { debugLog('Registering PR handlers'); - // List open PRs + // List PRs (supports open, closed, or all states) ipcMain.handle( IPC_CHANNELS.GITHUB_PR_LIST, - async (_, projectId: string): Promise => { - debugLog('listPRs handler called', { projectId }); + async (_, projectId: string, state: 'open' | 'closed' | 'all' = 'open'): Promise => { + debugLog('listPRs handler called', { projectId, state }); const result = await withProjectOrNull(projectId, async (project) => { const config = getGitHubConfig(project); if (!config) { @@ -346,7 +346,7 @@ export function registerPRHandlers( try { const prs = await githubFetch( config.token, - `/repos/${config.repo}/pulls?state=open&per_page=50` + `/repos/${config.repo}/pulls?state=${state}&per_page=50` ) as Array<{ number: number; title: string; diff --git a/apps/frontend/src/preload/api/modules/github-api.ts b/apps/frontend/src/preload/api/modules/github-api.ts index 4fb5ff1e01..609f8c11bc 100644 --- a/apps/frontend/src/preload/api/modules/github-api.ts +++ b/apps/frontend/src/preload/api/modules/github-api.ts @@ -234,7 +234,7 @@ export interface GitHubAPI { ) => IpcListenerCleanup; // PR operations - listPRs: (projectId: string) => Promise; + listPRs: (projectId: string, state?: 'open' | 'closed' | 'all') => Promise; runPRReview: (projectId: string, prNumber: number) => void; cancelPRReview: (projectId: string, prNumber: number) => Promise; postPRReview: (projectId: string, prNumber: number, selectedFindingIds?: string[]) => Promise; @@ -529,8 +529,8 @@ export const createGitHubAPI = (): GitHubAPI => ({ createIpcListener(IPC_CHANNELS.GITHUB_AUTOFIX_ANALYZE_PREVIEW_ERROR, callback), // PR operations - listPRs: (projectId: string): Promise => - invokeIpc(IPC_CHANNELS.GITHUB_PR_LIST, projectId), + listPRs: (projectId: string, state?: 'open' | 'closed' | 'all'): Promise => + invokeIpc(IPC_CHANNELS.GITHUB_PR_LIST, projectId, state), runPRReview: (projectId: string, prNumber: number): void => sendIpc(IPC_CHANNELS.GITHUB_PR_REVIEW, projectId, prNumber), diff --git a/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts b/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts index 329e3f0baa..6cda0645e8 100644 --- a/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts +++ b/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts @@ -11,8 +11,14 @@ import { usePRReviewStore, startPRReview as storeStartPRReview, startFollowupRev export type { PRData, PRReviewResult, PRReviewProgress }; export type { PRReviewFinding } from '../../../../preload/api/modules/github-api'; +// PR status filter type +export type PRStatusFilter = 'open' | 'closed'; + interface UseGitHubPRsResult { prs: PRData[]; + closedPRs: PRData[]; + openCount: number; + closedCount: number; isLoading: boolean; error: string | null; selectedPR: PRData | null; @@ -34,10 +40,12 @@ interface UseGitHubPRsResult { mergePR: (prNumber: number, mergeMethod?: 'merge' | 'squash' | 'rebase') => Promise; assignPR: (prNumber: number, username: string) => Promise; getReviewStateForPR: (prNumber: number) => { isReviewing: boolean; progress: PRReviewProgress | null; result: PRReviewResult | null; error: string | null } | null; + getPRsByStatus: (status: PRStatusFilter) => PRData[]; } export function useGitHubPRs(projectId?: string): UseGitHubPRsResult { const [prs, setPrs] = useState([]); + const [closedPRs, setClosedPRs] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [selectedPRNumber, setSelectedPRNumber] = useState(null); @@ -82,7 +90,7 @@ export function useGitHubPRs(projectId?: string): UseGitHubPRsResult { const selectedPR = prs.find(pr => pr.number === selectedPRNumber) || null; - // Check connection and fetch PRs + // Check connection and fetch PRs (both open and closed) const fetchPRs = useCallback(async () => { if (!projectId) return; @@ -97,13 +105,17 @@ export function useGitHubPRs(projectId?: string): UseGitHubPRsResult { setRepoFullName(connectionResult.data.repoFullName || null); if (connectionResult.data.connected) { - // Fetch PRs - const result = await window.electronAPI.github.listPRs(projectId); - if (result) { - setPrs(result); + // Fetch open and closed PRs in parallel + const [openPRs, closedPRsResult] = await Promise.all([ + window.electronAPI.github.listPRs(projectId, 'open'), + window.electronAPI.github.listPRs(projectId, 'closed') + ]); - // Preload review results for all PRs - result.forEach(pr => { + if (openPRs) { + setPrs(openPRs); + + // Preload review results for open PRs + openPRs.forEach(pr => { const existingState = getPRReviewState(projectId, pr.number); // Only fetch from disk if we don't have a result in the store if (!existingState?.result) { @@ -116,6 +128,10 @@ export function useGitHubPRs(projectId?: string): UseGitHubPRsResult { } }); } + + if (closedPRsResult) { + setClosedPRs(closedPRsResult); + } } } else { setIsConnected(false); @@ -260,8 +276,20 @@ export function useGitHubPRs(projectId?: string): UseGitHubPRsResult { } }, [projectId, fetchPRs]); + // Computed counts for open and closed PRs + const openCount = prs.length; + const closedCount = closedPRs.length; + + // Helper to get PRs by status + const getPRsByStatus = useCallback((status: PRStatusFilter): PRData[] => { + return status === 'open' ? prs : closedPRs; + }, [prs, closedPRs]); + return { prs, + closedPRs, + openCount, + closedCount, isLoading, error, selectedPR, @@ -283,5 +311,6 @@ export function useGitHubPRs(projectId?: string): UseGitHubPRsResult { mergePR, assignPR, getReviewStateForPR, + getPRsByStatus, }; } diff --git a/apps/frontend/src/renderer/lib/browser-mock.ts b/apps/frontend/src/renderer/lib/browser-mock.ts index aea4fb002e..c1f8290cb1 100644 --- a/apps/frontend/src/renderer/lib/browser-mock.ts +++ b/apps/frontend/src/renderer/lib/browser-mock.ts @@ -145,7 +145,7 @@ const browserMockAPI: ElectronAPI = { onAutoFixProgress: () => () => {}, onAutoFixComplete: () => () => {}, onAutoFixError: () => () => {}, - listPRs: async () => [], + listPRs: async (_projectId: string, _state?: 'open' | 'closed' | 'all') => [], runPRReview: () => {}, cancelPRReview: async () => true, postPRReview: async () => true, From c445204a642916c20e6999628699092382e86c12 Mon Sep 17 00:00:00 2001 From: kevin rajan Date: Sat, 27 Dec 2025 15:40:21 -0600 Subject: [PATCH 03/11] auto-claude: subtask-2-1 - Create StatusTabs component with Open/Closed toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add StatusTabs component with Open/Closed toggle buttons and counts - Include GitPullRequest icon for Open, Check icon for Closed - Support activeTab prop and onTabChange callback - Apply GitHub dark theme styling with proper color classes - Export PRStatusFilter type for use by parent components 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../github-prs/components/StatusTabs.tsx | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 apps/frontend/src/renderer/components/github-prs/components/StatusTabs.tsx diff --git a/apps/frontend/src/renderer/components/github-prs/components/StatusTabs.tsx b/apps/frontend/src/renderer/components/github-prs/components/StatusTabs.tsx new file mode 100644 index 0000000000..8b470d3451 --- /dev/null +++ b/apps/frontend/src/renderer/components/github-prs/components/StatusTabs.tsx @@ -0,0 +1,87 @@ +import * as React from 'react'; +import { GitPullRequest, Check } from 'lucide-react'; +import { cn } from '../../../lib/utils'; + +export type PRStatusFilter = 'open' | 'closed'; + +export interface StatusTabsProps { + /** Currently active tab */ + activeTab: PRStatusFilter; + /** Number of open PRs */ + openCount: number; + /** Number of closed PRs */ + closedCount: number; + /** Callback when tab is changed */ + onTabChange: (tab: PRStatusFilter) => void; + /** Optional additional className */ + className?: string; +} + +/** + * StatusTabs component displays Open/Closed toggle buttons with counts. + * Styled to match GitHub's PR list UI with dark theme colors. + */ +export function StatusTabs({ + activeTab, + openCount, + closedCount, + onTabChange, + className, +}: StatusTabsProps) { + return ( +
+ onTabChange('open')} + icon={} + label="Open" + count={openCount} + /> + onTabChange('closed')} + icon={} + label="Closed" + count={closedCount} + /> +
+ ); +} + +interface StatusTabButtonProps { + isActive: boolean; + onClick: () => void; + icon: React.ReactNode; + label: string; + count: number; +} + +function StatusTabButton({ + isActive, + onClick, + icon, + label, + count, +}: StatusTabButtonProps) { + return ( + + ); +} + +StatusTabs.displayName = 'StatusTabs'; From 9c58f4fff0aa857f577a225c889ae21bae09d4cb Mon Sep 17 00:00:00 2001 From: kevin rajan Date: Sat, 27 Dec 2025 15:43:22 -0600 Subject: [PATCH 04/11] auto-claude: subtask-2-2 - Create FilterDropdowns component with Author, Label, Sort filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added FilterDropdowns component with three dropdown filters: - Author filter with dynamic author list - Label filter with dynamic label list - Sort dropdown with newest/oldest/most-commented/etc options - Uses Radix UI DropdownMenu primitives following existing patterns - Includes check marks for selected items - Compact horizontal layout with chevron icons - Proper TypeScript interfaces (SortOption, FilterDropdownsProps exported) - Follows GitHub dark theme styling conventions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../github-prs/components/FilterDropdowns.tsx | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 apps/frontend/src/renderer/components/github-prs/components/FilterDropdowns.tsx diff --git a/apps/frontend/src/renderer/components/github-prs/components/FilterDropdowns.tsx b/apps/frontend/src/renderer/components/github-prs/components/FilterDropdowns.tsx new file mode 100644 index 0000000000..d7eb666013 --- /dev/null +++ b/apps/frontend/src/renderer/components/github-prs/components/FilterDropdowns.tsx @@ -0,0 +1,204 @@ +import * as React from 'react'; +import { ChevronDown, Check } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '../../ui/dropdown-menu'; +import { cn } from '../../../lib/utils'; + +export type SortOption = 'newest' | 'oldest' | 'most-commented' | 'least-commented' | 'recently-updated' | 'least-recently-updated'; + +export interface FilterDropdownsProps { + /** List of unique authors (usernames) for the Author filter */ + authors?: string[]; + /** List of unique labels for the Label filter */ + labels?: string[]; + /** Currently selected author filter (undefined means "all") */ + selectedAuthor?: string; + /** Currently selected label filter (undefined means "all") */ + selectedLabel?: string; + /** Currently selected sort option */ + selectedSort?: SortOption; + /** Callback when author filter changes */ + onAuthorChange?: (author: string | undefined) => void; + /** Callback when label filter changes */ + onLabelChange?: (label: string | undefined) => void; + /** Callback when sort option changes */ + onSortChange?: (sort: SortOption) => void; + /** Optional additional className */ + className?: string; +} + +const SORT_OPTIONS: { value: SortOption; label: string }[] = [ + { value: 'newest', label: 'Newest' }, + { value: 'oldest', label: 'Oldest' }, + { value: 'most-commented', label: 'Most commented' }, + { value: 'least-commented', label: 'Least commented' }, + { value: 'recently-updated', label: 'Recently updated' }, + { value: 'least-recently-updated', label: 'Least recently updated' }, +]; + +/** + * FilterDropdowns component provides Author, Label, and Sort filter dropdowns. + * Styled to match GitHub's PR list UI with compact horizontal layout. + */ +export function FilterDropdowns({ + authors = [], + labels = [], + selectedAuthor, + selectedLabel, + selectedSort = 'newest', + onAuthorChange, + onLabelChange, + onSortChange, + className, +}: FilterDropdownsProps) { + return ( +
+ {/* Author Filter */} + + + {/* Label Filter */} + + + {/* Sort Dropdown */} + +
+ ); +} + +interface FilterDropdownProps { + label: string; + value?: string; + options: string[]; + onChange?: (value: string | undefined) => void; + placeholder: string; +} + +function FilterDropdown({ + label, + value, + options, + onChange, + placeholder, +}: FilterDropdownProps) { + const handleSelect = (option: string | undefined) => { + onChange?.(option); + }; + + return ( + + + + + + + Filter by {label.toLowerCase()} + + + {/* Clear option */} + handleSelect(undefined)} + className="text-muted-foreground" + > + + All {label.toLowerCase()}s + + + {options.length > 0 ? ( + options.map((option) => ( + handleSelect(option)} + > + + {option} + + )) + ) : ( + + No {label.toLowerCase()}s found + + )} + + + ); +} + +interface SortDropdownProps { + value: SortOption; + onChange?: (value: SortOption) => void; +} + +function SortDropdown({ value, onChange }: SortDropdownProps) { + const currentLabel = SORT_OPTIONS.find((opt) => opt.value === value)?.label || 'Sort'; + + return ( + + + + + + + Sort by + + + {SORT_OPTIONS.map((option) => ( + onChange?.(option.value)} + > + + {option.label} + + ))} + + + ); +} + +FilterDropdowns.displayName = 'FilterDropdowns'; From 455963c780f4eaf2f96e822b5ecdea4c8eff5540 Mon Sep 17 00:00:00 2001 From: kevin rajan Date: Sat, 27 Dec 2025 15:45:41 -0600 Subject: [PATCH 05/11] auto-claude: subtask-2-3 - Create PRListHeader component with search bar, metadata badges, and New PR button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PRListHeader component with search input ('is:pr is:open' placeholder) - Add Labels and Milestones metadata badge buttons with counts - Add green 'New pull request' button that opens GitHub compare page - Add refresh button with loading state - Follow IssueListHeader patterns for consistent styling - Export PRListHeaderProps interface for type-safe usage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../github-prs/components/PRListHeader.tsx | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 apps/frontend/src/renderer/components/github-prs/components/PRListHeader.tsx diff --git a/apps/frontend/src/renderer/components/github-prs/components/PRListHeader.tsx b/apps/frontend/src/renderer/components/github-prs/components/PRListHeader.tsx new file mode 100644 index 0000000000..9ce0454915 --- /dev/null +++ b/apps/frontend/src/renderer/components/github-prs/components/PRListHeader.tsx @@ -0,0 +1,138 @@ +import * as React from 'react'; +import { Search, Tag, Milestone, RefreshCw, ExternalLink } from 'lucide-react'; +import { Badge } from '../../ui/badge'; +import { Button } from '../../ui/button'; +import { Input } from '../../ui/input'; +import { cn } from '../../../lib/utils'; + +export interface PRListHeaderProps { + /** Repository full name (e.g., "owner/repo") */ + repoFullName?: string; + /** Current search query */ + searchQuery: string; + /** Callback when search query changes */ + onSearchChange: (query: string) => void; + /** Number of labels in the repository (placeholder) */ + labelsCount?: number; + /** Number of milestones in the repository (placeholder) */ + milestonesCount?: number; + /** Whether data is currently loading */ + isLoading?: boolean; + /** Callback when refresh is requested */ + onRefresh?: () => void; + /** Optional additional className */ + className?: string; +} + +/** + * PRListHeader component displays the header section of the PR list. + * Includes search bar, metadata badges (Labels, Milestones), and New PR button. + * Styled to match GitHub's PR list UI with dark theme colors. + */ +export function PRListHeader({ + repoFullName, + searchQuery, + onSearchChange, + labelsCount = 0, + milestonesCount = 0, + isLoading = false, + onRefresh, + className, +}: PRListHeaderProps) { + // Construct the GitHub new PR URL + const newPRUrl = repoFullName + ? `https://github.com/${repoFullName}/compare` + : undefined; + + const handleNewPRClick = () => { + if (newPRUrl) { + window.open(newPRUrl, '_blank', 'noopener,noreferrer'); + } + }; + + return ( +
+ {/* Search and Actions Row */} +
+ {/* Search Input */} +
+ + onSearchChange(e.target.value)} + className="pl-9" + /> +
+ + {/* Metadata Badges */} +
+ } + label="Labels" + count={labelsCount} + /> + } + label="Milestones" + count={milestonesCount} + /> +
+ + {/* Refresh Button */} + {onRefresh && ( + + )} + + {/* New Pull Request Button */} + +
+
+ ); +} + +interface MetadataBadgeProps { + icon: React.ReactNode; + label: string; + count: number; +} + +function MetadataBadge({ icon, label, count }: MetadataBadgeProps) { + return ( + + ); +} + +PRListHeader.displayName = 'PRListHeader'; From ebe817ade91bc2328704645049d0a15c0c40e176 Mon Sep 17 00:00:00 2001 From: kevin rajan Date: Sat, 27 Dec 2025 15:47:50 -0600 Subject: [PATCH 06/11] auto-claude: subtask-2-4 - Create PRListControls component combining StatusTabs and FilterDropdowns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Creates PRListControls container component that combines StatusTabs and FilterDropdowns - Renders StatusTabs (Open/Closed toggle with counts) above FilterDropdowns (Author, Label, Sort) - Passes through all props for child components with proper TypeScript interfaces - Uses compact horizontal layout with justify-between for tabs and filters - Follows established patterns from IssueListHeader and existing PR components 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../github-prs/components/PRListControls.tsx | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 apps/frontend/src/renderer/components/github-prs/components/PRListControls.tsx diff --git a/apps/frontend/src/renderer/components/github-prs/components/PRListControls.tsx b/apps/frontend/src/renderer/components/github-prs/components/PRListControls.tsx new file mode 100644 index 0000000000..58fb964294 --- /dev/null +++ b/apps/frontend/src/renderer/components/github-prs/components/PRListControls.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; +import { StatusTabs, type PRStatusFilter } from './StatusTabs'; +import { FilterDropdowns, type SortOption } from './FilterDropdowns'; +import { cn } from '../../../lib/utils'; + +export interface PRListControlsProps { + /** Currently active status tab */ + activeTab: PRStatusFilter; + /** Number of open PRs */ + openCount: number; + /** Number of closed PRs */ + closedCount: number; + /** Callback when status tab is changed */ + onTabChange: (tab: PRStatusFilter) => void; + /** List of unique authors (usernames) for the Author filter */ + authors?: string[]; + /** List of unique labels for the Label filter */ + labels?: string[]; + /** Currently selected author filter (undefined means "all") */ + selectedAuthor?: string; + /** Currently selected label filter (undefined means "all") */ + selectedLabel?: string; + /** Currently selected sort option */ + selectedSort?: SortOption; + /** Callback when author filter changes */ + onAuthorChange?: (author: string | undefined) => void; + /** Callback when label filter changes */ + onLabelChange?: (label: string | undefined) => void; + /** Callback when sort option changes */ + onSortChange?: (sort: SortOption) => void; + /** Optional additional className */ + className?: string; +} + +/** + * PRListControls component combines StatusTabs and FilterDropdowns. + * Renders StatusTabs above FilterDropdowns in a compact layout. + * Styled to match GitHub's PR list UI with dark theme colors. + */ +export function PRListControls({ + activeTab, + openCount, + closedCount, + onTabChange, + authors, + labels, + selectedAuthor, + selectedLabel, + selectedSort, + onAuthorChange, + onLabelChange, + onSortChange, + className, +}: PRListControlsProps) { + return ( +
+ {/* Status Tabs and Filters Row */} +
+ {/* Status Tabs (Open/Closed) */} + + + {/* Filter Dropdowns */} + +
+
+ ); +} + +PRListControls.displayName = 'PRListControls'; From 716e12afb5064e5fb1f8f6f2e529aa3d4638d79e Mon Sep 17 00:00:00 2001 From: kevin rajan Date: Sat, 27 Dec 2025 15:49:00 -0600 Subject: [PATCH 07/11] auto-claude: subtask-2-5 - Update components index to export new components --- .../src/renderer/components/github-prs/components/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/frontend/src/renderer/components/github-prs/components/index.ts b/apps/frontend/src/renderer/components/github-prs/components/index.ts index 6643498954..e68424ef96 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/index.ts +++ b/apps/frontend/src/renderer/components/github-prs/components/index.ts @@ -1,2 +1,6 @@ export { PRList } from './PRList'; export { PRDetail } from './PRDetail'; +export { PRListHeader } from './PRListHeader'; +export { PRListControls } from './PRListControls'; +export { StatusTabs } from './StatusTabs'; +export { FilterDropdowns } from './FilterDropdowns'; From 403669c65067ad288b9a881aea7b1ea6a8ad38a2 Mon Sep 17 00:00:00 2001 From: kevin rajan Date: Sat, 27 Dec 2025 15:50:40 -0600 Subject: [PATCH 08/11] auto-claude: subtask-3-1 - Add statusFilter prop to PRList component for open/closed filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added optional statusFilter prop to PRListProps interface with type 'open' | 'closed' - Updated PRList function signature to accept and default statusFilter to 'open' - Updated empty state message to dynamically show filter state ("No {open|closed} pull requests") 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/frontend/package-lock.json | 58 +++++++++---------- .../github-prs/components/PRList.tsx | 5 +- 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index ce812372e9..fd6a1b0e1a 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "auto-claude-ui", - "version": "2.7.2", + "version": "2.7.2-beta.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "auto-claude-ui", - "version": "2.7.2", + "version": "2.7.2-beta.10", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -196,7 +196,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -581,7 +580,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -625,7 +623,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -665,7 +662,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1072,6 +1068,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1093,6 +1090,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -4215,7 +4213,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4402,7 +4401,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4413,7 +4411,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4505,7 +4502,6 @@ "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -4918,7 +4914,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4979,7 +4974,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5152,6 +5146,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -5546,7 +5541,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6217,7 +6211,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -6567,7 +6562,6 @@ "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.0.12", "builder-util": "26.0.11", @@ -6625,7 +6619,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dotenv": { "version": "16.6.1", @@ -6701,7 +6696,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", @@ -6830,6 +6824,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -6850,6 +6845,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -6865,6 +6861,7 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", + "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -6875,6 +6872,7 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -7244,7 +7242,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8490,7 +8487,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -9281,7 +9277,6 @@ "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -10213,6 +10208,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -12403,7 +12399,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12501,7 +12496,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -12538,6 +12532,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -12555,6 +12550,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -12575,6 +12571,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -12590,6 +12587,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -12602,7 +12600,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/proc-log": { "version": "2.0.1", @@ -12706,7 +12705,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12716,7 +12714,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14036,8 +14033,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -14094,6 +14090,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -14120,6 +14117,7 @@ "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -14141,6 +14139,7 @@ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -14154,6 +14153,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -14168,6 +14168,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -14484,7 +14485,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14834,7 +14834,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -15876,7 +15875,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx b/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx index 4b95e53e0a..86858499a0 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx @@ -107,6 +107,7 @@ interface PRListProps { activePRReviews: number[]; getReviewStateForPR: (prNumber: number) => PRReviewInfo | null; onSelectPR: (prNumber: number) => void; + statusFilter?: 'open' | 'closed'; } function formatDate(dateString: string): string { @@ -129,7 +130,7 @@ function formatDate(dateString: string): string { return date.toLocaleDateString(); } -export function PRList({ prs, selectedPRNumber, isLoading, error, activePRReviews, getReviewStateForPR, onSelectPR }: PRListProps) { +export function PRList({ prs, selectedPRNumber, isLoading, error, activePRReviews, getReviewStateForPR, onSelectPR, statusFilter = 'open' }: PRListProps) { const { t } = useTranslation('common'); if (isLoading && prs.length === 0) { @@ -158,7 +159,7 @@ export function PRList({ prs, selectedPRNumber, isLoading, error, activePRReview
-

No open pull requests

+

No {statusFilter} pull requests

); From 2282e9ce4b5ed1c8e1eb4b6ea33a8359dea6b5d8 Mon Sep 17 00:00:00 2001 From: kevin rajan Date: Sat, 27 Dec 2025 15:56:26 -0600 Subject: [PATCH 09/11] auto-claude: subtask-4-1 - Integrate PRListHeader, PRListControls into GitHubPRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace current header with new PRListHeader component - Search bar with 'is:pr is:open' placeholder - Labels and Milestones metadata badges (placeholder) - Green 'New pull request' button - Refresh button with loading state - Add PRListControls below header - StatusTabs for Open/Closed PR filtering with counts - FilterDropdowns for Author, Label, and Sort - Add state management for: - activeTab (open/closed) with PRStatusFilter type - searchQuery for filtering PRs - selectedAuthor, selectedLabel, selectedSort filters - Implement filtering and sorting logic: - Filter by author (functional) - Filter by search query (title/author/branch/number) - Sort by newest/oldest/recently-updated/least-recently-updated - Label and comment filtering are placeholders (PRData doesn't include these) - Wire up PRList to use filtered PRs and statusFilter prop 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../components/github-prs/GitHubPRs.tsx | 144 +++++++++++++----- 1 file changed, 106 insertions(+), 38 deletions(-) diff --git a/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx b/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx index fd8785ac39..d11b4c3bc7 100644 --- a/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx +++ b/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx @@ -1,9 +1,11 @@ -import { useCallback } from 'react'; -import { GitPullRequest, RefreshCw, ExternalLink, Settings } from 'lucide-react'; +import { useState, useCallback, useMemo } from 'react'; +import { GitPullRequest, Settings } from 'lucide-react'; import { useProjectStore } from '../../stores/project-store'; import { useGitHubPRs } from './hooks'; -import { PRList, PRDetail } from './components'; +import { PRList, PRDetail, PRListHeader, PRListControls } from './components'; import { Button } from '../ui/button'; +import type { PRStatusFilter } from './components/StatusTabs'; +import type { SortOption } from './components/FilterDropdowns'; interface GitHubPRsProps { onOpenSettings?: () => void; @@ -73,9 +75,80 @@ export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) { isConnected, repoFullName, getReviewStateForPR, + openCount, + closedCount, + getPRsByStatus, } = useGitHubPRs(selectedProject?.id); - const selectedPR = prs.find(pr => pr.number === selectedPRNumber); + // State for status tab (open/closed) + const [activeTab, setActiveTab] = useState('open'); + // State for search query + const [searchQuery, setSearchQuery] = useState(''); + // State for filter dropdowns + const [selectedAuthor, setSelectedAuthor] = useState(undefined); + const [selectedLabel, setSelectedLabel] = useState(undefined); + const [selectedSort, setSelectedSort] = useState('newest'); + + // Get PRs for current status tab + const currentPRs = useMemo(() => getPRsByStatus(activeTab), [getPRsByStatus, activeTab]); + + // Apply filters and search to current PRs + const filteredPRs = useMemo(() => { + let result = currentPRs; + + // Filter by author + if (selectedAuthor) { + result = result.filter(pr => pr.author.login === selectedAuthor); + } + + // Note: Label filtering is a placeholder - PRData doesn't include labels currently + // The UI shows the Label dropdown but it won't filter until the API includes labels + + // Filter by search query (simple title/author/branch search) + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase().trim(); + result = result.filter(pr => + pr.title.toLowerCase().includes(query) || + pr.author.login.toLowerCase().includes(query) || + pr.headRefName.toLowerCase().includes(query) || + `#${pr.number}`.includes(query) + ); + } + + // Sort PRs + result = [...result].sort((a, b) => { + switch (selectedSort) { + case 'oldest': + return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + case 'recently-updated': + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + case 'least-recently-updated': + return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(); + // Note: Comment-based sorting is a placeholder - PRData doesn't include comment count + // Fallback to newest for unsupported sort options + case 'most-commented': + case 'least-commented': + case 'newest': + default: + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + } + }); + + return result; + }, [currentPRs, selectedAuthor, searchQuery, selectedSort]); + + // Derive unique authors from all PRs (both open and closed) for filter dropdown + const uniqueAuthors = useMemo(() => { + const allPRs = [...getPRsByStatus('open'), ...getPRsByStatus('closed')]; + const authors = new Set(allPRs.map(pr => pr.author.login)); + return Array.from(authors).sort(); + }, [getPRsByStatus]); + + // Note: Labels are a placeholder - PRData doesn't include labels currently + // Return empty array for now; dropdown will show "No labels found" + const uniqueLabels: string[] = []; + + const selectedPR = filteredPRs.find(pr => pr.number === selectedPRNumber) || prs.find(pr => pr.number === selectedPRNumber); const handleRunReview = useCallback(() => { if (selectedPRNumber) { @@ -134,50 +207,45 @@ export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) { return (
- {/* Header */} -
-
-

- - Pull Requests -

- {repoFullName && ( - - {repoFullName} - - - )} - - {prs.length} open - -
- -
- {/* Content */}
- {/* PR List */} + {/* PR List Panel */}
+ {/* Header with search, badges, and New PR button */} + + + {/* Controls with status tabs and filter dropdowns */} + + + {/* PR List */}
From aa966bf13fe155278ad55d9af3f58e6bfc88cf4f Mon Sep 17 00:00:00 2001 From: kevin rajan Date: Sat, 27 Dec 2025 16:00:25 -0600 Subject: [PATCH 10/11] auto-claude: subtask-5-1 - Apply GitHub dark theme colors and spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Apply GitHub dark theme border color #30363d to all PR components - Use GitHub green #238636 for New PR button with hover state #2ea043 - Implement consistent 6px border-radius (rounded-[6px]) - Apply compact 8-12px padding (px-2 to px-2.5, py-1 to py-2) - Add GitHub-style hover border color #8b949e for interactive elements - Make count display font-semibold in StatusTabs - Reduce gaps for more compact layout matching GitHub's design 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../github-prs/components/FilterDropdowns.tsx | 18 +++++++++------- .../github-prs/components/PRListControls.tsx | 6 +++--- .../github-prs/components/PRListHeader.tsx | 21 ++++++++++--------- .../github-prs/components/StatusTabs.tsx | 9 ++++---- 4 files changed, 29 insertions(+), 25 deletions(-) diff --git a/apps/frontend/src/renderer/components/github-prs/components/FilterDropdowns.tsx b/apps/frontend/src/renderer/components/github-prs/components/FilterDropdowns.tsx index d7eb666013..f152e2f32c 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/FilterDropdowns.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/FilterDropdowns.tsx @@ -111,16 +111,17 @@ function FilterDropdown({ @@ -171,15 +172,16 @@ function SortDropdown({ value, onChange }: SortDropdownProps) { diff --git a/apps/frontend/src/renderer/components/github-prs/components/PRListControls.tsx b/apps/frontend/src/renderer/components/github-prs/components/PRListControls.tsx index 58fb964294..f73fcad605 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/PRListControls.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/PRListControls.tsx @@ -53,9 +53,9 @@ export function PRListControls({ className, }: PRListControlsProps) { return ( -
- {/* Status Tabs and Filters Row */} -
+
+ {/* Status Tabs and Filters Row - compact 8-12px padding */} +
{/* Status Tabs (Open/Closed) */} +
{/* Search and Actions Row */} -
+
{/* Search Input */}
- + onSearchChange(e.target.value)} - className="pl-9" + className="pl-8 h-8 rounded-[6px] border-[#30363d] focus:border-[#8b949e]" />
@@ -92,13 +92,13 @@ export function PRListHeader({ )} - {/* New Pull Request Button */} + {/* New Pull Request Button - GitHub green #238636 */} diff --git a/apps/frontend/src/renderer/components/github-prs/components/StatusTabs.tsx b/apps/frontend/src/renderer/components/github-prs/components/StatusTabs.tsx index 8b470d3451..ab20e6ccf8 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/StatusTabs.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/StatusTabs.tsx @@ -29,7 +29,7 @@ export function StatusTabs({ className, }: StatusTabsProps) { return ( -
+
onTabChange('open')} @@ -68,17 +68,18 @@ function StatusTabButton({ type="button" onClick={onClick} className={cn( - 'inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium', + // GitHub-style compact sizing: 8-12px padding, 6px border-radius + 'inline-flex items-center gap-1.5 px-2 py-1 text-sm font-medium', 'transition-colors duration-200', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', - 'rounded-md', + 'rounded-[6px]', isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground' )} > {icon} - {count.toLocaleString()} + {count.toLocaleString()} {label} ); From 856897bf04598a5350d5048f5bc11d6b2baeddae Mon Sep 17 00:00:00 2001 From: kevin rajan Date: Sat, 27 Dec 2025 21:42:08 -0600 Subject: [PATCH 11/11] fix: Address QA issues - search parsing and dropdown scrolling (qa-requested) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: - Search bar now parses GitHub-style query syntax (is:pr, is:open, is:closed, author:xxx) - Authors filter dropdown now has scrolling with max-height of 300px Changes: - GitHubPRs.tsx: Added parseSearchQuery() function to parse GitHub-style qualifiers - Supports: is:pr, is:open, is:closed, author:username - Search qualifiers override the tab selection (e.g., "is:closed" shows closed PRs) - Text search still works for title, author, branch, and PR number - FilterDropdowns.tsx: Added max-h-[300px] and overflow-y-auto to dropdown content - Label header is now sticky at top during scroll Verified: - TypeScript compiles without errors - All 792 tests pass QA Fix Session: 0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../components/github-prs/GitHubPRs.tsx | 74 +++++++++++++++---- .../github-prs/components/FilterDropdowns.tsx | 4 +- 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx b/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx index d11b4c3bc7..c071dd6e17 100644 --- a/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx +++ b/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx @@ -89,29 +89,77 @@ export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) { const [selectedLabel, setSelectedLabel] = useState(undefined); const [selectedSort, setSelectedSort] = useState('newest'); - // Get PRs for current status tab - const currentPRs = useMemo(() => getPRsByStatus(activeTab), [getPRsByStatus, activeTab]); + // Parse GitHub-style search query (e.g., "is:pr is:open author:username search terms") + const parseSearchQuery = useCallback((query: string) => { + const qualifiers: { + isOpen?: boolean; + isClosed?: boolean; + author?: string; + text: string; + } = { text: '' }; + + // Split on spaces but preserve quoted strings + const parts = query.match(/(?:[^\s"]+|"[^"]*")+/g) || []; + const textParts: string[] = []; + + for (const part of parts) { + const lowerPart = part.toLowerCase(); + + if (lowerPart === 'is:pr') { + // Ignore - we're already filtering PRs + continue; + } else if (lowerPart === 'is:open') { + qualifiers.isOpen = true; + qualifiers.isClosed = false; + } else if (lowerPart === 'is:closed') { + qualifiers.isClosed = true; + qualifiers.isOpen = false; + } else if (lowerPart.startsWith('author:')) { + qualifiers.author = part.substring(7).replace(/^"|"$/g, ''); + } else { + textParts.push(part); + } + } + + qualifiers.text = textParts.join(' ').toLowerCase().trim(); + return qualifiers; + }, []); + + // Parse the search query to extract qualifiers + const parsedQuery = useMemo(() => parseSearchQuery(searchQuery), [parseSearchQuery, searchQuery]); + + // Determine effective tab based on search qualifiers (query overrides tab if explicit) + const effectiveTab = useMemo(() => { + if (parsedQuery.isOpen) return 'open'; + if (parsedQuery.isClosed) return 'closed'; + return activeTab; + }, [parsedQuery.isOpen, parsedQuery.isClosed, activeTab]); + + // Get PRs for current status tab (considering search qualifiers) + const currentPRs = useMemo(() => getPRsByStatus(effectiveTab), [getPRsByStatus, effectiveTab]); // Apply filters and search to current PRs const filteredPRs = useMemo(() => { let result = currentPRs; - // Filter by author - if (selectedAuthor) { - result = result.filter(pr => pr.author.login === selectedAuthor); + // Filter by author (from dropdown or search qualifier) + const authorFilter = parsedQuery.author || selectedAuthor; + if (authorFilter) { + result = result.filter(pr => + pr.author.login.toLowerCase() === authorFilter.toLowerCase() + ); } // Note: Label filtering is a placeholder - PRData doesn't include labels currently // The UI shows the Label dropdown but it won't filter until the API includes labels - // Filter by search query (simple title/author/branch search) - if (searchQuery.trim()) { - const query = searchQuery.toLowerCase().trim(); + // Filter by text portion of search query (title/author/branch/number) + if (parsedQuery.text) { result = result.filter(pr => - pr.title.toLowerCase().includes(query) || - pr.author.login.toLowerCase().includes(query) || - pr.headRefName.toLowerCase().includes(query) || - `#${pr.number}`.includes(query) + pr.title.toLowerCase().includes(parsedQuery.text) || + pr.author.login.toLowerCase().includes(parsedQuery.text) || + pr.headRefName.toLowerCase().includes(parsedQuery.text) || + `#${pr.number}`.includes(parsedQuery.text) ); } @@ -135,7 +183,7 @@ export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) { }); return result; - }, [currentPRs, selectedAuthor, searchQuery, selectedSort]); + }, [currentPRs, selectedAuthor, parsedQuery, selectedSort]); // Derive unique authors from all PRs (both open and closed) for filter dropdown const uniqueAuthors = useMemo(() => { diff --git a/apps/frontend/src/renderer/components/github-prs/components/FilterDropdowns.tsx b/apps/frontend/src/renderer/components/github-prs/components/FilterDropdowns.tsx index f152e2f32c..010962fb0e 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/FilterDropdowns.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/FilterDropdowns.tsx @@ -124,8 +124,8 @@ function FilterDropdown({ - - + + Filter by {label.toLowerCase()}