mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-29 22:02:02 +00:00
perf(ui): smooth large lists and graphs
This commit is contained in:
8
TODO.md
8
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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 ?? '' });
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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) */}
|
||||
|
||||
7
apps/ui/src/components/views/graph-view/constants.ts
Normal file
7
apps/ui/src/components/views/graph-view/constants.ts
Normal 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;
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1120,3 +1120,8 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.perf-contain {
|
||||
contain: layout paint;
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ export {
|
||||
resolveDependencies,
|
||||
areDependenciesSatisfied,
|
||||
getBlockingDependencies,
|
||||
createFeatureMap,
|
||||
getBlockingDependenciesFromMap,
|
||||
wouldCreateCircularDependency,
|
||||
dependencyExists,
|
||||
getAncestors,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')];
|
||||
|
||||
Reference in New Issue
Block a user