From 9bb52f1ded70801fdd1ee980fb4ad5aadf1a748e Mon Sep 17 00:00:00 2001 From: DhanushSantosh Date: Mon, 19 Jan 2026 19:38:56 +0530 Subject: [PATCH] perf(ui): smooth large lists and graphs --- TODO.md | 8 + apps/ui/src/components/views/board-view.tsx | 27 +- .../kanban-card/agent-info-panel.tsx | 6 +- .../components/kanban-card/card-actions.tsx | 5 +- .../components/kanban-card/card-badges.tsx | 18 +- .../kanban-card/card-content-sections.tsx | 8 +- .../components/kanban-card/card-header.tsx | 6 +- .../components/kanban-card/kanban-card.tsx | 17 +- .../board-view/components/kanban-column.tsx | 21 +- .../hooks/use-board-column-features.ts | 13 +- .../views/board-view/kanban-board.tsx | 692 +++++++++++++----- apps/ui/src/components/views/chat-history.tsx | 239 ++++-- .../src/components/views/graph-view-page.tsx | 16 +- .../graph-view/components/dependency-edge.tsx | 48 ++ .../views/graph-view/components/task-node.tsx | 98 ++- .../components/views/graph-view/constants.ts | 7 + .../views/graph-view/graph-canvas.tsx | 24 +- .../views/graph-view/graph-view.tsx | 2 +- .../graph-view/hooks/use-graph-filter.ts | 40 +- .../views/graph-view/hooks/use-graph-nodes.ts | 23 +- .../providers/opencode-settings-tab.tsx | 1 + apps/ui/src/hooks/queries/use-features.ts | 9 + .../src/hooks/queries/use-running-agents.ts | 5 + apps/ui/src/hooks/queries/use-usage.ts | 6 + apps/ui/src/hooks/queries/use-worktrees.ts | 17 + apps/ui/src/routes/__root.tsx | 5 +- apps/ui/src/styles/global.css | 5 + libs/dependency-resolver/src/index.ts | 2 + libs/dependency-resolver/src/resolver.ts | 43 ++ .../tests/resolver.test.ts | 17 + 30 files changed, 1116 insertions(+), 312 deletions(-) create mode 100644 apps/ui/src/components/views/graph-view/constants.ts diff --git a/TODO.md b/TODO.md index 3771806b..4ea7cf34 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,14 @@ - Setting the default model does not seem like it works. +# Performance (completed) + +- [x] Graph performance mode for large graphs (compact nodes/edges + visible-only rendering) +- [x] Render containment on heavy scroll regions (kanban columns, chat history) +- [x] Reduce blur/shadow effects when lists get large +- [x] React Query tuning for heavy datasets (less refetch on focus/reconnect) +- [x] DnD/list rendering optimizations (virtualized kanban + memoized card sections) + # UX - Consolidate all models to a single place in the settings instead of having AI profiles and all this other stuff diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index cfc81497..d2f6c40b 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -35,6 +35,7 @@ import { toast } from 'sonner'; import { getBlockingDependencies } from '@automaker/dependency-resolver'; import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal'; import { Spinner } from '@/components/ui/spinner'; +import { useShallow } from 'zustand/react/shallow'; import { useAutoMode } from '@/hooks/use-auto-mode'; import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; import { useWindowState } from '@/hooks/use-window-state'; @@ -112,7 +113,31 @@ export function BoardView() { isPrimaryWorktreeBranch, getPrimaryWorktreeBranch, setPipelineConfig, - } = useAppStore(); + } = useAppStore( + useShallow((state) => ({ + currentProject: state.currentProject, + maxConcurrency: state.maxConcurrency, + setMaxConcurrency: state.setMaxConcurrency, + defaultSkipTests: state.defaultSkipTests, + specCreatingForProject: state.specCreatingForProject, + setSpecCreatingForProject: state.setSpecCreatingForProject, + pendingPlanApproval: state.pendingPlanApproval, + setPendingPlanApproval: state.setPendingPlanApproval, + updateFeature: state.updateFeature, + getCurrentWorktree: state.getCurrentWorktree, + setCurrentWorktree: state.setCurrentWorktree, + getWorktrees: state.getWorktrees, + setWorktrees: state.setWorktrees, + useWorktrees: state.useWorktrees, + enableDependencyBlocking: state.enableDependencyBlocking, + skipVerificationInAutoMode: state.skipVerificationInAutoMode, + planUseSelectedWorktreeBranch: state.planUseSelectedWorktreeBranch, + addFeatureUseSelectedWorktreeBranch: state.addFeatureUseSelectedWorktreeBranch, + isPrimaryWorktreeBranch: state.isPrimaryWorktreeBranch, + getPrimaryWorktreeBranch: state.getPrimaryWorktreeBranch, + setPipelineConfig: state.setPipelineConfig, + })) + ); // Fetch pipeline config via React Query const { data: pipelineConfig } = usePipelineConfig(currentProject?.path); const queryClient = useQueryClient(); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx index bb9f893f..9cd9d793 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useMemo } from 'react'; +import { memo, useEffect, useState, useMemo } from 'react'; import { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store'; import type { ReasoningEffort } from '@automaker/types'; import { getProviderFromModel } from '@/lib/utils'; @@ -56,7 +56,7 @@ interface AgentInfoPanelProps { isCurrentAutoTask?: boolean; } -export function AgentInfoPanel({ +export const AgentInfoPanel = memo(function AgentInfoPanel({ feature, projectPath, contextContent, @@ -405,4 +405,4 @@ export function AgentInfoPanel({ onOpenChange={setIsSummaryDialogOpen} /> ); -} +}); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx index 7dfa4bef..0151a798 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx @@ -1,4 +1,5 @@ // @ts-nocheck +import { memo } from 'react'; import { Feature } from '@/store/app-store'; import { Button } from '@/components/ui/button'; import { @@ -32,7 +33,7 @@ interface CardActionsProps { onApprovePlan?: () => void; } -export function CardActions({ +export const CardActions = memo(function CardActions({ feature, isCurrentAutoTask, hasContext, @@ -344,4 +345,4 @@ export function CardActions({ )} ); -} +}); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx index 268e67be..e2673415 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx @@ -1,10 +1,11 @@ // @ts-nocheck -import { useEffect, useMemo, useState } from 'react'; +import { memo, useEffect, useMemo, useState } from 'react'; import { Feature, useAppStore } from '@/store/app-store'; import { cn } from '@/lib/utils'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react'; import { getBlockingDependencies } from '@automaker/dependency-resolver'; +import { useShallow } from 'zustand/react/shallow'; /** Uniform badge style for all card badges */ const uniformBadgeClass = @@ -18,7 +19,7 @@ interface CardBadgesProps { * CardBadges - Shows error badges below the card header * Note: Blocked/Lock badges are now shown in PriorityBadges for visual consistency */ -export function CardBadges({ feature }: CardBadgesProps) { +export const CardBadges = memo(function CardBadges({ feature }: CardBadgesProps) { if (!feature.error) { return null; } @@ -46,14 +47,19 @@ export function CardBadges({ feature }: CardBadgesProps) { ); -} +}); interface PriorityBadgesProps { feature: Feature; } -export function PriorityBadges({ feature }: PriorityBadgesProps) { - const { enableDependencyBlocking, features } = useAppStore(); +export const PriorityBadges = memo(function PriorityBadges({ feature }: PriorityBadgesProps) { + const { enableDependencyBlocking, features } = useAppStore( + useShallow((state) => ({ + enableDependencyBlocking: state.enableDependencyBlocking, + features: state.features, + })) + ); const [currentTime, setCurrentTime] = useState(() => Date.now()); // Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies) @@ -223,4 +229,4 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) { )} ); -} +}); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx index 237c0a7e..5b2229d8 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx @@ -1,4 +1,5 @@ // @ts-nocheck +import { memo } from 'react'; import { Feature } from '@/store/app-store'; import { GitBranch, GitPullRequest, ExternalLink } from 'lucide-react'; @@ -7,7 +8,10 @@ interface CardContentSectionsProps { useWorktrees: boolean; } -export function CardContentSections({ feature, useWorktrees }: CardContentSectionsProps) { +export const CardContentSections = memo(function CardContentSections({ + feature, + useWorktrees, +}: CardContentSectionsProps) { return ( <> {/* Target Branch Display */} @@ -48,4 +52,4 @@ export function CardContentSections({ feature, useWorktrees }: CardContentSectio })()} ); -} +}); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx index 73d1dc3a..87a26cdf 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx @@ -1,5 +1,5 @@ // @ts-nocheck -import { useState } from 'react'; +import { memo, useState } from 'react'; import { Feature } from '@/store/app-store'; import { cn } from '@/lib/utils'; import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -37,7 +37,7 @@ interface CardHeaderProps { onSpawnTask?: () => void; } -export function CardHeaderSection({ +export const CardHeaderSection = memo(function CardHeaderSection({ feature, isDraggable, isCurrentAutoTask, @@ -378,4 +378,4 @@ export function CardHeaderSection({ /> ); -} +}); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx index a6f1753f..31863fb5 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx @@ -5,6 +5,7 @@ import { cn } from '@/lib/utils'; import { Card, CardContent } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; import { Feature, useAppStore } from '@/store/app-store'; +import { useShallow } from 'zustand/react/shallow'; import { CardBadges, PriorityBadges } from './card-badges'; import { CardHeaderSection } from './card-header'; import { CardContentSections } from './card-content-sections'; @@ -61,6 +62,7 @@ interface KanbanCardProps { cardBorderEnabled?: boolean; cardBorderOpacity?: number; isOverlay?: boolean; + reduceEffects?: boolean; // Selection mode props isSelectionMode?: boolean; isSelected?: boolean; @@ -94,12 +96,18 @@ export const KanbanCard = memo(function KanbanCard({ cardBorderEnabled = true, cardBorderOpacity = 100, isOverlay, + reduceEffects = false, isSelectionMode = false, isSelected = false, onToggleSelect, selectionTarget = null, }: KanbanCardProps) { - const { useWorktrees, currentProject } = useAppStore(); + const { useWorktrees, currentProject } = useAppStore( + useShallow((state) => ({ + useWorktrees: state.useWorktrees, + currentProject: state.currentProject, + })) + ); const [isLifted, setIsLifted] = useState(false); useLayoutEffect(() => { @@ -140,9 +148,12 @@ export const KanbanCard = memo(function KanbanCard({ const hasError = feature.error && !isCurrentAutoTask; const innerCardClasses = cn( - 'kanban-card-content h-full relative shadow-sm', + 'kanban-card-content h-full relative', + reduceEffects ? 'shadow-none' : 'shadow-sm', 'transition-all duration-200 ease-out', - isInteractive && 'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent', + isInteractive && + !reduceEffects && + 'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent', !glassmorphism && 'backdrop-blur-[0px]!', !isCurrentAutoTask && cardBorderEnabled && diff --git a/apps/ui/src/components/views/board-view/components/kanban-column.tsx b/apps/ui/src/components/views/board-view/components/kanban-column.tsx index 4a1b62dd..1fc1029b 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-column.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-column.tsx @@ -1,7 +1,7 @@ import { memo } from 'react'; import { useDroppable } from '@dnd-kit/core'; import { cn } from '@/lib/utils'; -import type { ReactNode } from 'react'; +import type { CSSProperties, ReactNode, Ref, UIEvent } from 'react'; interface KanbanColumnProps { id: string; @@ -17,6 +17,11 @@ interface KanbanColumnProps { hideScrollbar?: boolean; /** Custom width in pixels. If not provided, defaults to 288px (w-72) */ width?: number; + contentRef?: Ref; + onScroll?: (event: UIEvent) => void; + contentClassName?: string; + contentStyle?: CSSProperties; + disableItemSpacing?: boolean; } export const KanbanColumn = memo(function KanbanColumn({ @@ -31,6 +36,11 @@ export const KanbanColumn = memo(function KanbanColumn({ showBorder = true, hideScrollbar = false, width, + contentRef, + onScroll, + contentClassName, + contentStyle, + disableItemSpacing = false, }: KanbanColumnProps) { const { setNodeRef, isOver } = useDroppable({ id }); @@ -78,14 +88,19 @@ export const KanbanColumn = memo(function KanbanColumn({ {/* Column Content */}
{children}
diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts b/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts index 1d831f4b..8a34bdea 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts @@ -1,7 +1,11 @@ // @ts-nocheck import { useMemo, useCallback } from 'react'; import { Feature, useAppStore } from '@/store/app-store'; -import { resolveDependencies, getBlockingDependencies } from '@automaker/dependency-resolver'; +import { + createFeatureMap, + getBlockingDependenciesFromMap, + resolveDependencies, +} from '@automaker/dependency-resolver'; type ColumnId = Feature['status']; @@ -32,6 +36,8 @@ export function useBoardColumnFeatures({ verified: [], completed: [], // Completed features are shown in the archive modal, not as a column }; + const featureMap = createFeatureMap(features); + const runningTaskIds = new Set(runningAutoTasks); // Filter features by search query (case-insensitive) const normalizedQuery = searchQuery.toLowerCase().trim(); @@ -55,7 +61,7 @@ export function useBoardColumnFeatures({ filteredFeatures.forEach((f) => { // If feature has a running agent, always show it in "in_progress" - const isRunning = runningAutoTasks.includes(f.id); + const isRunning = runningTaskIds.has(f.id); // Check if feature matches the current worktree by branchName // Features without branchName are considered unassigned (show only on primary worktree) @@ -151,7 +157,6 @@ export function useBoardColumnFeatures({ const { orderedFeatures } = resolveDependencies(map.backlog); // Get all features to check blocking dependencies against - const allFeatures = features; const enableDependencyBlocking = useAppStore.getState().enableDependencyBlocking; // Sort blocked features to the end of the backlog @@ -161,7 +166,7 @@ export function useBoardColumnFeatures({ const blocked: Feature[] = []; for (const f of orderedFeatures) { - if (getBlockingDependencies(f, allFeatures).length > 0) { + if (getBlockingDependenciesFromMap(f, featureMap).length > 0) { blocked.push(f); } else { unblocked.push(f); diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index 6ace0e76..4b642ece 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -1,4 +1,5 @@ -import { useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { ReactNode, UIEvent, RefObject } from 'react'; import { DndContext, DragOverlay } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { Button } from '@/components/ui/button'; @@ -64,6 +65,199 @@ interface KanbanBoardProps { className?: string; } +const KANBAN_VIRTUALIZATION_THRESHOLD = 40; +const KANBAN_CARD_ESTIMATED_HEIGHT_PX = 220; +const KANBAN_CARD_GAP_PX = 10; +const KANBAN_OVERSCAN_COUNT = 6; +const VIRTUALIZATION_MEASURE_EPSILON_PX = 1; +const REDUCED_CARD_OPACITY_PERCENT = 85; + +type VirtualListItem = { id: string }; + +interface VirtualListState { + contentRef: RefObject; + onScroll: (event: UIEvent) => void; + itemIds: string[]; + visibleItems: Item[]; + totalHeight: number; + offsetTop: number; + startIndex: number; + shouldVirtualize: boolean; + registerItem: (id: string) => (node: HTMLDivElement | null) => void; +} + +interface VirtualizedListProps { + items: Item[]; + isDragging: boolean; + estimatedItemHeight: number; + itemGap: number; + overscan: number; + virtualizationThreshold: number; + children: (state: VirtualListState) => ReactNode; +} + +function findIndexForOffset(itemEnds: number[], offset: number): number { + let low = 0; + let high = itemEnds.length - 1; + let result = itemEnds.length; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + if (itemEnds[mid] >= offset) { + result = mid; + high = mid - 1; + } else { + low = mid + 1; + } + } + + return Math.min(result, itemEnds.length - 1); +} + +// Virtualize long columns while keeping full DOM during drag interactions. +function VirtualizedList({ + items, + isDragging, + estimatedItemHeight, + itemGap, + overscan, + virtualizationThreshold, + children, +}: VirtualizedListProps) { + const contentRef = useRef(null); + const measurementsRef = useRef>(new Map()); + const scrollRafRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [viewportHeight, setViewportHeight] = useState(0); + const [measureVersion, setMeasureVersion] = useState(0); + + const itemIds = useMemo(() => items.map((item) => item.id), [items]); + const shouldVirtualize = !isDragging && items.length >= virtualizationThreshold; + + const itemSizes = useMemo(() => { + return items.map((item) => { + const measured = measurementsRef.current.get(item.id); + const resolvedHeight = measured ?? estimatedItemHeight; + return resolvedHeight + itemGap; + }); + }, [items, estimatedItemHeight, itemGap, measureVersion]); + + const itemStarts = useMemo(() => { + let offset = 0; + return itemSizes.map((size) => { + const start = offset; + offset += size; + return start; + }); + }, [itemSizes]); + + const itemEnds = useMemo(() => { + return itemStarts.map((start, index) => start + itemSizes[index]); + }, [itemStarts, itemSizes]); + + const totalHeight = itemEnds.length > 0 ? itemEnds[itemEnds.length - 1] : 0; + + const { startIndex, endIndex, offsetTop } = useMemo(() => { + if (!shouldVirtualize || items.length === 0) { + return { startIndex: 0, endIndex: items.length, offsetTop: 0 }; + } + + const firstVisible = findIndexForOffset(itemEnds, scrollTop); + const lastVisible = findIndexForOffset(itemEnds, scrollTop + viewportHeight); + const overscannedStart = Math.max(0, firstVisible - overscan); + const overscannedEnd = Math.min(items.length, lastVisible + overscan + 1); + + return { + startIndex: overscannedStart, + endIndex: overscannedEnd, + offsetTop: itemStarts[overscannedStart] ?? 0, + }; + }, [shouldVirtualize, items.length, itemEnds, itemStarts, overscan, scrollTop, viewportHeight]); + + const visibleItems = shouldVirtualize ? items.slice(startIndex, endIndex) : items; + + const onScroll = useCallback((event: UIEvent) => { + const target = event.currentTarget; + if (scrollRafRef.current !== null) { + cancelAnimationFrame(scrollRafRef.current); + } + scrollRafRef.current = requestAnimationFrame(() => { + setScrollTop(target.scrollTop); + scrollRafRef.current = null; + }); + }, []); + + const registerItem = useCallback( + (id: string) => (node: HTMLDivElement | null) => { + if (!node || !shouldVirtualize) return; + const measuredHeight = node.getBoundingClientRect().height; + const previousHeight = measurementsRef.current.get(id); + if ( + previousHeight === undefined || + Math.abs(previousHeight - measuredHeight) > VIRTUALIZATION_MEASURE_EPSILON_PX + ) { + measurementsRef.current.set(id, measuredHeight); + setMeasureVersion((value) => value + 1); + } + }, + [shouldVirtualize] + ); + + useEffect(() => { + const container = contentRef.current; + if (!container || typeof window === 'undefined') return; + + const updateHeight = () => { + setViewportHeight(container.clientHeight); + }; + + updateHeight(); + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', updateHeight); + return () => window.removeEventListener('resize', updateHeight); + } + + const observer = new ResizeObserver(() => updateHeight()); + observer.observe(container); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!shouldVirtualize) return; + const currentIds = new Set(items.map((item) => item.id)); + for (const id of measurementsRef.current.keys()) { + if (!currentIds.has(id)) { + measurementsRef.current.delete(id); + } + } + }, [items, shouldVirtualize]); + + useEffect(() => { + return () => { + if (scrollRafRef.current !== null) { + cancelAnimationFrame(scrollRafRef.current); + } + }; + }, []); + + return ( + <> + {children({ + contentRef, + onScroll, + itemIds, + visibleItems, + totalHeight, + offsetTop, + startIndex, + shouldVirtualize, + registerItem, + })} + + ); +} + export function KanbanBoard({ sensors, collisionDetectionStrategy, @@ -109,7 +303,7 @@ export function KanbanBoard({ const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]); // Get the keyboard shortcut for adding features - const { keyboardShortcuts } = useAppStore(); + const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts); const addFeatureShortcut = keyboardShortcuts.addFeature || 'N'; // Use responsive column widths based on window size @@ -135,213 +329,307 @@ export function KanbanBoard({ {columns.map((column) => { const columnFeatures = getColumnFeatures(column.id as ColumnId); return ( - - {columnFeatures.length > 0 && ( + items={columnFeatures} + isDragging={isDragging} + estimatedItemHeight={KANBAN_CARD_ESTIMATED_HEIGHT_PX} + itemGap={KANBAN_CARD_GAP_PX} + overscan={KANBAN_OVERSCAN_COUNT} + virtualizationThreshold={KANBAN_VIRTUALIZATION_THRESHOLD} + > + {({ + contentRef, + onScroll, + itemIds, + visibleItems, + totalHeight, + offsetTop, + startIndex, + shouldVirtualize, + registerItem, + }) => ( + + {columnFeatures.length > 0 && ( + + )} + + + ) : column.id === 'backlog' ? ( +
+ + +
+ ) : column.id === 'waiting_approval' ? ( - )} - - - ) : column.id === 'backlog' ? ( -
- - -
- ) : column.id === 'waiting_approval' ? ( - - ) : column.id === 'in_progress' ? ( - - ) : column.isPipelineStep ? ( - - ) : undefined - } - footerAction={ - column.id === 'backlog' ? ( - - ) : undefined - } - > - f.id)} - strategy={verticalListSortingStrategy} - > - {/* Empty state card when column has no features */} - {columnFeatures.length === 0 && !isDragging && ( - - )} - {columnFeatures.map((feature, index) => { - // Calculate shortcut key for in-progress cards (first 10 get 1-9, 0) - let shortcutKey: string | undefined; - if (column.id === 'in_progress' && index < 10) { - shortcutKey = index === 9 ? '0' : String(index + 1); + ) : column.id === 'in_progress' ? ( + + ) : column.isPipelineStep ? ( + + ) : undefined } - return ( - onEdit(feature)} - onDelete={() => onDelete(feature.id)} - onViewOutput={() => onViewOutput(feature)} - onVerify={() => onVerify(feature)} - onResume={() => onResume(feature)} - onForceStop={() => onForceStop(feature)} - onManualVerify={() => onManualVerify(feature)} - onMoveBackToInProgress={() => onMoveBackToInProgress(feature)} - onFollowUp={() => onFollowUp(feature)} - onComplete={() => onComplete(feature)} - onImplement={() => onImplement(feature)} - onViewPlan={() => onViewPlan(feature)} - onApprovePlan={() => onApprovePlan(feature)} - onSpawnTask={() => onSpawnTask?.(feature)} - hasContext={featuresWithContext.has(feature.id)} - isCurrentAutoTask={runningAutoTasks.includes(feature.id)} - shortcutKey={shortcutKey} - opacity={backgroundSettings.cardOpacity} - glassmorphism={backgroundSettings.cardGlassmorphism} - cardBorderEnabled={backgroundSettings.cardBorderEnabled} - cardBorderOpacity={backgroundSettings.cardBorderOpacity} - isSelectionMode={isSelectionMode} - selectionTarget={selectionTarget} - isSelected={selectedFeatureIds.has(feature.id)} - onToggleSelect={() => onToggleFeatureSelection?.(feature.id)} - /> - ); - })} - -
+ footerAction={ + column.id === 'backlog' ? ( + + ) : undefined + } + > + {(() => { + const reduceEffects = shouldVirtualize; + const effectiveCardOpacity = reduceEffects + ? Math.min(backgroundSettings.cardOpacity, REDUCED_CARD_OPACITY_PERCENT) + : backgroundSettings.cardOpacity; + const effectiveGlassmorphism = + backgroundSettings.cardGlassmorphism && !reduceEffects; + + return ( + + {/* Empty state card when column has no features */} + {columnFeatures.length === 0 && !isDragging && ( + + )} + {shouldVirtualize ? ( +
+
+ {visibleItems.map((feature, index) => { + const absoluteIndex = startIndex + index; + let shortcutKey: string | undefined; + if (column.id === 'in_progress' && absoluteIndex < 10) { + shortcutKey = + absoluteIndex === 9 ? '0' : String(absoluteIndex + 1); + } + return ( +
+ onEdit(feature)} + onDelete={() => onDelete(feature.id)} + onViewOutput={() => onViewOutput(feature)} + onVerify={() => onVerify(feature)} + onResume={() => onResume(feature)} + onForceStop={() => onForceStop(feature)} + onManualVerify={() => onManualVerify(feature)} + onMoveBackToInProgress={() => + onMoveBackToInProgress(feature) + } + onFollowUp={() => onFollowUp(feature)} + onComplete={() => onComplete(feature)} + onImplement={() => onImplement(feature)} + onViewPlan={() => onViewPlan(feature)} + onApprovePlan={() => onApprovePlan(feature)} + onSpawnTask={() => onSpawnTask?.(feature)} + hasContext={featuresWithContext.has(feature.id)} + isCurrentAutoTask={runningAutoTasks.includes(feature.id)} + shortcutKey={shortcutKey} + opacity={effectiveCardOpacity} + glassmorphism={effectiveGlassmorphism} + cardBorderEnabled={backgroundSettings.cardBorderEnabled} + cardBorderOpacity={backgroundSettings.cardBorderOpacity} + reduceEffects={reduceEffects} + isSelectionMode={isSelectionMode} + selectionTarget={selectionTarget} + isSelected={selectedFeatureIds.has(feature.id)} + onToggleSelect={() => + onToggleFeatureSelection?.(feature.id) + } + /> +
+ ); + })} +
+
+ ) : ( + columnFeatures.map((feature, index) => { + let shortcutKey: string | undefined; + if (column.id === 'in_progress' && index < 10) { + shortcutKey = index === 9 ? '0' : String(index + 1); + } + return ( + onEdit(feature)} + onDelete={() => onDelete(feature.id)} + onViewOutput={() => onViewOutput(feature)} + onVerify={() => onVerify(feature)} + onResume={() => onResume(feature)} + onForceStop={() => onForceStop(feature)} + onManualVerify={() => onManualVerify(feature)} + onMoveBackToInProgress={() => onMoveBackToInProgress(feature)} + onFollowUp={() => onFollowUp(feature)} + onComplete={() => onComplete(feature)} + onImplement={() => onImplement(feature)} + onViewPlan={() => onViewPlan(feature)} + onApprovePlan={() => onApprovePlan(feature)} + onSpawnTask={() => onSpawnTask?.(feature)} + hasContext={featuresWithContext.has(feature.id)} + isCurrentAutoTask={runningAutoTasks.includes(feature.id)} + shortcutKey={shortcutKey} + opacity={effectiveCardOpacity} + glassmorphism={effectiveGlassmorphism} + cardBorderEnabled={backgroundSettings.cardBorderEnabled} + cardBorderOpacity={backgroundSettings.cardBorderOpacity} + reduceEffects={reduceEffects} + isSelectionMode={isSelectionMode} + selectionTarget={selectionTarget} + isSelected={selectedFeatureIds.has(feature.id)} + onToggleSelect={() => onToggleFeatureSelection?.(feature.id)} + /> + ); + }) + )} +
+ ); + })()} +
+ )} + ); })} diff --git a/apps/ui/src/components/views/chat-history.tsx b/apps/ui/src/components/views/chat-history.tsx index e6939361..eed0b062 100644 --- a/apps/ui/src/components/views/chat-history.tsx +++ b/apps/ui/src/components/views/chat-history.tsx @@ -1,5 +1,7 @@ -import { useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { UIEvent } from 'react'; import { useAppStore } from '@/store/app-store'; +import { useShallow } from 'zustand/react/shallow'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { @@ -22,6 +24,10 @@ import { } from '@/components/ui/dropdown-menu'; import { Badge } from '@/components/ui/badge'; +const CHAT_SESSION_ROW_HEIGHT_PX = 84; +const CHAT_SESSION_OVERSCAN_COUNT = 6; +const CHAT_SESSION_LIST_PADDING_PX = 8; + export function ChatHistory() { const { chatSessions, @@ -34,29 +40,117 @@ export function ChatHistory() { unarchiveChatSession, deleteChatSession, setChatHistoryOpen, - } = useAppStore(); + } = useAppStore( + useShallow((state) => ({ + chatSessions: state.chatSessions, + currentProject: state.currentProject, + currentChatSession: state.currentChatSession, + chatHistoryOpen: state.chatHistoryOpen, + createChatSession: state.createChatSession, + setCurrentChatSession: state.setCurrentChatSession, + archiveChatSession: state.archiveChatSession, + unarchiveChatSession: state.unarchiveChatSession, + deleteChatSession: state.deleteChatSession, + setChatHistoryOpen: state.setChatHistoryOpen, + })) + ); const [searchQuery, setSearchQuery] = useState(''); const [showArchived, setShowArchived] = useState(false); + const listRef = useRef(null); + const scrollRafRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [viewportHeight, setViewportHeight] = useState(0); - if (!currentProject) { - return null; - } + const normalizedQuery = searchQuery.trim().toLowerCase(); + const currentProjectId = currentProject?.id; // Filter sessions for current project - const projectSessions = chatSessions.filter((session) => session.projectId === currentProject.id); + const projectSessions = useMemo(() => { + if (!currentProjectId) return []; + return chatSessions.filter((session) => session.projectId === currentProjectId); + }, [chatSessions, currentProjectId]); // Filter by search query and archived status - const filteredSessions = projectSessions.filter((session) => { - const matchesSearch = session.title.toLowerCase().includes(searchQuery.toLowerCase()); - const matchesArchivedStatus = showArchived ? session.archived : !session.archived; - return matchesSearch && matchesArchivedStatus; - }); + const filteredSessions = useMemo(() => { + return projectSessions.filter((session) => { + const matchesSearch = session.title.toLowerCase().includes(normalizedQuery); + const matchesArchivedStatus = showArchived ? session.archived : !session.archived; + return matchesSearch && matchesArchivedStatus; + }); + }, [projectSessions, normalizedQuery, showArchived]); // Sort by most recently updated - const sortedSessions = filteredSessions.sort( - (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + const sortedSessions = useMemo(() => { + return [...filteredSessions].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + }, [filteredSessions]); + + const totalHeight = + sortedSessions.length * CHAT_SESSION_ROW_HEIGHT_PX + CHAT_SESSION_LIST_PADDING_PX * 2; + const startIndex = Math.max( + 0, + Math.floor(scrollTop / CHAT_SESSION_ROW_HEIGHT_PX) - CHAT_SESSION_OVERSCAN_COUNT ); + const endIndex = Math.min( + sortedSessions.length, + Math.ceil((scrollTop + viewportHeight) / CHAT_SESSION_ROW_HEIGHT_PX) + + CHAT_SESSION_OVERSCAN_COUNT + ); + const offsetTop = startIndex * CHAT_SESSION_ROW_HEIGHT_PX; + const visibleSessions = sortedSessions.slice(startIndex, endIndex); + + const handleScroll = useCallback((event: UIEvent) => { + const target = event.currentTarget; + if (scrollRafRef.current !== null) { + cancelAnimationFrame(scrollRafRef.current); + } + scrollRafRef.current = requestAnimationFrame(() => { + setScrollTop(target.scrollTop); + scrollRafRef.current = null; + }); + }, []); + + useEffect(() => { + const container = listRef.current; + if (!container || typeof window === 'undefined') return; + + const updateHeight = () => { + setViewportHeight(container.clientHeight); + }; + + updateHeight(); + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', updateHeight); + return () => window.removeEventListener('resize', updateHeight); + } + + const observer = new ResizeObserver(() => updateHeight()); + observer.observe(container); + return () => observer.disconnect(); + }, [chatHistoryOpen]); + + useEffect(() => { + if (!chatHistoryOpen) return; + setScrollTop(0); + if (listRef.current) { + listRef.current.scrollTop = 0; + } + }, [chatHistoryOpen, normalizedQuery, showArchived, currentProjectId]); + + useEffect(() => { + return () => { + if (scrollRafRef.current !== null) { + cancelAnimationFrame(scrollRafRef.current); + } + }; + }, []); + + if (!currentProjectId) { + return null; + } const handleCreateNewChat = () => { createChatSession(); @@ -151,7 +245,11 @@ export function ChatHistory() { {/* Chat Sessions List */} -
+
{sortedSessions.length === 0 ? (
{searchQuery ? ( @@ -163,60 +261,75 @@ export function ChatHistory() { )}
) : ( -
- {sortedSessions.map((session) => ( -
handleSelectSession(session)} - > -
-

{session.title}

-

- {session.messages.length} messages -

-

- {new Date(session.updatedAt).toLocaleDateString()} -

-
+
+
+ {visibleSessions.map((session) => ( +
handleSelectSession(session)} + > +
+

{session.title}

+

+ {session.messages.length} messages +

+

+ {new Date(session.updatedAt).toLocaleDateString()} +

+
-
- - - - - - {session.archived ? ( +
+ + + + + + {session.archived ? ( + handleUnarchiveSession(session.id, e)} + > + + Unarchive + + ) : ( + handleArchiveSession(session.id, e)} + > + + Archive + + )} + handleUnarchiveSession(session.id, e)} + onClick={(e) => handleDeleteSession(session.id, e)} + className="text-destructive" > - - Unarchive + + Delete - ) : ( - handleArchiveSession(session.id, e)}> - - Archive - - )} - - handleDeleteSession(session.id, e)} - className="text-destructive" - > - - Delete - - - + + +
-
- ))} + ))} +
)}
diff --git a/apps/ui/src/components/views/graph-view-page.tsx b/apps/ui/src/components/views/graph-view-page.tsx index 47acf313..e3899297 100644 --- a/apps/ui/src/components/views/graph-view-page.tsx +++ b/apps/ui/src/components/views/graph-view-page.tsx @@ -1,6 +1,7 @@ // @ts-nocheck import { useState, useCallback, useMemo, useEffect } from 'react'; import { useAppStore, Feature } from '@/store/app-store'; +import { useShallow } from 'zustand/react/shallow'; import { GraphView } from './graph-view'; import { EditFeatureDialog, @@ -40,7 +41,20 @@ export function GraphViewPage() { addFeatureUseSelectedWorktreeBranch, planUseSelectedWorktreeBranch, setPlanUseSelectedWorktreeBranch, - } = useAppStore(); + } = useAppStore( + useShallow((state) => ({ + currentProject: state.currentProject, + updateFeature: state.updateFeature, + getCurrentWorktree: state.getCurrentWorktree, + getWorktrees: state.getWorktrees, + setWorktrees: state.setWorktrees, + setCurrentWorktree: state.setCurrentWorktree, + defaultSkipTests: state.defaultSkipTests, + addFeatureUseSelectedWorktreeBranch: state.addFeatureUseSelectedWorktreeBranch, + planUseSelectedWorktreeBranch: state.planUseSelectedWorktreeBranch, + setPlanUseSelectedWorktreeBranch: state.setPlanUseSelectedWorktreeBranch, + })) + ); // Ensure worktrees are loaded when landing directly on graph view useWorktrees({ projectPath: currentProject?.path ?? '' }); diff --git a/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx b/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx index 8ad385b9..44cac85c 100644 --- a/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx +++ b/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx @@ -4,6 +4,7 @@ import type { EdgeProps } from '@xyflow/react'; import { cn } from '@/lib/utils'; import { Feature } from '@/store/app-store'; import { Trash2 } from 'lucide-react'; +import { GRAPH_RENDER_MODE_COMPACT, type GraphRenderMode } from '../constants'; export interface DependencyEdgeData { sourceStatus: Feature['status']; @@ -11,6 +12,7 @@ export interface DependencyEdgeData { isHighlighted?: boolean; isDimmed?: boolean; onDeleteDependency?: (sourceId: string, targetId: string) => void; + renderMode?: GraphRenderMode; } const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => { @@ -61,6 +63,7 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) { const isHighlighted = edgeData?.isHighlighted ?? false; const isDimmed = edgeData?.isDimmed ?? false; + const isCompact = edgeData?.renderMode === GRAPH_RENDER_MODE_COMPACT; const edgeColor = isHighlighted ? 'var(--brand-500)' @@ -86,6 +89,51 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) { } }; + if (isCompact) { + return ( + <> + + {selected && edgeData?.onDeleteDependency && ( + +
+ +
+
+ )} + + ); + } + return ( <> {/* Invisible wider path for hover detection */} diff --git a/apps/ui/src/components/views/graph-view/components/task-node.tsx b/apps/ui/src/components/views/graph-view/components/task-node.tsx index 020b1914..16cf6817 100644 --- a/apps/ui/src/components/views/graph-view/components/task-node.tsx +++ b/apps/ui/src/components/views/graph-view/components/task-node.tsx @@ -18,6 +18,7 @@ import { Trash2, } from 'lucide-react'; import { TaskNodeData } from '../hooks/use-graph-nodes'; +import { GRAPH_RENDER_MODE_COMPACT } from '../constants'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -109,9 +110,11 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps // Background/theme settings with defaults const cardOpacity = data.cardOpacity ?? 100; - const glassmorphism = data.cardGlassmorphism ?? true; + const shouldUseGlassmorphism = data.cardGlassmorphism ?? true; const cardBorderEnabled = data.cardBorderEnabled ?? true; const cardBorderOpacity = data.cardBorderOpacity ?? 100; + const isCompact = data.renderMode === GRAPH_RENDER_MODE_COMPACT; + const glassmorphism = shouldUseGlassmorphism && !isCompact; // Get the border color based on status and error state const borderColor = data.error @@ -129,6 +132,99 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps // Get computed border style const borderStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity, borderColor); + if (isCompact) { + return ( + <> + + +
+
+
+ + {config.label} + {priorityConf && ( + + {data.priority === 1 ? 'H' : data.priority === 2 ? 'M' : 'L'} + + )} +
+
+

+ {data.title || data.description} +

+ {data.title && data.description && ( +

+ {data.description} +

+ )} + {data.isRunning && ( +
+ + Running +
+ )} + {isStopped && ( +
+ + Paused +
+ )} +
+
+ + + + ); + } + return ( <> {/* Target handle (left side - receives dependencies) */} diff --git a/apps/ui/src/components/views/graph-view/constants.ts b/apps/ui/src/components/views/graph-view/constants.ts new file mode 100644 index 00000000..d75b6ea8 --- /dev/null +++ b/apps/ui/src/components/views/graph-view/constants.ts @@ -0,0 +1,7 @@ +export const GRAPH_RENDER_MODE_FULL = 'full'; +export const GRAPH_RENDER_MODE_COMPACT = 'compact'; + +export type GraphRenderMode = typeof GRAPH_RENDER_MODE_FULL | typeof GRAPH_RENDER_MODE_COMPACT; + +export const GRAPH_LARGE_NODE_COUNT = 150; +export const GRAPH_LARGE_EDGE_COUNT = 300; diff --git a/apps/ui/src/components/views/graph-view/graph-canvas.tsx b/apps/ui/src/components/views/graph-view/graph-canvas.tsx index f14f3120..1286a745 100644 --- a/apps/ui/src/components/views/graph-view/graph-canvas.tsx +++ b/apps/ui/src/components/views/graph-view/graph-canvas.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState, useEffect, useRef } from 'react'; +import { useCallback, useState, useEffect, useMemo, useRef } from 'react'; import { ReactFlow, Background, @@ -39,6 +39,12 @@ import { useDebounceValue } from 'usehooks-ts'; import { SearchX, Plus, Wand2, ClipboardCheck } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { PlanSettingsPopover } from '../board-view/dialogs/plan-settings-popover'; +import { + GRAPH_LARGE_EDGE_COUNT, + GRAPH_LARGE_NODE_COUNT, + GRAPH_RENDER_MODE_COMPACT, + GRAPH_RENDER_MODE_FULL, +} from './constants'; // Define custom node and edge types - using any to avoid React Flow's strict typing // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -198,6 +204,17 @@ function GraphCanvasInner({ // Calculate filter results const filterResult = useGraphFilter(features, filterState, runningAutoTasks); + const estimatedEdgeCount = useMemo(() => { + return features.reduce((total, feature) => { + const deps = feature.dependencies as string[] | undefined; + return total + (deps?.length ?? 0); + }, 0); + }, [features]); + + const isLargeGraph = + features.length >= GRAPH_LARGE_NODE_COUNT || estimatedEdgeCount >= GRAPH_LARGE_EDGE_COUNT; + const renderMode = isLargeGraph ? GRAPH_RENDER_MODE_COMPACT : GRAPH_RENDER_MODE_FULL; + // Transform features to nodes and edges with filter results const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({ features, @@ -205,6 +222,8 @@ function GraphCanvasInner({ filterResult, actionCallbacks: nodeActionCallbacks, backgroundSettings, + renderMode, + enableEdgeAnimations: !isLargeGraph, }); // Apply layout @@ -457,6 +476,8 @@ function GraphCanvasInner({ } }, []); + const shouldRenderVisibleOnly = isLargeGraph; + return (
diff --git a/apps/ui/src/components/views/graph-view/graph-view.tsx b/apps/ui/src/components/views/graph-view/graph-view.tsx index 245894ab..e84bb1d5 100644 --- a/apps/ui/src/components/views/graph-view/graph-view.tsx +++ b/apps/ui/src/components/views/graph-view/graph-view.tsx @@ -51,7 +51,7 @@ export function GraphView({ planUseSelectedWorktreeBranch, onPlanUseSelectedWorktreeBranchChange, }: GraphViewProps) { - const { currentProject } = useAppStore(); + const currentProject = useAppStore((state) => state.currentProject); // Use the same background hook as the board view const { backgroundImageStyle, backgroundSettings } = useBoardBackground({ currentProject }); diff --git a/apps/ui/src/components/views/graph-view/hooks/use-graph-filter.ts b/apps/ui/src/components/views/graph-view/hooks/use-graph-filter.ts index 8349bff6..e769e4e3 100644 --- a/apps/ui/src/components/views/graph-view/hooks/use-graph-filter.ts +++ b/apps/ui/src/components/views/graph-view/hooks/use-graph-filter.ts @@ -54,16 +54,40 @@ function getAncestors( /** * Traverses down to find all descendants (features that depend on this one) */ -function getDescendants(featureId: string, features: Feature[], visited: Set): void { +function getDescendants( + featureId: string, + dependentsMap: Map, + visited: Set +): void { if (visited.has(featureId)) return; visited.add(featureId); + const dependents = dependentsMap.get(featureId); + if (!dependents || dependents.length === 0) return; + + for (const dependentId of dependents) { + getDescendants(dependentId, dependentsMap, visited); + } +} + +function buildDependentsMap(features: Feature[]): Map { + const dependentsMap = new Map(); + for (const feature of features) { const deps = feature.dependencies as string[] | undefined; - if (deps?.includes(featureId)) { - getDescendants(feature.id, features, visited); + if (!deps || deps.length === 0) continue; + + for (const depId of deps) { + const existing = dependentsMap.get(depId); + if (existing) { + existing.push(feature.id); + } else { + dependentsMap.set(depId, [feature.id]); + } } } + + return dependentsMap; } /** @@ -91,9 +115,9 @@ function getHighlightedEdges(highlightedNodeIds: Set, features: Feature[ * Gets the effective status of a feature (accounting for running state) * Treats completed (archived) as verified */ -function getEffectiveStatus(feature: Feature, runningAutoTasks: string[]): StatusFilterValue { +function getEffectiveStatus(feature: Feature, runningTaskIds: Set): StatusFilterValue { if (feature.status === 'in_progress') { - return runningAutoTasks.includes(feature.id) ? 'running' : 'paused'; + return runningTaskIds.has(feature.id) ? 'running' : 'paused'; } // Treat completed (archived) as verified if (feature.status === 'completed') { @@ -119,6 +143,7 @@ export function useGraphFilter( ).sort(); const normalizedQuery = searchQuery.toLowerCase().trim(); + const runningTaskIds = new Set(runningAutoTasks); const hasSearchQuery = normalizedQuery.length > 0; const hasCategoryFilter = selectedCategories.length > 0; const hasStatusFilter = selectedStatuses.length > 0; @@ -139,6 +164,7 @@ export function useGraphFilter( // Find directly matched nodes const matchedNodeIds = new Set(); const featureMap = new Map(features.map((f) => [f.id, f])); + const dependentsMap = buildDependentsMap(features); for (const feature of features) { let matchesSearch = true; @@ -159,7 +185,7 @@ export function useGraphFilter( // Check status match if (hasStatusFilter) { - const effectiveStatus = getEffectiveStatus(feature, runningAutoTasks); + const effectiveStatus = getEffectiveStatus(feature, runningTaskIds); matchesStatus = selectedStatuses.includes(effectiveStatus); } @@ -190,7 +216,7 @@ export function useGraphFilter( getAncestors(id, featureMap, highlightedNodeIds); // Add all descendants (dependents) - getDescendants(id, features, highlightedNodeIds); + getDescendants(id, dependentsMap, highlightedNodeIds); } // Get edges in the highlighted path diff --git a/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts b/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts index 3e9e41e0..3b902611 100644 --- a/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts +++ b/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts @@ -1,7 +1,8 @@ import { useMemo } from 'react'; import { Node, Edge } from '@xyflow/react'; import { Feature } from '@/store/app-store'; -import { getBlockingDependencies } from '@automaker/dependency-resolver'; +import { createFeatureMap, getBlockingDependenciesFromMap } from '@automaker/dependency-resolver'; +import { GRAPH_RENDER_MODE_FULL, type GraphRenderMode } from '../constants'; import { GraphFilterResult } from './use-graph-filter'; export interface TaskNodeData extends Feature { @@ -31,6 +32,7 @@ export interface TaskNodeData extends Feature { onResumeTask?: () => void; onSpawnTask?: () => void; onDeleteTask?: () => void; + renderMode?: GraphRenderMode; } export type TaskNode = Node; @@ -40,6 +42,7 @@ export type DependencyEdge = Edge<{ isHighlighted?: boolean; isDimmed?: boolean; onDeleteDependency?: (sourceId: string, targetId: string) => void; + renderMode?: GraphRenderMode; }>; export interface NodeActionCallbacks { @@ -66,6 +69,8 @@ interface UseGraphNodesProps { filterResult?: GraphFilterResult; actionCallbacks?: NodeActionCallbacks; backgroundSettings?: BackgroundSettings; + renderMode?: GraphRenderMode; + enableEdgeAnimations?: boolean; } /** @@ -78,14 +83,14 @@ export function useGraphNodes({ filterResult, actionCallbacks, backgroundSettings, + renderMode = GRAPH_RENDER_MODE_FULL, + enableEdgeAnimations = true, }: UseGraphNodesProps) { const { nodes, edges } = useMemo(() => { const nodeList: TaskNode[] = []; const edgeList: DependencyEdge[] = []; - const featureMap = new Map(); - - // Create feature map for quick lookups - features.forEach((f) => featureMap.set(f.id, f)); + const featureMap = createFeatureMap(features); + const runningTaskIds = new Set(runningAutoTasks); // Extract filter state const hasActiveFilter = filterResult?.hasActiveFilter ?? false; @@ -95,8 +100,8 @@ export function useGraphNodes({ // Create nodes features.forEach((feature) => { - const isRunning = runningAutoTasks.includes(feature.id); - const blockingDeps = getBlockingDependencies(feature, features); + const isRunning = runningTaskIds.has(feature.id); + const blockingDeps = getBlockingDependenciesFromMap(feature, featureMap); // Calculate filter highlight states const isMatched = hasActiveFilter && matchedNodeIds.has(feature.id); @@ -121,6 +126,7 @@ export function useGraphNodes({ cardGlassmorphism: backgroundSettings?.cardGlassmorphism, cardBorderEnabled: backgroundSettings?.cardBorderEnabled, cardBorderOpacity: backgroundSettings?.cardBorderOpacity, + renderMode, // Action callbacks (bound to this feature's ID) onViewLogs: actionCallbacks?.onViewLogs ? () => actionCallbacks.onViewLogs!(feature.id) @@ -166,13 +172,14 @@ export function useGraphNodes({ source: depId, target: feature.id, type: 'dependency', - animated: isRunning || runningAutoTasks.includes(depId), + animated: enableEdgeAnimations && (isRunning || runningTaskIds.has(depId)), data: { sourceStatus: sourceFeature.status, targetStatus: feature.status, isHighlighted: edgeIsHighlighted, isDimmed: edgeIsDimmed, onDeleteDependency: actionCallbacks?.onDeleteDependency, + renderMode, }, }; edgeList.push(edge); diff --git a/apps/ui/src/components/views/settings-view/providers/opencode-settings-tab.tsx b/apps/ui/src/components/views/settings-view/providers/opencode-settings-tab.tsx index 83e05e9e..f56e9a64 100644 --- a/apps/ui/src/components/views/settings-view/providers/opencode-settings-tab.tsx +++ b/apps/ui/src/components/views/settings-view/providers/opencode-settings-tab.tsx @@ -4,6 +4,7 @@ import { toast } from 'sonner'; import { useAppStore } from '@/store/app-store'; import { OpencodeCliStatus, OpencodeCliStatusSkeleton } from '../cli-status/opencode-cli-status'; import { OpencodeModelConfiguration } from './opencode-model-configuration'; +import { ProviderToggle } from './provider-toggle'; import { useOpencodeCliStatus, useOpencodeProviders, useOpencodeModels } from '@/hooks/queries'; import { queryKeys } from '@/lib/query-keys'; import type { CliStatus as SharedCliStatus } from '../shared/types'; diff --git a/apps/ui/src/hooks/queries/use-features.ts b/apps/ui/src/hooks/queries/use-features.ts index 89a67987..78db6101 100644 --- a/apps/ui/src/hooks/queries/use-features.ts +++ b/apps/ui/src/hooks/queries/use-features.ts @@ -12,6 +12,9 @@ import { queryKeys } from '@/lib/query-keys'; import { STALE_TIMES } from '@/lib/query-client'; import type { Feature } from '@/store/app-store'; +const FEATURES_REFETCH_ON_FOCUS = false; +const FEATURES_REFETCH_ON_RECONNECT = false; + /** * Fetch all features for a project * @@ -37,6 +40,8 @@ export function useFeatures(projectPath: string | undefined) { }, enabled: !!projectPath, staleTime: STALE_TIMES.FEATURES, + refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS, + refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT, }); } @@ -75,6 +80,8 @@ export function useFeature( enabled: !!projectPath && !!featureId && enabled, staleTime: STALE_TIMES.FEATURES, refetchInterval: pollingInterval, + refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS, + refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT, }); } @@ -123,5 +130,7 @@ export function useAgentOutput( } return false; }, + refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS, + refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT, }); } diff --git a/apps/ui/src/hooks/queries/use-running-agents.ts b/apps/ui/src/hooks/queries/use-running-agents.ts index a661d9c3..75002226 100644 --- a/apps/ui/src/hooks/queries/use-running-agents.ts +++ b/apps/ui/src/hooks/queries/use-running-agents.ts @@ -10,6 +10,9 @@ import { getElectronAPI, type RunningAgent } from '@/lib/electron'; import { queryKeys } from '@/lib/query-keys'; import { STALE_TIMES } from '@/lib/query-client'; +const RUNNING_AGENTS_REFETCH_ON_FOCUS = false; +const RUNNING_AGENTS_REFETCH_ON_RECONNECT = false; + interface RunningAgentsResult { agents: RunningAgent[]; count: number; @@ -43,6 +46,8 @@ export function useRunningAgents() { staleTime: STALE_TIMES.RUNNING_AGENTS, // Note: Don't use refetchInterval here - rely on WebSocket invalidation // for real-time updates instead of polling + refetchOnWindowFocus: RUNNING_AGENTS_REFETCH_ON_FOCUS, + refetchOnReconnect: RUNNING_AGENTS_REFETCH_ON_RECONNECT, }); } diff --git a/apps/ui/src/hooks/queries/use-usage.ts b/apps/ui/src/hooks/queries/use-usage.ts index 38de9bb8..21f0267d 100644 --- a/apps/ui/src/hooks/queries/use-usage.ts +++ b/apps/ui/src/hooks/queries/use-usage.ts @@ -13,6 +13,8 @@ import type { ClaudeUsage, CodexUsage } from '@/store/app-store'; /** Polling interval for usage data (60 seconds) */ const USAGE_POLLING_INTERVAL = 60 * 1000; +const USAGE_REFETCH_ON_FOCUS = false; +const USAGE_REFETCH_ON_RECONNECT = false; /** * Fetch Claude API usage data @@ -42,6 +44,8 @@ export function useClaudeUsage(enabled = true) { refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false, // Keep previous data while refetching placeholderData: (previousData) => previousData, + refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS, + refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, }); } @@ -73,5 +77,7 @@ export function useCodexUsage(enabled = true) { refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false, // Keep previous data while refetching placeholderData: (previousData) => previousData, + refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS, + refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, }); } diff --git a/apps/ui/src/hooks/queries/use-worktrees.ts b/apps/ui/src/hooks/queries/use-worktrees.ts index 9a7eefec..551894ef 100644 --- a/apps/ui/src/hooks/queries/use-worktrees.ts +++ b/apps/ui/src/hooks/queries/use-worktrees.ts @@ -9,6 +9,9 @@ import { getElectronAPI } from '@/lib/electron'; import { queryKeys } from '@/lib/query-keys'; import { STALE_TIMES } from '@/lib/query-client'; +const WORKTREE_REFETCH_ON_FOCUS = false; +const WORKTREE_REFETCH_ON_RECONNECT = false; + interface WorktreeInfo { path: string; branch: string; @@ -59,6 +62,8 @@ export function useWorktrees(projectPath: string | undefined, includeDetails = t }, enabled: !!projectPath, staleTime: STALE_TIMES.WORKTREES, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, }); } @@ -83,6 +88,8 @@ export function useWorktreeInfo(projectPath: string | undefined, featureId: stri }, enabled: !!projectPath && !!featureId, staleTime: STALE_TIMES.WORKTREES, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, }); } @@ -107,6 +114,8 @@ export function useWorktreeStatus(projectPath: string | undefined, featureId: st }, enabled: !!projectPath && !!featureId, staleTime: STALE_TIMES.WORKTREES, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, }); } @@ -134,6 +143,8 @@ export function useWorktreeDiffs(projectPath: string | undefined, featureId: str }, enabled: !!projectPath && !!featureId, staleTime: STALE_TIMES.WORKTREES, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, }); } @@ -203,6 +214,8 @@ export function useWorktreeBranches(worktreePath: string | undefined, includeRem }, enabled: !!worktreePath, staleTime: STALE_TIMES.WORKTREES, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, }); } @@ -229,6 +242,8 @@ export function useWorktreeInitScript(projectPath: string | undefined) { }, enabled: !!projectPath, staleTime: STALE_TIMES.SETTINGS, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, }); } @@ -249,5 +264,7 @@ export function useAvailableEditors() { return result.editors ?? []; }, staleTime: STALE_TIMES.CLI_STATUS, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, }); } diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 8bb286eb..1660e048 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -40,6 +40,7 @@ import { useIsCompact } from '@/hooks/use-media-query'; import type { Project } from '@/lib/electron'; const logger = createLogger('RootLayout'); +const SHOW_QUERY_DEVTOOLS = import.meta.env.DEV; const SERVER_READY_MAX_ATTEMPTS = 8; const SERVER_READY_BACKOFF_BASE_MS = 250; const SERVER_READY_MAX_DELAY_MS = 1500; @@ -899,7 +900,9 @@ function RootLayout() { - + {SHOW_QUERY_DEVTOOLS ? ( + + ) : null} ); } diff --git a/apps/ui/src/styles/global.css b/apps/ui/src/styles/global.css index a8a6e53a..3e2ae46d 100644 --- a/apps/ui/src/styles/global.css +++ b/apps/ui/src/styles/global.css @@ -1120,3 +1120,8 @@ animation: none; } } + +.perf-contain { + contain: layout paint; + content-visibility: auto; +} diff --git a/libs/dependency-resolver/src/index.ts b/libs/dependency-resolver/src/index.ts index 63fd22e4..fcae1258 100644 --- a/libs/dependency-resolver/src/index.ts +++ b/libs/dependency-resolver/src/index.ts @@ -7,6 +7,8 @@ export { resolveDependencies, areDependenciesSatisfied, getBlockingDependencies, + createFeatureMap, + getBlockingDependenciesFromMap, wouldCreateCircularDependency, dependencyExists, getAncestors, diff --git a/libs/dependency-resolver/src/resolver.ts b/libs/dependency-resolver/src/resolver.ts index 145617f4..02c87c26 100644 --- a/libs/dependency-resolver/src/resolver.ts +++ b/libs/dependency-resolver/src/resolver.ts @@ -229,6 +229,49 @@ export function getBlockingDependencies(feature: Feature, allFeatures: Feature[] }); } +/** + * Builds a lookup map for features by id. + * + * @param features - Features to index + * @returns Map keyed by feature id + */ +export function createFeatureMap(features: Feature[]): Map { + const featureMap = new Map(); + for (const feature of features) { + if (feature?.id) { + featureMap.set(feature.id, feature); + } + } + return featureMap; +} + +/** + * Gets the blocking dependencies using a precomputed feature map. + * + * @param feature - Feature to check + * @param featureMap - Map of all features by id + * @returns Array of feature IDs that are blocking this feature + */ +export function getBlockingDependenciesFromMap( + feature: Feature, + featureMap: Map +): string[] { + const dependencies = feature.dependencies; + if (!dependencies || dependencies.length === 0) { + return []; + } + + const blockingDependencies: string[] = []; + for (const depId of dependencies) { + const dep = featureMap.get(depId); + if (dep && dep.status !== 'completed' && dep.status !== 'verified') { + blockingDependencies.push(depId); + } + } + + return blockingDependencies; +} + /** * Checks if adding a dependency from sourceId to targetId would create a circular dependency. * When we say "targetId depends on sourceId", we add sourceId to targetId.dependencies. diff --git a/libs/dependency-resolver/tests/resolver.test.ts b/libs/dependency-resolver/tests/resolver.test.ts index 5f246b2a..7f6726f8 100644 --- a/libs/dependency-resolver/tests/resolver.test.ts +++ b/libs/dependency-resolver/tests/resolver.test.ts @@ -3,6 +3,8 @@ import { resolveDependencies, areDependenciesSatisfied, getBlockingDependencies, + createFeatureMap, + getBlockingDependenciesFromMap, wouldCreateCircularDependency, dependencyExists, } from '../src/resolver'; @@ -351,6 +353,21 @@ describe('resolver.ts', () => { }); }); + describe('getBlockingDependenciesFromMap', () => { + it('should match getBlockingDependencies when using a feature map', () => { + const dep1 = createFeature('Dep1', { status: 'pending' }); + const dep2 = createFeature('Dep2', { status: 'completed' }); + const dep3 = createFeature('Dep3', { status: 'running' }); + const feature = createFeature('A', { dependencies: ['Dep1', 'Dep2', 'Dep3'] }); + const allFeatures = [dep1, dep2, dep3, feature]; + const featureMap = createFeatureMap(allFeatures); + + expect(getBlockingDependenciesFromMap(feature, featureMap)).toEqual( + getBlockingDependencies(feature, allFeatures) + ); + }); + }); + describe('wouldCreateCircularDependency', () => { it('should return false for features with no existing dependencies', () => { const features = [createFeature('A'), createFeature('B')];