mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +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.
|
- 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
|
# UX
|
||||||
|
|
||||||
- Consolidate all models to a single place in the settings instead of having AI profiles and all this other stuff
|
- 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 { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||||
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
|
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { useAutoMode } from '@/hooks/use-auto-mode';
|
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||||
import { useWindowState } from '@/hooks/use-window-state';
|
import { useWindowState } from '@/hooks/use-window-state';
|
||||||
@@ -112,7 +113,31 @@ export function BoardView() {
|
|||||||
isPrimaryWorktreeBranch,
|
isPrimaryWorktreeBranch,
|
||||||
getPrimaryWorktreeBranch,
|
getPrimaryWorktreeBranch,
|
||||||
setPipelineConfig,
|
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
|
// Fetch pipeline config via React Query
|
||||||
const { data: pipelineConfig } = usePipelineConfig(currentProject?.path);
|
const { data: pipelineConfig } = usePipelineConfig(currentProject?.path);
|
||||||
const queryClient = useQueryClient();
|
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 { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store';
|
||||||
import type { ReasoningEffort } from '@automaker/types';
|
import type { ReasoningEffort } from '@automaker/types';
|
||||||
import { getProviderFromModel } from '@/lib/utils';
|
import { getProviderFromModel } from '@/lib/utils';
|
||||||
@@ -56,7 +56,7 @@ interface AgentInfoPanelProps {
|
|||||||
isCurrentAutoTask?: boolean;
|
isCurrentAutoTask?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentInfoPanel({
|
export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||||
feature,
|
feature,
|
||||||
projectPath,
|
projectPath,
|
||||||
contextContent,
|
contextContent,
|
||||||
@@ -405,4 +405,4 @@ export function AgentInfoPanel({
|
|||||||
onOpenChange={setIsSummaryDialogOpen}
|
onOpenChange={setIsSummaryDialogOpen}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
import { memo } from 'react';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +33,7 @@ interface CardActionsProps {
|
|||||||
onApprovePlan?: () => void;
|
onApprovePlan?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardActions({
|
export const CardActions = memo(function CardActions({
|
||||||
feature,
|
feature,
|
||||||
isCurrentAutoTask,
|
isCurrentAutoTask,
|
||||||
hasContext,
|
hasContext,
|
||||||
@@ -344,4 +345,4 @@ export function CardActions({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { memo, useEffect, useMemo, useState } from 'react';
|
||||||
import { Feature, useAppStore } from '@/store/app-store';
|
import { Feature, useAppStore } from '@/store/app-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react';
|
import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react';
|
||||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
/** Uniform badge style for all card badges */
|
/** Uniform badge style for all card badges */
|
||||||
const uniformBadgeClass =
|
const uniformBadgeClass =
|
||||||
@@ -18,7 +19,7 @@ interface CardBadgesProps {
|
|||||||
* CardBadges - Shows error badges below the card header
|
* CardBadges - Shows error badges below the card header
|
||||||
* Note: Blocked/Lock badges are now shown in PriorityBadges for visual consistency
|
* 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) {
|
if (!feature.error) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -46,14 +47,19 @@ export function CardBadges({ feature }: CardBadgesProps) {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
interface PriorityBadgesProps {
|
interface PriorityBadgesProps {
|
||||||
feature: Feature;
|
feature: Feature;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PriorityBadges({ feature }: PriorityBadgesProps) {
|
export const PriorityBadges = memo(function PriorityBadges({ feature }: PriorityBadgesProps) {
|
||||||
const { enableDependencyBlocking, features } = useAppStore();
|
const { enableDependencyBlocking, features } = useAppStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
enableDependencyBlocking: state.enableDependencyBlocking,
|
||||||
|
features: state.features,
|
||||||
|
}))
|
||||||
|
);
|
||||||
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
||||||
|
|
||||||
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
||||||
@@ -223,4 +229,4 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
import { memo } from 'react';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
import { GitBranch, GitPullRequest, ExternalLink } from 'lucide-react';
|
import { GitBranch, GitPullRequest, ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
@@ -7,7 +8,10 @@ interface CardContentSectionsProps {
|
|||||||
useWorktrees: boolean;
|
useWorktrees: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardContentSections({ feature, useWorktrees }: CardContentSectionsProps) {
|
export const CardContentSections = memo(function CardContentSections({
|
||||||
|
feature,
|
||||||
|
useWorktrees,
|
||||||
|
}: CardContentSectionsProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Target Branch Display */}
|
{/* Target Branch Display */}
|
||||||
@@ -48,4 +52,4 @@ export function CardContentSections({ feature, useWorktrees }: CardContentSectio
|
|||||||
})()}
|
})()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { useState } from 'react';
|
import { memo, useState } from 'react';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -37,7 +37,7 @@ interface CardHeaderProps {
|
|||||||
onSpawnTask?: () => void;
|
onSpawnTask?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardHeaderSection({
|
export const CardHeaderSection = memo(function CardHeaderSection({
|
||||||
feature,
|
feature,
|
||||||
isDraggable,
|
isDraggable,
|
||||||
isCurrentAutoTask,
|
isCurrentAutoTask,
|
||||||
@@ -378,4 +378,4 @@ export function CardHeaderSection({
|
|||||||
/>
|
/>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Feature, useAppStore } from '@/store/app-store';
|
import { Feature, useAppStore } from '@/store/app-store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { CardBadges, PriorityBadges } from './card-badges';
|
import { CardBadges, PriorityBadges } from './card-badges';
|
||||||
import { CardHeaderSection } from './card-header';
|
import { CardHeaderSection } from './card-header';
|
||||||
import { CardContentSections } from './card-content-sections';
|
import { CardContentSections } from './card-content-sections';
|
||||||
@@ -61,6 +62,7 @@ interface KanbanCardProps {
|
|||||||
cardBorderEnabled?: boolean;
|
cardBorderEnabled?: boolean;
|
||||||
cardBorderOpacity?: number;
|
cardBorderOpacity?: number;
|
||||||
isOverlay?: boolean;
|
isOverlay?: boolean;
|
||||||
|
reduceEffects?: boolean;
|
||||||
// Selection mode props
|
// Selection mode props
|
||||||
isSelectionMode?: boolean;
|
isSelectionMode?: boolean;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
@@ -94,12 +96,18 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
cardBorderEnabled = true,
|
cardBorderEnabled = true,
|
||||||
cardBorderOpacity = 100,
|
cardBorderOpacity = 100,
|
||||||
isOverlay,
|
isOverlay,
|
||||||
|
reduceEffects = false,
|
||||||
isSelectionMode = false,
|
isSelectionMode = false,
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
onToggleSelect,
|
onToggleSelect,
|
||||||
selectionTarget = null,
|
selectionTarget = null,
|
||||||
}: KanbanCardProps) {
|
}: KanbanCardProps) {
|
||||||
const { useWorktrees, currentProject } = useAppStore();
|
const { useWorktrees, currentProject } = useAppStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
useWorktrees: state.useWorktrees,
|
||||||
|
currentProject: state.currentProject,
|
||||||
|
}))
|
||||||
|
);
|
||||||
const [isLifted, setIsLifted] = useState(false);
|
const [isLifted, setIsLifted] = useState(false);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -140,9 +148,12 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
const hasError = feature.error && !isCurrentAutoTask;
|
const hasError = feature.error && !isCurrentAutoTask;
|
||||||
|
|
||||||
const innerCardClasses = cn(
|
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',
|
'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]!',
|
!glassmorphism && 'backdrop-blur-[0px]!',
|
||||||
!isCurrentAutoTask &&
|
!isCurrentAutoTask &&
|
||||||
cardBorderEnabled &&
|
cardBorderEnabled &&
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { ReactNode } from 'react';
|
import type { CSSProperties, ReactNode, Ref, UIEvent } from 'react';
|
||||||
|
|
||||||
interface KanbanColumnProps {
|
interface KanbanColumnProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,6 +17,11 @@ interface KanbanColumnProps {
|
|||||||
hideScrollbar?: boolean;
|
hideScrollbar?: boolean;
|
||||||
/** Custom width in pixels. If not provided, defaults to 288px (w-72) */
|
/** Custom width in pixels. If not provided, defaults to 288px (w-72) */
|
||||||
width?: number;
|
width?: number;
|
||||||
|
contentRef?: Ref<HTMLDivElement>;
|
||||||
|
onScroll?: (event: UIEvent<HTMLDivElement>) => void;
|
||||||
|
contentClassName?: string;
|
||||||
|
contentStyle?: CSSProperties;
|
||||||
|
disableItemSpacing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KanbanColumn = memo(function KanbanColumn({
|
export const KanbanColumn = memo(function KanbanColumn({
|
||||||
@@ -31,6 +36,11 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
showBorder = true,
|
showBorder = true,
|
||||||
hideScrollbar = false,
|
hideScrollbar = false,
|
||||||
width,
|
width,
|
||||||
|
contentRef,
|
||||||
|
onScroll,
|
||||||
|
contentClassName,
|
||||||
|
contentStyle,
|
||||||
|
disableItemSpacing = false,
|
||||||
}: KanbanColumnProps) {
|
}: KanbanColumnProps) {
|
||||||
const { setNodeRef, isOver } = useDroppable({ id });
|
const { setNodeRef, isOver } = useDroppable({ id });
|
||||||
|
|
||||||
@@ -78,14 +88,19 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
{/* Column Content */}
|
{/* Column Content */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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 &&
|
hideScrollbar &&
|
||||||
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
|
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
|
||||||
// Smooth scrolling
|
// Smooth scrolling
|
||||||
'scroll-smooth',
|
'scroll-smooth',
|
||||||
// Add padding at bottom if there's a footer action
|
// Add padding at bottom if there's a footer action
|
||||||
footerAction && 'pb-14'
|
footerAction && 'pb-14',
|
||||||
|
contentClassName
|
||||||
)}
|
)}
|
||||||
|
ref={contentRef}
|
||||||
|
onScroll={onScroll}
|
||||||
|
style={contentStyle}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { useMemo, useCallback } from 'react';
|
import { useMemo, useCallback } from 'react';
|
||||||
import { Feature, useAppStore } from '@/store/app-store';
|
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'];
|
type ColumnId = Feature['status'];
|
||||||
|
|
||||||
@@ -32,6 +36,8 @@ export function useBoardColumnFeatures({
|
|||||||
verified: [],
|
verified: [],
|
||||||
completed: [], // Completed features are shown in the archive modal, not as a column
|
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)
|
// Filter features by search query (case-insensitive)
|
||||||
const normalizedQuery = searchQuery.toLowerCase().trim();
|
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||||
@@ -55,7 +61,7 @@ export function useBoardColumnFeatures({
|
|||||||
|
|
||||||
filteredFeatures.forEach((f) => {
|
filteredFeatures.forEach((f) => {
|
||||||
// If feature has a running agent, always show it in "in_progress"
|
// 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
|
// Check if feature matches the current worktree by branchName
|
||||||
// Features without branchName are considered unassigned (show only on primary worktree)
|
// Features without branchName are considered unassigned (show only on primary worktree)
|
||||||
@@ -151,7 +157,6 @@ export function useBoardColumnFeatures({
|
|||||||
const { orderedFeatures } = resolveDependencies(map.backlog);
|
const { orderedFeatures } = resolveDependencies(map.backlog);
|
||||||
|
|
||||||
// Get all features to check blocking dependencies against
|
// Get all features to check blocking dependencies against
|
||||||
const allFeatures = features;
|
|
||||||
const enableDependencyBlocking = useAppStore.getState().enableDependencyBlocking;
|
const enableDependencyBlocking = useAppStore.getState().enableDependencyBlocking;
|
||||||
|
|
||||||
// Sort blocked features to the end of the backlog
|
// Sort blocked features to the end of the backlog
|
||||||
@@ -161,7 +166,7 @@ export function useBoardColumnFeatures({
|
|||||||
const blocked: Feature[] = [];
|
const blocked: Feature[] = [];
|
||||||
|
|
||||||
for (const f of orderedFeatures) {
|
for (const f of orderedFeatures) {
|
||||||
if (getBlockingDependencies(f, allFeatures).length > 0) {
|
if (getBlockingDependenciesFromMap(f, featureMap).length > 0) {
|
||||||
blocked.push(f);
|
blocked.push(f);
|
||||||
} else {
|
} else {
|
||||||
unblocked.push(f);
|
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 { DndContext, DragOverlay } from '@dnd-kit/core';
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -64,6 +65,199 @@ interface KanbanBoardProps {
|
|||||||
className?: string;
|
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({
|
export function KanbanBoard({
|
||||||
sensors,
|
sensors,
|
||||||
collisionDetectionStrategy,
|
collisionDetectionStrategy,
|
||||||
@@ -109,7 +303,7 @@ export function KanbanBoard({
|
|||||||
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
|
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
|
||||||
|
|
||||||
// Get the keyboard shortcut for adding features
|
// Get the keyboard shortcut for adding features
|
||||||
const { keyboardShortcuts } = useAppStore();
|
const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
|
||||||
const addFeatureShortcut = keyboardShortcuts.addFeature || 'N';
|
const addFeatureShortcut = keyboardShortcuts.addFeature || 'N';
|
||||||
|
|
||||||
// Use responsive column widths based on window size
|
// Use responsive column widths based on window size
|
||||||
@@ -135,8 +329,27 @@ export function KanbanBoard({
|
|||||||
{columns.map((column) => {
|
{columns.map((column) => {
|
||||||
const columnFeatures = getColumnFeatures(column.id as ColumnId);
|
const columnFeatures = getColumnFeatures(column.id as ColumnId);
|
||||||
return (
|
return (
|
||||||
<KanbanColumn
|
<VirtualizedList
|
||||||
key={column.id}
|
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}
|
id={column.id}
|
||||||
title={column.title}
|
title={column.title}
|
||||||
colorClass={column.colorClass}
|
colorClass={column.colorClass}
|
||||||
@@ -145,6 +358,10 @@ export function KanbanBoard({
|
|||||||
opacity={backgroundSettings.columnOpacity}
|
opacity={backgroundSettings.columnOpacity}
|
||||||
showBorder={backgroundSettings.columnBorderEnabled}
|
showBorder={backgroundSettings.columnBorderEnabled}
|
||||||
hideScrollbar={backgroundSettings.hideScrollbar}
|
hideScrollbar={backgroundSettings.hideScrollbar}
|
||||||
|
contentRef={contentRef}
|
||||||
|
onScroll={shouldVirtualize ? onScroll : undefined}
|
||||||
|
disableItemSpacing={shouldVirtualize}
|
||||||
|
contentClassName="perf-contain"
|
||||||
headerAction={
|
headerAction={
|
||||||
column.id === 'verified' ? (
|
column.id === 'verified' ? (
|
||||||
<div className="flex items-center gap-1">
|
<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'}`}
|
className={`h-6 px-2 text-xs ${selectionTarget === 'backlog' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
|
||||||
onClick={() => onToggleSelectionMode?.('backlog')}
|
onClick={() => onToggleSelectionMode?.('backlog')}
|
||||||
title={
|
title={
|
||||||
selectionTarget === 'backlog' ? 'Switch to Drag Mode' : 'Select Multiple'
|
selectionTarget === 'backlog'
|
||||||
|
? 'Switch to Drag Mode'
|
||||||
|
: 'Select Multiple'
|
||||||
}
|
}
|
||||||
data-testid="selection-mode-button"
|
data-testid="selection-mode-button"
|
||||||
>
|
>
|
||||||
@@ -278,10 +497,16 @@ export function KanbanBoard({
|
|||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SortableContext
|
{(() => {
|
||||||
items={columnFeatures.map((f) => f.id)}
|
const reduceEffects = shouldVirtualize;
|
||||||
strategy={verticalListSortingStrategy}
|
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 */}
|
{/* Empty state card when column has no features */}
|
||||||
{columnFeatures.length === 0 && !isDragging && (
|
{columnFeatures.length === 0 && !isDragging && (
|
||||||
<EmptyStateCard
|
<EmptyStateCard
|
||||||
@@ -290,8 +515,8 @@ export function KanbanBoard({
|
|||||||
addFeatureShortcut={addFeatureShortcut}
|
addFeatureShortcut={addFeatureShortcut}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
onAiSuggest={column.id === 'backlog' ? onAiSuggest : undefined}
|
onAiSuggest={column.id === 'backlog' ? onAiSuggest : undefined}
|
||||||
opacity={backgroundSettings.cardOpacity}
|
opacity={effectiveCardOpacity}
|
||||||
glassmorphism={backgroundSettings.cardGlassmorphism}
|
glassmorphism={effectiveGlassmorphism}
|
||||||
customConfig={
|
customConfig={
|
||||||
column.isPipelineStep
|
column.isPipelineStep
|
||||||
? {
|
? {
|
||||||
@@ -302,8 +527,65 @@ export function KanbanBoard({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{columnFeatures.map((feature, index) => {
|
{shouldVirtualize ? (
|
||||||
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
|
<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;
|
let shortcutKey: string | undefined;
|
||||||
if (column.id === 'in_progress' && index < 10) {
|
if (column.id === 'in_progress' && index < 10) {
|
||||||
shortcutKey = index === 9 ? '0' : String(index + 1);
|
shortcutKey = index === 9 ? '0' : String(index + 1);
|
||||||
@@ -329,19 +611,25 @@ export function KanbanBoard({
|
|||||||
hasContext={featuresWithContext.has(feature.id)}
|
hasContext={featuresWithContext.has(feature.id)}
|
||||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||||
shortcutKey={shortcutKey}
|
shortcutKey={shortcutKey}
|
||||||
opacity={backgroundSettings.cardOpacity}
|
opacity={effectiveCardOpacity}
|
||||||
glassmorphism={backgroundSettings.cardGlassmorphism}
|
glassmorphism={effectiveGlassmorphism}
|
||||||
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
||||||
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
||||||
|
reduceEffects={reduceEffects}
|
||||||
isSelectionMode={isSelectionMode}
|
isSelectionMode={isSelectionMode}
|
||||||
selectionTarget={selectionTarget}
|
selectionTarget={selectionTarget}
|
||||||
isSelected={selectedFeatureIds.has(feature.id)}
|
isSelected={selectedFeatureIds.has(feature.id)}
|
||||||
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
|
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
|
)}
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</KanbanColumn>
|
</KanbanColumn>
|
||||||
|
)}
|
||||||
|
</VirtualizedList>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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 { useAppStore } from '@/store/app-store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +24,10 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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() {
|
export function ChatHistory() {
|
||||||
const {
|
const {
|
||||||
chatSessions,
|
chatSessions,
|
||||||
@@ -34,29 +40,117 @@ export function ChatHistory() {
|
|||||||
unarchiveChatSession,
|
unarchiveChatSession,
|
||||||
deleteChatSession,
|
deleteChatSession,
|
||||||
setChatHistoryOpen,
|
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 [searchQuery, setSearchQuery] = useState('');
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
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) {
|
const normalizedQuery = searchQuery.trim().toLowerCase();
|
||||||
return null;
|
const currentProjectId = currentProject?.id;
|
||||||
}
|
|
||||||
|
|
||||||
// Filter sessions for current project
|
// 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
|
// Filter by search query and archived status
|
||||||
const filteredSessions = projectSessions.filter((session) => {
|
const filteredSessions = useMemo(() => {
|
||||||
const matchesSearch = session.title.toLowerCase().includes(searchQuery.toLowerCase());
|
return projectSessions.filter((session) => {
|
||||||
|
const matchesSearch = session.title.toLowerCase().includes(normalizedQuery);
|
||||||
const matchesArchivedStatus = showArchived ? session.archived : !session.archived;
|
const matchesArchivedStatus = showArchived ? session.archived : !session.archived;
|
||||||
return matchesSearch && matchesArchivedStatus;
|
return matchesSearch && matchesArchivedStatus;
|
||||||
});
|
});
|
||||||
|
}, [projectSessions, normalizedQuery, showArchived]);
|
||||||
|
|
||||||
// Sort by most recently updated
|
// 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()
|
(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 = () => {
|
const handleCreateNewChat = () => {
|
||||||
createChatSession();
|
createChatSession();
|
||||||
@@ -151,7 +245,11 @@ export function ChatHistory() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chat Sessions List */}
|
{/* 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 ? (
|
{sortedSessions.length === 0 ? (
|
||||||
<div className="p-4 text-center text-muted-foreground">
|
<div className="p-4 text-center text-muted-foreground">
|
||||||
{searchQuery ? (
|
{searchQuery ? (
|
||||||
@@ -163,14 +261,26 @@ export function ChatHistory() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2">
|
<div
|
||||||
{sortedSessions.map((session) => (
|
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
|
<div
|
||||||
key={session.id}
|
key={session.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 p-3 rounded-lg cursor-pointer hover:bg-accent transition-colors group',
|
'flex items-center gap-2 p-3 rounded-lg cursor-pointer hover:bg-accent transition-colors group',
|
||||||
currentChatSession?.id === session.id && 'bg-accent'
|
currentChatSession?.id === session.id && 'bg-accent'
|
||||||
)}
|
)}
|
||||||
|
style={{ height: CHAT_SESSION_ROW_HEIGHT_PX }}
|
||||||
onClick={() => handleSelectSession(session)}
|
onClick={() => handleSelectSession(session)}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -199,7 +309,9 @@ export function ChatHistory() {
|
|||||||
Unarchive
|
Unarchive
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem onClick={(e) => handleArchiveSession(session.id, e)}>
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => handleArchiveSession(session.id, e)}
|
||||||
|
>
|
||||||
<Archive className="w-4 h-4 mr-2" />
|
<Archive className="w-4 h-4 mr-2" />
|
||||||
Archive
|
Archive
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -218,6 +330,7 @@ export function ChatHistory() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||||
import { useAppStore, Feature } from '@/store/app-store';
|
import { useAppStore, Feature } from '@/store/app-store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { GraphView } from './graph-view';
|
import { GraphView } from './graph-view';
|
||||||
import {
|
import {
|
||||||
EditFeatureDialog,
|
EditFeatureDialog,
|
||||||
@@ -40,7 +41,20 @@ export function GraphViewPage() {
|
|||||||
addFeatureUseSelectedWorktreeBranch,
|
addFeatureUseSelectedWorktreeBranch,
|
||||||
planUseSelectedWorktreeBranch,
|
planUseSelectedWorktreeBranch,
|
||||||
setPlanUseSelectedWorktreeBranch,
|
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
|
// Ensure worktrees are loaded when landing directly on graph view
|
||||||
useWorktrees({ projectPath: currentProject?.path ?? '' });
|
useWorktrees({ projectPath: currentProject?.path ?? '' });
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { EdgeProps } from '@xyflow/react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
import { Trash2 } from 'lucide-react';
|
import { Trash2 } from 'lucide-react';
|
||||||
|
import { GRAPH_RENDER_MODE_COMPACT, type GraphRenderMode } from '../constants';
|
||||||
|
|
||||||
export interface DependencyEdgeData {
|
export interface DependencyEdgeData {
|
||||||
sourceStatus: Feature['status'];
|
sourceStatus: Feature['status'];
|
||||||
@@ -11,6 +12,7 @@ export interface DependencyEdgeData {
|
|||||||
isHighlighted?: boolean;
|
isHighlighted?: boolean;
|
||||||
isDimmed?: boolean;
|
isDimmed?: boolean;
|
||||||
onDeleteDependency?: (sourceId: string, targetId: string) => void;
|
onDeleteDependency?: (sourceId: string, targetId: string) => void;
|
||||||
|
renderMode?: GraphRenderMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => {
|
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 isHighlighted = edgeData?.isHighlighted ?? false;
|
||||||
const isDimmed = edgeData?.isDimmed ?? false;
|
const isDimmed = edgeData?.isDimmed ?? false;
|
||||||
|
const isCompact = edgeData?.renderMode === GRAPH_RENDER_MODE_COMPACT;
|
||||||
|
|
||||||
const edgeColor = isHighlighted
|
const edgeColor = isHighlighted
|
||||||
? 'var(--brand-500)'
|
? '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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Invisible wider path for hover detection */}
|
{/* Invisible wider path for hover detection */}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { TaskNodeData } from '../hooks/use-graph-nodes';
|
import { TaskNodeData } from '../hooks/use-graph-nodes';
|
||||||
|
import { GRAPH_RENDER_MODE_COMPACT } from '../constants';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -109,9 +110,11 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
|
|||||||
|
|
||||||
// Background/theme settings with defaults
|
// Background/theme settings with defaults
|
||||||
const cardOpacity = data.cardOpacity ?? 100;
|
const cardOpacity = data.cardOpacity ?? 100;
|
||||||
const glassmorphism = data.cardGlassmorphism ?? true;
|
const shouldUseGlassmorphism = data.cardGlassmorphism ?? true;
|
||||||
const cardBorderEnabled = data.cardBorderEnabled ?? true;
|
const cardBorderEnabled = data.cardBorderEnabled ?? true;
|
||||||
const cardBorderOpacity = data.cardBorderOpacity ?? 100;
|
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
|
// Get the border color based on status and error state
|
||||||
const borderColor = data.error
|
const borderColor = data.error
|
||||||
@@ -129,6 +132,99 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
|
|||||||
// Get computed border style
|
// Get computed border style
|
||||||
const borderStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity, borderColor);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Target handle (left side - receives dependencies) */}
|
{/* 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 {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Background,
|
Background,
|
||||||
@@ -39,6 +39,12 @@ import { useDebounceValue } from 'usehooks-ts';
|
|||||||
import { SearchX, Plus, Wand2, ClipboardCheck } from 'lucide-react';
|
import { SearchX, Plus, Wand2, ClipboardCheck } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { PlanSettingsPopover } from '../board-view/dialogs/plan-settings-popover';
|
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
|
// Define custom node and edge types - using any to avoid React Flow's strict typing
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -198,6 +204,17 @@ function GraphCanvasInner({
|
|||||||
// Calculate filter results
|
// Calculate filter results
|
||||||
const filterResult = useGraphFilter(features, filterState, runningAutoTasks);
|
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
|
// Transform features to nodes and edges with filter results
|
||||||
const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({
|
const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({
|
||||||
features,
|
features,
|
||||||
@@ -205,6 +222,8 @@ function GraphCanvasInner({
|
|||||||
filterResult,
|
filterResult,
|
||||||
actionCallbacks: nodeActionCallbacks,
|
actionCallbacks: nodeActionCallbacks,
|
||||||
backgroundSettings,
|
backgroundSettings,
|
||||||
|
renderMode,
|
||||||
|
enableEdgeAnimations: !isLargeGraph,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply layout
|
// Apply layout
|
||||||
@@ -457,6 +476,8 @@ function GraphCanvasInner({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const shouldRenderVisibleOnly = isLargeGraph;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('w-full h-full', className)} style={backgroundStyle}>
|
<div className={cn('w-full h-full', className)} style={backgroundStyle}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
@@ -478,6 +499,7 @@ function GraphCanvasInner({
|
|||||||
maxZoom={2}
|
maxZoom={2}
|
||||||
selectionMode={SelectionMode.Partial}
|
selectionMode={SelectionMode.Partial}
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
|
onlyRenderVisibleElements={shouldRenderVisibleOnly}
|
||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
className="graph-canvas"
|
className="graph-canvas"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function GraphView({
|
|||||||
planUseSelectedWorktreeBranch,
|
planUseSelectedWorktreeBranch,
|
||||||
onPlanUseSelectedWorktreeBranchChange,
|
onPlanUseSelectedWorktreeBranchChange,
|
||||||
}: GraphViewProps) {
|
}: GraphViewProps) {
|
||||||
const { currentProject } = useAppStore();
|
const currentProject = useAppStore((state) => state.currentProject);
|
||||||
|
|
||||||
// Use the same background hook as the board view
|
// Use the same background hook as the board view
|
||||||
const { backgroundImageStyle, backgroundSettings } = useBoardBackground({ currentProject });
|
const { backgroundImageStyle, backgroundSettings } = useBoardBackground({ currentProject });
|
||||||
|
|||||||
@@ -54,16 +54,40 @@ function getAncestors(
|
|||||||
/**
|
/**
|
||||||
* Traverses down to find all descendants (features that depend on this one)
|
* 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;
|
if (visited.has(featureId)) return;
|
||||||
visited.add(featureId);
|
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) {
|
for (const feature of features) {
|
||||||
const deps = feature.dependencies as string[] | undefined;
|
const deps = feature.dependencies as string[] | undefined;
|
||||||
if (deps?.includes(featureId)) {
|
if (!deps || deps.length === 0) continue;
|
||||||
getDescendants(feature.id, features, visited);
|
|
||||||
|
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)
|
* Gets the effective status of a feature (accounting for running state)
|
||||||
* Treats completed (archived) as verified
|
* 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') {
|
if (feature.status === 'in_progress') {
|
||||||
return runningAutoTasks.includes(feature.id) ? 'running' : 'paused';
|
return runningTaskIds.has(feature.id) ? 'running' : 'paused';
|
||||||
}
|
}
|
||||||
// Treat completed (archived) as verified
|
// Treat completed (archived) as verified
|
||||||
if (feature.status === 'completed') {
|
if (feature.status === 'completed') {
|
||||||
@@ -119,6 +143,7 @@ export function useGraphFilter(
|
|||||||
).sort();
|
).sort();
|
||||||
|
|
||||||
const normalizedQuery = searchQuery.toLowerCase().trim();
|
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||||
|
const runningTaskIds = new Set(runningAutoTasks);
|
||||||
const hasSearchQuery = normalizedQuery.length > 0;
|
const hasSearchQuery = normalizedQuery.length > 0;
|
||||||
const hasCategoryFilter = selectedCategories.length > 0;
|
const hasCategoryFilter = selectedCategories.length > 0;
|
||||||
const hasStatusFilter = selectedStatuses.length > 0;
|
const hasStatusFilter = selectedStatuses.length > 0;
|
||||||
@@ -139,6 +164,7 @@ export function useGraphFilter(
|
|||||||
// Find directly matched nodes
|
// Find directly matched nodes
|
||||||
const matchedNodeIds = new Set<string>();
|
const matchedNodeIds = new Set<string>();
|
||||||
const featureMap = new Map(features.map((f) => [f.id, f]));
|
const featureMap = new Map(features.map((f) => [f.id, f]));
|
||||||
|
const dependentsMap = buildDependentsMap(features);
|
||||||
|
|
||||||
for (const feature of features) {
|
for (const feature of features) {
|
||||||
let matchesSearch = true;
|
let matchesSearch = true;
|
||||||
@@ -159,7 +185,7 @@ export function useGraphFilter(
|
|||||||
|
|
||||||
// Check status match
|
// Check status match
|
||||||
if (hasStatusFilter) {
|
if (hasStatusFilter) {
|
||||||
const effectiveStatus = getEffectiveStatus(feature, runningAutoTasks);
|
const effectiveStatus = getEffectiveStatus(feature, runningTaskIds);
|
||||||
matchesStatus = selectedStatuses.includes(effectiveStatus);
|
matchesStatus = selectedStatuses.includes(effectiveStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +216,7 @@ export function useGraphFilter(
|
|||||||
getAncestors(id, featureMap, highlightedNodeIds);
|
getAncestors(id, featureMap, highlightedNodeIds);
|
||||||
|
|
||||||
// Add all descendants (dependents)
|
// Add all descendants (dependents)
|
||||||
getDescendants(id, features, highlightedNodeIds);
|
getDescendants(id, dependentsMap, highlightedNodeIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get edges in the highlighted path
|
// Get edges in the highlighted path
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Node, Edge } from '@xyflow/react';
|
import { Node, Edge } from '@xyflow/react';
|
||||||
import { Feature } from '@/store/app-store';
|
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';
|
import { GraphFilterResult } from './use-graph-filter';
|
||||||
|
|
||||||
export interface TaskNodeData extends Feature {
|
export interface TaskNodeData extends Feature {
|
||||||
@@ -31,6 +32,7 @@ export interface TaskNodeData extends Feature {
|
|||||||
onResumeTask?: () => void;
|
onResumeTask?: () => void;
|
||||||
onSpawnTask?: () => void;
|
onSpawnTask?: () => void;
|
||||||
onDeleteTask?: () => void;
|
onDeleteTask?: () => void;
|
||||||
|
renderMode?: GraphRenderMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskNode = Node<TaskNodeData, 'task'>;
|
export type TaskNode = Node<TaskNodeData, 'task'>;
|
||||||
@@ -40,6 +42,7 @@ export type DependencyEdge = Edge<{
|
|||||||
isHighlighted?: boolean;
|
isHighlighted?: boolean;
|
||||||
isDimmed?: boolean;
|
isDimmed?: boolean;
|
||||||
onDeleteDependency?: (sourceId: string, targetId: string) => void;
|
onDeleteDependency?: (sourceId: string, targetId: string) => void;
|
||||||
|
renderMode?: GraphRenderMode;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export interface NodeActionCallbacks {
|
export interface NodeActionCallbacks {
|
||||||
@@ -66,6 +69,8 @@ interface UseGraphNodesProps {
|
|||||||
filterResult?: GraphFilterResult;
|
filterResult?: GraphFilterResult;
|
||||||
actionCallbacks?: NodeActionCallbacks;
|
actionCallbacks?: NodeActionCallbacks;
|
||||||
backgroundSettings?: BackgroundSettings;
|
backgroundSettings?: BackgroundSettings;
|
||||||
|
renderMode?: GraphRenderMode;
|
||||||
|
enableEdgeAnimations?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,14 +83,14 @@ export function useGraphNodes({
|
|||||||
filterResult,
|
filterResult,
|
||||||
actionCallbacks,
|
actionCallbacks,
|
||||||
backgroundSettings,
|
backgroundSettings,
|
||||||
|
renderMode = GRAPH_RENDER_MODE_FULL,
|
||||||
|
enableEdgeAnimations = true,
|
||||||
}: UseGraphNodesProps) {
|
}: UseGraphNodesProps) {
|
||||||
const { nodes, edges } = useMemo(() => {
|
const { nodes, edges } = useMemo(() => {
|
||||||
const nodeList: TaskNode[] = [];
|
const nodeList: TaskNode[] = [];
|
||||||
const edgeList: DependencyEdge[] = [];
|
const edgeList: DependencyEdge[] = [];
|
||||||
const featureMap = new Map<string, Feature>();
|
const featureMap = createFeatureMap(features);
|
||||||
|
const runningTaskIds = new Set(runningAutoTasks);
|
||||||
// Create feature map for quick lookups
|
|
||||||
features.forEach((f) => featureMap.set(f.id, f));
|
|
||||||
|
|
||||||
// Extract filter state
|
// Extract filter state
|
||||||
const hasActiveFilter = filterResult?.hasActiveFilter ?? false;
|
const hasActiveFilter = filterResult?.hasActiveFilter ?? false;
|
||||||
@@ -95,8 +100,8 @@ export function useGraphNodes({
|
|||||||
|
|
||||||
// Create nodes
|
// Create nodes
|
||||||
features.forEach((feature) => {
|
features.forEach((feature) => {
|
||||||
const isRunning = runningAutoTasks.includes(feature.id);
|
const isRunning = runningTaskIds.has(feature.id);
|
||||||
const blockingDeps = getBlockingDependencies(feature, features);
|
const blockingDeps = getBlockingDependenciesFromMap(feature, featureMap);
|
||||||
|
|
||||||
// Calculate filter highlight states
|
// Calculate filter highlight states
|
||||||
const isMatched = hasActiveFilter && matchedNodeIds.has(feature.id);
|
const isMatched = hasActiveFilter && matchedNodeIds.has(feature.id);
|
||||||
@@ -121,6 +126,7 @@ export function useGraphNodes({
|
|||||||
cardGlassmorphism: backgroundSettings?.cardGlassmorphism,
|
cardGlassmorphism: backgroundSettings?.cardGlassmorphism,
|
||||||
cardBorderEnabled: backgroundSettings?.cardBorderEnabled,
|
cardBorderEnabled: backgroundSettings?.cardBorderEnabled,
|
||||||
cardBorderOpacity: backgroundSettings?.cardBorderOpacity,
|
cardBorderOpacity: backgroundSettings?.cardBorderOpacity,
|
||||||
|
renderMode,
|
||||||
// Action callbacks (bound to this feature's ID)
|
// Action callbacks (bound to this feature's ID)
|
||||||
onViewLogs: actionCallbacks?.onViewLogs
|
onViewLogs: actionCallbacks?.onViewLogs
|
||||||
? () => actionCallbacks.onViewLogs!(feature.id)
|
? () => actionCallbacks.onViewLogs!(feature.id)
|
||||||
@@ -166,13 +172,14 @@ export function useGraphNodes({
|
|||||||
source: depId,
|
source: depId,
|
||||||
target: feature.id,
|
target: feature.id,
|
||||||
type: 'dependency',
|
type: 'dependency',
|
||||||
animated: isRunning || runningAutoTasks.includes(depId),
|
animated: enableEdgeAnimations && (isRunning || runningTaskIds.has(depId)),
|
||||||
data: {
|
data: {
|
||||||
sourceStatus: sourceFeature.status,
|
sourceStatus: sourceFeature.status,
|
||||||
targetStatus: feature.status,
|
targetStatus: feature.status,
|
||||||
isHighlighted: edgeIsHighlighted,
|
isHighlighted: edgeIsHighlighted,
|
||||||
isDimmed: edgeIsDimmed,
|
isDimmed: edgeIsDimmed,
|
||||||
onDeleteDependency: actionCallbacks?.onDeleteDependency,
|
onDeleteDependency: actionCallbacks?.onDeleteDependency,
|
||||||
|
renderMode,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
edgeList.push(edge);
|
edgeList.push(edge);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { toast } from 'sonner';
|
|||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { OpencodeCliStatus, OpencodeCliStatusSkeleton } from '../cli-status/opencode-cli-status';
|
import { OpencodeCliStatus, OpencodeCliStatusSkeleton } from '../cli-status/opencode-cli-status';
|
||||||
import { OpencodeModelConfiguration } from './opencode-model-configuration';
|
import { OpencodeModelConfiguration } from './opencode-model-configuration';
|
||||||
|
import { ProviderToggle } from './provider-toggle';
|
||||||
import { useOpencodeCliStatus, useOpencodeProviders, useOpencodeModels } from '@/hooks/queries';
|
import { useOpencodeCliStatus, useOpencodeProviders, useOpencodeModels } from '@/hooks/queries';
|
||||||
import { queryKeys } from '@/lib/query-keys';
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import type { CliStatus as SharedCliStatus } from '../shared/types';
|
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 { STALE_TIMES } from '@/lib/query-client';
|
||||||
import type { Feature } from '@/store/app-store';
|
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
|
* Fetch all features for a project
|
||||||
*
|
*
|
||||||
@@ -37,6 +40,8 @@ export function useFeatures(projectPath: string | undefined) {
|
|||||||
},
|
},
|
||||||
enabled: !!projectPath,
|
enabled: !!projectPath,
|
||||||
staleTime: STALE_TIMES.FEATURES,
|
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,
|
enabled: !!projectPath && !!featureId && enabled,
|
||||||
staleTime: STALE_TIMES.FEATURES,
|
staleTime: STALE_TIMES.FEATURES,
|
||||||
refetchInterval: pollingInterval,
|
refetchInterval: pollingInterval,
|
||||||
|
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
|
||||||
|
refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,5 +130,7 @@ export function useAgentOutput(
|
|||||||
}
|
}
|
||||||
return false;
|
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 { queryKeys } from '@/lib/query-keys';
|
||||||
import { STALE_TIMES } from '@/lib/query-client';
|
import { STALE_TIMES } from '@/lib/query-client';
|
||||||
|
|
||||||
|
const RUNNING_AGENTS_REFETCH_ON_FOCUS = false;
|
||||||
|
const RUNNING_AGENTS_REFETCH_ON_RECONNECT = false;
|
||||||
|
|
||||||
interface RunningAgentsResult {
|
interface RunningAgentsResult {
|
||||||
agents: RunningAgent[];
|
agents: RunningAgent[];
|
||||||
count: number;
|
count: number;
|
||||||
@@ -43,6 +46,8 @@ export function useRunningAgents() {
|
|||||||
staleTime: STALE_TIMES.RUNNING_AGENTS,
|
staleTime: STALE_TIMES.RUNNING_AGENTS,
|
||||||
// Note: Don't use refetchInterval here - rely on WebSocket invalidation
|
// Note: Don't use refetchInterval here - rely on WebSocket invalidation
|
||||||
// for real-time updates instead of polling
|
// 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) */
|
/** Polling interval for usage data (60 seconds) */
|
||||||
const USAGE_POLLING_INTERVAL = 60 * 1000;
|
const USAGE_POLLING_INTERVAL = 60 * 1000;
|
||||||
|
const USAGE_REFETCH_ON_FOCUS = false;
|
||||||
|
const USAGE_REFETCH_ON_RECONNECT = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch Claude API usage data
|
* Fetch Claude API usage data
|
||||||
@@ -42,6 +44,8 @@ export function useClaudeUsage(enabled = true) {
|
|||||||
refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false,
|
refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false,
|
||||||
// Keep previous data while refetching
|
// Keep previous data while refetching
|
||||||
placeholderData: (previousData) => previousData,
|
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,
|
refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false,
|
||||||
// Keep previous data while refetching
|
// Keep previous data while refetching
|
||||||
placeholderData: (previousData) => previousData,
|
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 { queryKeys } from '@/lib/query-keys';
|
||||||
import { STALE_TIMES } from '@/lib/query-client';
|
import { STALE_TIMES } from '@/lib/query-client';
|
||||||
|
|
||||||
|
const WORKTREE_REFETCH_ON_FOCUS = false;
|
||||||
|
const WORKTREE_REFETCH_ON_RECONNECT = false;
|
||||||
|
|
||||||
interface WorktreeInfo {
|
interface WorktreeInfo {
|
||||||
path: string;
|
path: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
@@ -59,6 +62,8 @@ export function useWorktrees(projectPath: string | undefined, includeDetails = t
|
|||||||
},
|
},
|
||||||
enabled: !!projectPath,
|
enabled: !!projectPath,
|
||||||
staleTime: STALE_TIMES.WORKTREES,
|
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,
|
enabled: !!projectPath && !!featureId,
|
||||||
staleTime: STALE_TIMES.WORKTREES,
|
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,
|
enabled: !!projectPath && !!featureId,
|
||||||
staleTime: STALE_TIMES.WORKTREES,
|
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,
|
enabled: !!projectPath && !!featureId,
|
||||||
staleTime: STALE_TIMES.WORKTREES,
|
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,
|
enabled: !!worktreePath,
|
||||||
staleTime: STALE_TIMES.WORKTREES,
|
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,
|
enabled: !!projectPath,
|
||||||
staleTime: STALE_TIMES.SETTINGS,
|
staleTime: STALE_TIMES.SETTINGS,
|
||||||
|
refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS,
|
||||||
|
refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,5 +264,7 @@ export function useAvailableEditors() {
|
|||||||
return result.editors ?? [];
|
return result.editors ?? [];
|
||||||
},
|
},
|
||||||
staleTime: STALE_TIMES.CLI_STATUS,
|
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';
|
import type { Project } from '@/lib/electron';
|
||||||
|
|
||||||
const logger = createLogger('RootLayout');
|
const logger = createLogger('RootLayout');
|
||||||
|
const SHOW_QUERY_DEVTOOLS = import.meta.env.DEV;
|
||||||
const SERVER_READY_MAX_ATTEMPTS = 8;
|
const SERVER_READY_MAX_ATTEMPTS = 8;
|
||||||
const SERVER_READY_BACKOFF_BASE_MS = 250;
|
const SERVER_READY_BACKOFF_BASE_MS = 250;
|
||||||
const SERVER_READY_MAX_DELAY_MS = 1500;
|
const SERVER_READY_MAX_DELAY_MS = 1500;
|
||||||
@@ -899,7 +900,9 @@ function RootLayout() {
|
|||||||
<FileBrowserProvider>
|
<FileBrowserProvider>
|
||||||
<RootLayoutContent />
|
<RootLayoutContent />
|
||||||
</FileBrowserProvider>
|
</FileBrowserProvider>
|
||||||
|
{SHOW_QUERY_DEVTOOLS ? (
|
||||||
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
|
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
|
||||||
|
) : null}
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1120,3 +1120,8 @@
|
|||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.perf-contain {
|
||||||
|
contain: layout paint;
|
||||||
|
content-visibility: auto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export {
|
|||||||
resolveDependencies,
|
resolveDependencies,
|
||||||
areDependenciesSatisfied,
|
areDependenciesSatisfied,
|
||||||
getBlockingDependencies,
|
getBlockingDependencies,
|
||||||
|
createFeatureMap,
|
||||||
|
getBlockingDependenciesFromMap,
|
||||||
wouldCreateCircularDependency,
|
wouldCreateCircularDependency,
|
||||||
dependencyExists,
|
dependencyExists,
|
||||||
getAncestors,
|
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.
|
* 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.
|
* When we say "targetId depends on sourceId", we add sourceId to targetId.dependencies.
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
resolveDependencies,
|
resolveDependencies,
|
||||||
areDependenciesSatisfied,
|
areDependenciesSatisfied,
|
||||||
getBlockingDependencies,
|
getBlockingDependencies,
|
||||||
|
createFeatureMap,
|
||||||
|
getBlockingDependenciesFromMap,
|
||||||
wouldCreateCircularDependency,
|
wouldCreateCircularDependency,
|
||||||
dependencyExists,
|
dependencyExists,
|
||||||
} from '../src/resolver';
|
} 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', () => {
|
describe('wouldCreateCircularDependency', () => {
|
||||||
it('should return false for features with no existing dependencies', () => {
|
it('should return false for features with no existing dependencies', () => {
|
||||||
const features = [createFeature('A'), createFeature('B')];
|
const features = [createFeature('A'), createFeature('B')];
|
||||||
|
|||||||
Reference in New Issue
Block a user