perf(ui): smooth large lists and graphs

This commit is contained in:
DhanushSantosh
2026-01-19 19:38:56 +05:30
parent f987fc1f10
commit 9bb52f1ded
30 changed files with 1116 additions and 312 deletions

View File

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

View File

@@ -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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<HTMLDivElement>;
onScroll?: (event: UIEvent<HTMLDivElement>) => 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 */}
<div
className={cn(
'relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5',
'relative z-10 flex-1 overflow-y-auto p-2',
!disableItemSpacing && 'space-y-2.5',
hideScrollbar &&
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
// Smooth scrolling
'scroll-smooth',
// Add padding at bottom if there's a footer action
footerAction && 'pb-14'
footerAction && 'pb-14',
contentClassName
)}
ref={contentRef}
onScroll={onScroll}
style={contentStyle}
>
{children}
</div>

View File

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

View File

@@ -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<Item extends VirtualListItem> {
contentRef: RefObject<HTMLDivElement>;
onScroll: (event: UIEvent<HTMLDivElement>) => void;
itemIds: string[];
visibleItems: Item[];
totalHeight: number;
offsetTop: number;
startIndex: number;
shouldVirtualize: boolean;
registerItem: (id: string) => (node: HTMLDivElement | null) => void;
}
interface VirtualizedListProps<Item extends VirtualListItem> {
items: Item[];
isDragging: boolean;
estimatedItemHeight: number;
itemGap: number;
overscan: number;
virtualizationThreshold: number;
children: (state: VirtualListState<Item>) => 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<Item extends VirtualListItem>({
items,
isDragging,
estimatedItemHeight,
itemGap,
overscan,
virtualizationThreshold,
children,
}: VirtualizedListProps<Item>) {
const contentRef = useRef<HTMLDivElement>(null);
const measurementsRef = useRef<Map<string, number>>(new Map());
const scrollRafRef = useRef<number | null>(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<HTMLDivElement>) => {
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,8 +329,27 @@ export function KanbanBoard({
{columns.map((column) => {
const columnFeatures = getColumnFeatures(column.id as ColumnId);
return (
<KanbanColumn
<VirtualizedList
key={column.id}
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,
}) => (
<KanbanColumn
id={column.id}
title={column.title}
colorClass={column.colorClass}
@@ -145,6 +358,10 @@ export function KanbanBoard({
opacity={backgroundSettings.columnOpacity}
showBorder={backgroundSettings.columnBorderEnabled}
hideScrollbar={backgroundSettings.hideScrollbar}
contentRef={contentRef}
onScroll={shouldVirtualize ? onScroll : undefined}
disableItemSpacing={shouldVirtualize}
contentClassName="perf-contain"
headerAction={
column.id === 'verified' ? (
<div className="flex items-center gap-1">
@@ -194,7 +411,9 @@ export function KanbanBoard({
className={`h-6 px-2 text-xs ${selectionTarget === 'backlog' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => onToggleSelectionMode?.('backlog')}
title={
selectionTarget === 'backlog' ? 'Switch to Drag Mode' : 'Select Multiple'
selectionTarget === 'backlog'
? 'Switch to Drag Mode'
: 'Select Multiple'
}
data-testid="selection-mode-button"
>
@@ -278,10 +497,16 @@ export function KanbanBoard({
) : undefined
}
>
<SortableContext
items={columnFeatures.map((f) => f.id)}
strategy={verticalListSortingStrategy}
>
{(() => {
const reduceEffects = shouldVirtualize;
const effectiveCardOpacity = reduceEffects
? Math.min(backgroundSettings.cardOpacity, REDUCED_CARD_OPACITY_PERCENT)
: backgroundSettings.cardOpacity;
const effectiveGlassmorphism =
backgroundSettings.cardGlassmorphism && !reduceEffects;
return (
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
{/* Empty state card when column has no features */}
{columnFeatures.length === 0 && !isDragging && (
<EmptyStateCard
@@ -290,8 +515,8 @@ export function KanbanBoard({
addFeatureShortcut={addFeatureShortcut}
isReadOnly={isReadOnly}
onAiSuggest={column.id === 'backlog' ? onAiSuggest : undefined}
opacity={backgroundSettings.cardOpacity}
glassmorphism={backgroundSettings.cardGlassmorphism}
opacity={effectiveCardOpacity}
glassmorphism={effectiveGlassmorphism}
customConfig={
column.isPipelineStep
? {
@@ -302,8 +527,65 @@ export function KanbanBoard({
}
/>
)}
{columnFeatures.map((feature, index) => {
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
{shouldVirtualize ? (
<div className="relative" style={{ height: totalHeight }}>
<div
className="absolute left-0 right-0"
style={{ transform: `translateY(${offsetTop}px)` }}
>
{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 (
<div
key={feature.id}
ref={registerItem(feature.id)}
style={{ marginBottom: `${KANBAN_CARD_GAP_PX}px` }}
>
<KanbanCard
feature={feature}
onEdit={() => 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)
}
/>
</div>
);
})}
</div>
</div>
) : (
columnFeatures.map((feature, index) => {
let shortcutKey: string | undefined;
if (column.id === 'in_progress' && index < 10) {
shortcutKey = index === 9 ? '0' : String(index + 1);
@@ -329,19 +611,25 @@ export function KanbanBoard({
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}
opacity={backgroundSettings.cardOpacity}
glassmorphism={backgroundSettings.cardGlassmorphism}
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)}
/>
);
})}
})
)}
</SortableContext>
);
})()}
</KanbanColumn>
)}
</VirtualizedList>
);
})}
</div>

View File

@@ -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<HTMLDivElement>(null);
const scrollRafRef = useRef<number | null>(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 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(
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<HTMLDivElement>) => {
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() {
</div>
{/* Chat Sessions List */}
<div className="flex-1 overflow-y-auto">
<div
className="flex-1 overflow-y-auto perf-contain"
ref={listRef}
onScroll={handleScroll}
>
{sortedSessions.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
{searchQuery ? (
@@ -163,14 +261,26 @@ export function ChatHistory() {
)}
</div>
) : (
<div className="p-2">
{sortedSessions.map((session) => (
<div
className="relative"
style={{
height: totalHeight,
paddingTop: CHAT_SESSION_LIST_PADDING_PX,
paddingBottom: CHAT_SESSION_LIST_PADDING_PX,
}}
>
<div
className="absolute left-0 right-0"
style={{ transform: `translateY(${offsetTop}px)` }}
>
{visibleSessions.map((session) => (
<div
key={session.id}
className={cn(
'flex items-center gap-2 p-3 rounded-lg cursor-pointer hover:bg-accent transition-colors group',
currentChatSession?.id === session.id && 'bg-accent'
)}
style={{ height: CHAT_SESSION_ROW_HEIGHT_PX }}
onClick={() => handleSelectSession(session)}
>
<div className="flex-1 min-w-0">
@@ -199,7 +309,9 @@ export function ChatHistory() {
Unarchive
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={(e) => handleArchiveSession(session.id, e)}>
<DropdownMenuItem
onClick={(e) => handleArchiveSession(session.id, e)}
>
<Archive className="w-4 h-4 mr-2" />
Archive
</DropdownMenuItem>
@@ -218,6 +330,7 @@ export function ChatHistory() {
</div>
))}
</div>
</div>
)}
</div>
</>

View File

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

View File

@@ -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 (
<>
<BaseEdge
id={id}
path={edgePath}
className={cn('transition-opacity duration-200', isDimmed && 'graph-edge-dimmed')}
style={{
strokeWidth: selected ? 2 : 1.5,
stroke: selected ? 'var(--status-error)' : edgeColor,
strokeDasharray: isCompleted ? 'none' : '5 5',
opacity: isDimmed ? 0.2 : 1,
}}
/>
{selected && edgeData?.onDeleteDependency && (
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'auto',
zIndex: 1000,
}}
>
<button
onClick={handleDelete}
className={cn(
'flex items-center justify-center',
'w-6 h-6 rounded-full',
'bg-[var(--status-error)] hover:bg-[var(--status-error)]/80',
'text-white shadow-lg',
'transition-all duration-150',
'hover:scale-110'
)}
title="Delete dependency"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</EdgeLabelRenderer>
)}
</>
);
}
return (
<>
{/* Invisible wider path for hover detection */}

View File

@@ -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 (
<>
<Handle
id="target"
type="target"
position={Position.Left}
isConnectable={true}
className={cn(
'w-3 h-3 !bg-border border-2 border-background',
'transition-colors duration-200',
'hover:!bg-brand-500',
isDimmed && 'opacity-30'
)}
/>
<div
className={cn(
'min-w-[200px] max-w-[240px] rounded-lg shadow-sm relative',
'transition-all duration-200',
selected && 'ring-2 ring-brand-500 ring-offset-1 ring-offset-background',
isMatched && 'graph-node-matched',
isHighlighted && !isMatched && 'graph-node-highlighted',
isDimmed && 'graph-node-dimmed'
)}
style={borderStyle}
>
<div
className="absolute inset-0 rounded-lg bg-card"
style={{ opacity: cardOpacity / 100 }}
/>
<div className={cn('relative flex items-center gap-2 px-2.5 py-2', config.bgClass)}>
<StatusIcon className={cn('w-3.5 h-3.5', config.colorClass)} />
<span className={cn('text-[11px] font-medium', config.colorClass)}>{config.label}</span>
{priorityConf && (
<span
className={cn(
'ml-auto text-[9px] font-bold px-1.5 py-0.5 rounded',
priorityConf.colorClass
)}
>
{data.priority === 1 ? 'H' : data.priority === 2 ? 'M' : 'L'}
</span>
)}
</div>
<div className="relative px-2.5 py-2">
<p
className={cn(
'text-xs text-foreground line-clamp-2',
data.title ? 'font-medium' : 'font-semibold'
)}
>
{data.title || data.description}
</p>
{data.title && data.description && (
<p className="text-[11px] text-muted-foreground line-clamp-1 mt-1">
{data.description}
</p>
)}
{data.isRunning && (
<div className="mt-2 flex items-center gap-2 text-[10px] text-muted-foreground">
<span className="inline-flex w-1.5 h-1.5 rounded-full bg-[var(--status-in-progress)]" />
Running
</div>
)}
{isStopped && (
<div className="mt-2 flex items-center gap-2 text-[10px] text-[var(--status-warning)]">
<span className="inline-flex w-1.5 h-1.5 rounded-full bg-[var(--status-warning)]" />
Paused
</div>
)}
</div>
</div>
<Handle
id="source"
type="source"
position={Position.Right}
isConnectable={true}
className={cn(
'w-3 h-3 !bg-border border-2 border-background',
'transition-colors duration-200',
'hover:!bg-brand-500',
data.status === 'completed' || data.status === 'verified'
? '!bg-[var(--status-success)]'
: '',
isDimmed && 'opacity-30'
)}
/>
</>
);
}
return (
<>
{/* Target handle (left side - receives dependencies) */}

View File

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

View File

@@ -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 (
<div className={cn('w-full h-full', className)} style={backgroundStyle}>
<ReactFlow
@@ -478,6 +499,7 @@ function GraphCanvasInner({
maxZoom={2}
selectionMode={SelectionMode.Partial}
connectionMode={ConnectionMode.Loose}
onlyRenderVisibleElements={shouldRenderVisibleOnly}
proOptions={{ hideAttribution: true }}
className="graph-canvas"
>

View File

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

View File

@@ -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<string>): void {
function getDescendants(
featureId: string,
dependentsMap: Map<string, string[]>,
visited: Set<string>
): 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<string, string[]> {
const dependentsMap = new Map<string, string[]>();
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<string>, 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<string>): 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<string>();
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

View File

@@ -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<TaskNodeData, 'task'>;
@@ -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<string, Feature>();
// 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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {
<FileBrowserProvider>
<RootLayoutContent />
</FileBrowserProvider>
{SHOW_QUERY_DEVTOOLS ? (
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
) : null}
</QueryClientProvider>
);
}

View File

@@ -1120,3 +1120,8 @@
animation: none;
}
}
.perf-contain {
contain: layout paint;
content-visibility: auto;
}

View File

@@ -7,6 +7,8 @@ export {
resolveDependencies,
areDependenciesSatisfied,
getBlockingDependencies,
createFeatureMap,
getBlockingDependenciesFromMap,
wouldCreateCircularDependency,
dependencyExists,
getAncestors,

View File

@@ -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<string, Feature> {
const featureMap = new Map<string, Feature>();
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, Feature>
): 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.

View File

@@ -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')];