add node actions

This commit is contained in:
James
2025-12-22 18:53:44 -05:00
parent 12a796bcbb
commit b3c321ce02
6 changed files with 198 additions and 37 deletions

View File

@@ -1040,6 +1040,9 @@ export function BoardView() {
onSearchQueryChange={setSearchQuery} onSearchQueryChange={setSearchQuery}
onEditFeature={(feature) => setEditingFeature(feature)} onEditFeature={(feature) => setEditingFeature(feature)}
onViewOutput={handleViewOutput} onViewOutput={handleViewOutput}
onStartTask={handleStartImplementation}
onStopTask={handleForceStopFeature}
onResumeTask={handleResumeFeature}
/> />
)} )}
</div> </div>

View File

@@ -10,8 +10,10 @@ import {
Play, Play,
Pause, Pause,
Eye, Eye,
MoreHorizontal, MoreVertical,
GitBranch, GitBranch,
Terminal,
RotateCcw,
} from 'lucide-react'; } from 'lucide-react';
import { TaskNodeData } from '../hooks/use-graph-nodes'; import { TaskNodeData } from '../hooks/use-graph-nodes';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -82,6 +84,9 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
const isHighlighted = data.isHighlighted ?? false; const isHighlighted = data.isHighlighted ?? false;
const isDimmed = data.isDimmed ?? false; const isDimmed = data.isDimmed ?? false;
// Task is stopped if it's in_progress but not actively running
const isStopped = data.status === 'in_progress' && !data.isRunning;
return ( return (
<> <>
{/* Target handle (left side - receives dependencies) */} {/* Target handle (left side - receives dependencies) */}
@@ -167,35 +172,114 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
</TooltipProvider> </TooltipProvider>
)} )}
{/* Stopped indicator - task is in_progress but not actively running */}
{isStopped && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div className="p-1 rounded bg-[var(--status-warning-bg)]">
<Pause className="w-3 h-3 text-[var(--status-warning)]" />
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs max-w-[200px]">
<p>Task paused - click menu to resume</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Actions dropdown */} {/* Actions dropdown */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 hover:bg-background/50"> <Button
<MoreHorizontal className="w-4 h-4" /> variant="ghost"
size="sm"
className={cn(
'h-7 w-7 p-0 rounded-md',
'bg-background/60 hover:bg-background',
'border border-border/50 hover:border-border',
'shadow-sm',
'transition-all duration-150'
)}
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="w-4 h-4 text-foreground" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40"> <DropdownMenuContent
<DropdownMenuItem className="text-xs"> align="end"
className="w-44"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem
className="text-xs cursor-pointer"
onClick={(e) => {
e.stopPropagation();
data.onViewLogs?.();
}}
>
<Terminal className="w-3 h-3 mr-2" />
View Agent Logs
</DropdownMenuItem>
<DropdownMenuItem
className="text-xs cursor-pointer"
onClick={(e) => {
e.stopPropagation();
data.onViewLogs?.();
}}
>
<Eye className="w-3 h-3 mr-2" /> <Eye className="w-3 h-3 mr-2" />
View Details View Details
</DropdownMenuItem> </DropdownMenuItem>
{data.status === 'backlog' && !data.isBlocked && ( {data.status === 'backlog' && !data.isBlocked && (
<DropdownMenuItem className="text-xs"> <DropdownMenuItem
className="text-xs cursor-pointer"
onClick={(e) => {
e.stopPropagation();
data.onStartTask?.();
}}
>
<Play className="w-3 h-3 mr-2" /> <Play className="w-3 h-3 mr-2" />
Start Task Start Task
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{data.isRunning && ( {data.isRunning && (
<DropdownMenuItem className="text-xs text-[var(--status-error)]"> <DropdownMenuItem
className="text-xs text-[var(--status-error)] cursor-pointer"
onClick={(e) => {
e.stopPropagation();
data.onStopTask?.();
}}
>
<Pause className="w-3 h-3 mr-2" /> <Pause className="w-3 h-3 mr-2" />
Stop Task Stop Task
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuSeparator /> {isStopped && (
<DropdownMenuItem className="text-xs"> <DropdownMenuItem
<GitBranch className="w-3 h-3 mr-2" /> className="text-xs text-[var(--status-success)] cursor-pointer"
View Branch onClick={(e) => {
</DropdownMenuItem> e.stopPropagation();
data.onResumeTask?.();
}}
>
<RotateCcw className="w-3 h-3 mr-2" />
Resume Task
</DropdownMenuItem>
)}
{Boolean(data.branchName) && <DropdownMenuSeparator />}
{Boolean(data.branchName) && (
<DropdownMenuItem
className="text-xs cursor-pointer"
onClick={(e) => {
e.stopPropagation();
data.onViewBranch?.();
}}
>
<GitBranch className="w-3 h-3 mr-2" />
View Branch
</DropdownMenuItem>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@@ -223,6 +307,16 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
</div> </div>
)} )}
{/* Paused indicator for stopped tasks */}
{isStopped && (
<div className="mt-2 flex items-center gap-2">
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
<div className="h-full w-1/2 bg-[var(--status-warning)] rounded-full" />
</div>
<span className="text-[10px] text-[var(--status-warning)] font-medium">Paused</span>
</div>
)}
{/* Branch name if assigned */} {/* Branch name if assigned */}
{data.branchName && ( {data.branchName && (
<div className="mt-2 flex items-center gap-1 text-[10px] text-muted-foreground"> <div className="mt-2 flex items-center gap-1 text-[10px] text-muted-foreground">

View File

@@ -27,6 +27,7 @@ import {
useGraphFilter, useGraphFilter,
type TaskNodeData, type TaskNodeData,
type GraphFilterState, type GraphFilterState,
type NodeActionCallbacks,
} from './hooks'; } from './hooks';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -46,8 +47,8 @@ interface GraphCanvasProps {
runningAutoTasks: string[]; runningAutoTasks: string[];
searchQuery: string; searchQuery: string;
onSearchQueryChange: (query: string) => void; onSearchQueryChange: (query: string) => void;
onNodeClick?: (featureId: string) => void;
onNodeDoubleClick?: (featureId: string) => void; onNodeDoubleClick?: (featureId: string) => void;
nodeActionCallbacks?: NodeActionCallbacks;
backgroundStyle?: React.CSSProperties; backgroundStyle?: React.CSSProperties;
className?: string; className?: string;
} }
@@ -57,8 +58,8 @@ function GraphCanvasInner({
runningAutoTasks, runningAutoTasks,
searchQuery, searchQuery,
onSearchQueryChange, onSearchQueryChange,
onNodeClick,
onNodeDoubleClick, onNodeDoubleClick,
nodeActionCallbacks,
backgroundStyle, backgroundStyle,
className, className,
}: GraphCanvasProps) { }: GraphCanvasProps) {
@@ -84,6 +85,7 @@ function GraphCanvasInner({
features, features,
runningAutoTasks, runningAutoTasks,
filterResult, filterResult,
actionCallbacks: nodeActionCallbacks,
}); });
// Apply layout // Apply layout
@@ -118,14 +120,6 @@ function GraphCanvasInner({
setIsNegativeFilter(false); setIsNegativeFilter(false);
}, [onSearchQueryChange]); }, [onSearchQueryChange]);
// Handle node click
const handleNodeClick = useCallback(
(_event: React.MouseEvent, node: Node<TaskNodeData>) => {
onNodeClick?.(node.id);
},
[onNodeClick]
);
// Handle node double click // Handle node double click
const handleNodeDoubleClick = useCallback( const handleNodeDoubleClick = useCallback(
(_event: React.MouseEvent, node: Node<TaskNodeData>) => { (_event: React.MouseEvent, node: Node<TaskNodeData>) => {
@@ -160,7 +154,6 @@ function GraphCanvasInner({
edges={edges} edges={edges}
onNodesChange={isLocked ? undefined : onNodesChange} onNodesChange={isLocked ? undefined : onNodesChange}
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick} onNodeDoubleClick={handleNodeDoubleClick}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes} edgeTypes={edgeTypes}

View File

@@ -2,6 +2,7 @@ import { useMemo, useCallback } from 'react';
import { Feature, useAppStore } from '@/store/app-store'; import { Feature, useAppStore } from '@/store/app-store';
import { GraphCanvas } from './graph-canvas'; import { GraphCanvas } from './graph-canvas';
import { useBoardBackground } from '../board-view/hooks'; import { useBoardBackground } from '../board-view/hooks';
import { NodeActionCallbacks } from './hooks';
interface GraphViewProps { interface GraphViewProps {
features: Feature[]; features: Feature[];
@@ -13,6 +14,9 @@ interface GraphViewProps {
onSearchQueryChange: (query: string) => void; onSearchQueryChange: (query: string) => void;
onEditFeature: (feature: Feature) => void; onEditFeature: (feature: Feature) => void;
onViewOutput: (feature: Feature) => void; onViewOutput: (feature: Feature) => void;
onStartTask?: (feature: Feature) => void;
onStopTask?: (feature: Feature) => void;
onResumeTask?: (feature: Feature) => void;
} }
export function GraphView({ export function GraphView({
@@ -25,6 +29,9 @@ export function GraphView({
onSearchQueryChange, onSearchQueryChange,
onEditFeature, onEditFeature,
onViewOutput, onViewOutput,
onStartTask,
onStopTask,
onResumeTask,
}: GraphViewProps) { }: GraphViewProps) {
const { currentProject } = useAppStore(); const { currentProject } = useAppStore();
@@ -56,17 +63,6 @@ export function GraphView({
}); });
}, [features, currentWorktreePath, currentWorktreeBranch, projectPath]); }, [features, currentWorktreePath, currentWorktreeBranch, projectPath]);
// Handle node click - view details
const handleNodeClick = useCallback(
(featureId: string) => {
const feature = features.find((f) => f.id === featureId);
if (feature) {
onViewOutput(feature);
}
},
[features, onViewOutput]
);
// Handle node double click - edit // Handle node double click - edit
const handleNodeDoubleClick = useCallback( const handleNodeDoubleClick = useCallback(
(featureId: string) => { (featureId: string) => {
@@ -78,6 +74,44 @@ export function GraphView({
[features, onEditFeature] [features, onEditFeature]
); );
// Node action callbacks for dropdown menu
const nodeActionCallbacks: NodeActionCallbacks = useMemo(
() => ({
onViewLogs: (featureId: string) => {
const feature = features.find((f) => f.id === featureId);
if (feature) {
onViewOutput(feature);
}
},
onStartTask: (featureId: string) => {
const feature = features.find((f) => f.id === featureId);
if (feature) {
onStartTask?.(feature);
}
},
onStopTask: (featureId: string) => {
const feature = features.find((f) => f.id === featureId);
if (feature) {
onStopTask?.(feature);
}
},
onResumeTask: (featureId: string) => {
const feature = features.find((f) => f.id === featureId);
if (feature) {
onResumeTask?.(feature);
}
},
onViewBranch: (featureId: string) => {
const feature = features.find((f) => f.id === featureId);
if (feature?.branchName) {
// TODO: Implement view branch action
console.log('View branch:', feature.branchName);
}
},
}),
[features, onViewOutput, onStartTask, onStopTask, onResumeTask]
);
return ( return (
<div className="flex-1 overflow-hidden relative"> <div className="flex-1 overflow-hidden relative">
<GraphCanvas <GraphCanvas
@@ -85,8 +119,8 @@ export function GraphView({
runningAutoTasks={runningAutoTasks} runningAutoTasks={runningAutoTasks}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchQueryChange={onSearchQueryChange} onSearchQueryChange={onSearchQueryChange}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick} onNodeDoubleClick={handleNodeDoubleClick}
nodeActionCallbacks={nodeActionCallbacks}
backgroundStyle={backgroundImageStyle} backgroundStyle={backgroundImageStyle}
className="h-full" className="h-full"
/> />

View File

@@ -3,6 +3,7 @@ export {
type TaskNode, type TaskNode,
type DependencyEdge, type DependencyEdge,
type TaskNodeData, type TaskNodeData,
type NodeActionCallbacks,
} from './use-graph-nodes'; } from './use-graph-nodes';
export { useGraphLayout } from './use-graph-layout'; export { useGraphLayout } from './use-graph-layout';
export { useGraphFilter, type GraphFilterState, type GraphFilterResult } from './use-graph-filter'; export { useGraphFilter, type GraphFilterState, type GraphFilterResult } from './use-graph-filter';

View File

@@ -12,6 +12,12 @@ export interface TaskNodeData extends Feature {
isMatched?: boolean; isMatched?: boolean;
isHighlighted?: boolean; isHighlighted?: boolean;
isDimmed?: boolean; isDimmed?: boolean;
// Action callbacks
onViewLogs?: () => void;
onStartTask?: () => void;
onStopTask?: () => void;
onResumeTask?: () => void;
onViewBranch?: () => void;
} }
export type TaskNode = Node<TaskNodeData, 'task'>; export type TaskNode = Node<TaskNodeData, 'task'>;
@@ -22,17 +28,31 @@ export type DependencyEdge = Edge<{
isDimmed?: boolean; isDimmed?: boolean;
}>; }>;
export interface NodeActionCallbacks {
onViewLogs?: (featureId: string) => void;
onStartTask?: (featureId: string) => void;
onStopTask?: (featureId: string) => void;
onResumeTask?: (featureId: string) => void;
onViewBranch?: (featureId: string) => void;
}
interface UseGraphNodesProps { interface UseGraphNodesProps {
features: Feature[]; features: Feature[];
runningAutoTasks: string[]; runningAutoTasks: string[];
filterResult?: GraphFilterResult; filterResult?: GraphFilterResult;
actionCallbacks?: NodeActionCallbacks;
} }
/** /**
* Transforms features into React Flow nodes and edges * Transforms features into React Flow nodes and edges
* Creates dependency edges based on feature.dependencies array * Creates dependency edges based on feature.dependencies array
*/ */
export function useGraphNodes({ features, runningAutoTasks, filterResult }: UseGraphNodesProps) { export function useGraphNodes({
features,
runningAutoTasks,
filterResult,
actionCallbacks,
}: UseGraphNodesProps) {
const { nodes, edges } = useMemo(() => { const { nodes, edges } = useMemo(() => {
const nodeList: TaskNode[] = []; const nodeList: TaskNode[] = [];
const edgeList: DependencyEdge[] = []; const edgeList: DependencyEdge[] = [];
@@ -70,6 +90,22 @@ export function useGraphNodes({ features, runningAutoTasks, filterResult }: UseG
isMatched, isMatched,
isHighlighted, isHighlighted,
isDimmed, isDimmed,
// Action callbacks (bound to this feature's ID)
onViewLogs: actionCallbacks?.onViewLogs
? () => actionCallbacks.onViewLogs!(feature.id)
: undefined,
onStartTask: actionCallbacks?.onStartTask
? () => actionCallbacks.onStartTask!(feature.id)
: undefined,
onStopTask: actionCallbacks?.onStopTask
? () => actionCallbacks.onStopTask!(feature.id)
: undefined,
onResumeTask: actionCallbacks?.onResumeTask
? () => actionCallbacks.onResumeTask!(feature.id)
: undefined,
onViewBranch: actionCallbacks?.onViewBranch
? () => actionCallbacks.onViewBranch!(feature.id)
: undefined,
}, },
}; };
@@ -107,7 +143,7 @@ export function useGraphNodes({ features, runningAutoTasks, filterResult }: UseG
}); });
return { nodes: nodeList, edges: edgeList }; return { nodes: nodeList, edges: edgeList };
}, [features, runningAutoTasks, filterResult]); }, [features, runningAutoTasks, filterResult, actionCallbacks]);
return { nodes, edges }; return { nodes, edges };
} }