feat: improve stepper (#229)
This commit is contained in:
@@ -4,13 +4,13 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/backend/**'
|
||||
- '.github/workflows/backend-tests.yml'
|
||||
- "apps/backend/**"
|
||||
- ".github/workflows/backend-tests.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/backend/**'
|
||||
- '.github/workflows/backend-tests.yml'
|
||||
- "apps/backend/**"
|
||||
- ".github/workflows/backend-tests.yml"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
name: Test
|
||||
on: workflow_dispatch
|
||||
jobs:
|
||||
test:
|
||||
runs-on: homelab-runner
|
||||
steps:
|
||||
- run: echo "hello from homelab"
|
||||
@@ -20,14 +20,11 @@ import {
|
||||
IconGripVertical,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
IconRobot,
|
||||
IconClipboard,
|
||||
IconArrowRight,
|
||||
IconChevronRight,
|
||||
IconInfoCircle,
|
||||
IconRosetteNumber1,
|
||||
IconMessageForward,
|
||||
} from '@tabler/icons-react';
|
||||
import { StepCapabilityIcons } from '@/components/step-capability-icons';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Input } from '@kandev/ui/input';
|
||||
import { Textarea } from '@kandev/ui/textarea';
|
||||
@@ -220,11 +217,6 @@ function PipelineNode({ step, isSelected, onSelect, onRemove, readOnly = false }
|
||||
transition,
|
||||
};
|
||||
|
||||
const hasAuto = hasOnEnterAction(step, 'auto_start_agent');
|
||||
const hasPlan = hasOnEnterAction(step, 'enable_plan_mode');
|
||||
const hasTransition = getTransitionType(step) !== 'none';
|
||||
const hasOnTurnStart = getOnTurnStartTransitionType(step) !== 'none';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
@@ -276,51 +268,9 @@ function PipelineNode({ step, isSelected, onSelect, onRemove, readOnly = false }
|
||||
</div>
|
||||
|
||||
{/* Event icons */}
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
{hasOnTurnStart && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconMessageForward className="h-3.5 w-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>On user message</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{hasAuto && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconRobot className="h-3.5 w-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Auto-start agent</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{hasPlan && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconClipboard className="h-3.5 w-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Plan mode</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{hasTransition && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconArrowRight className="h-3.5 w-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Auto-transition</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{!hasAuto && !hasPlan && !hasTransition && !hasOnTurnStart && (
|
||||
<span className="text-xs text-muted-foreground/50">manual</span>
|
||||
)}
|
||||
</div>
|
||||
<StepCapabilityIcons events={step.events} fallback={
|
||||
<span className="text-xs text-muted-foreground/50">manual</span>
|
||||
} />
|
||||
</div>
|
||||
|
||||
{/* Delete button on hover */}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type React from 'react';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconClipboard,
|
||||
IconMessageForward,
|
||||
IconRobot,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@kandev/ui/tooltip';
|
||||
import type { KanbanStepEvents } from '@/lib/state/slices/kanban/types';
|
||||
|
||||
type StepCapabilityIconsProps = {
|
||||
events?: KanbanStepEvents;
|
||||
className?: string;
|
||||
fallback?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function StepCapabilityIcons({ events, className, fallback }: StepCapabilityIconsProps) {
|
||||
const hasOnTurnStart = events?.on_turn_start?.some((a) =>
|
||||
['move_to_next', 'move_to_previous', 'move_to_step'].includes(a.type)
|
||||
) ?? false;
|
||||
const hasAuto = events?.on_enter?.some((a) => a.type === 'auto_start_agent') ?? false;
|
||||
const hasPlan = events?.on_enter?.some((a) => a.type === 'enable_plan_mode') ?? false;
|
||||
const hasTransition = events?.on_turn_complete?.some((a) =>
|
||||
['move_to_next', 'move_to_previous', 'move_to_step'].includes(a.type)
|
||||
) ?? false;
|
||||
|
||||
if (!hasOnTurnStart && !hasAuto && !hasPlan && !hasTransition) {
|
||||
return fallback ? <div className={className ?? 'flex items-center gap-1.5 text-muted-foreground'}>{fallback}</div> : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className ?? 'flex items-center gap-1.5 text-muted-foreground'}>
|
||||
{hasOnTurnStart && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconMessageForward className="h-3.5 w-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>On user message</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{hasAuto && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconRobot className="h-3.5 w-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Auto-start agent</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{hasPlan && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconClipboard className="h-3.5 w-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Plan mode</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{hasTransition && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconArrowRight className="h-3.5 w-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Auto-transition</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -103,7 +103,16 @@ export function TaskPageContent({
|
||||
useTasks(task?.workflow_id ?? null);
|
||||
const kanbanSteps = useAppStore((state) => state.kanban.steps);
|
||||
const workflowSteps = useMemo(
|
||||
() => kanbanSteps.map((s) => ({ id: s.id, name: s.title, color: s.color, position: s.position })),
|
||||
() => kanbanSteps.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.title,
|
||||
color: s.color,
|
||||
position: s.position,
|
||||
events: s.events,
|
||||
allow_manual_move: s.allow_manual_move,
|
||||
prompt: s.prompt,
|
||||
is_start_step: s.is_start_step,
|
||||
})),
|
||||
[kanbanSteps]
|
||||
);
|
||||
const connectionStatus = useAppStore((state) => state.connection.status);
|
||||
@@ -260,6 +269,7 @@ export function TaskPageContent({
|
||||
onToggleDebugOverlay={() => setShowDebugOverlay((prev) => !prev)}
|
||||
workflowSteps={workflowSteps}
|
||||
currentStepId={task?.workflow_step_id ?? null}
|
||||
workflowId={task?.workflow_id ?? null}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -49,9 +49,11 @@ type TaskTopBarProps = {
|
||||
onToggleDebugOverlay?: () => void;
|
||||
workflowSteps?: WorkflowStepperStep[];
|
||||
currentStepId?: string | null;
|
||||
workflowId?: string | null;
|
||||
};
|
||||
|
||||
const TaskTopBar = memo(function TaskTopBar({
|
||||
taskId,
|
||||
activeSessionId,
|
||||
taskTitle,
|
||||
baseBranch,
|
||||
@@ -63,6 +65,7 @@ const TaskTopBar = memo(function TaskTopBar({
|
||||
onToggleDebugOverlay,
|
||||
workflowSteps,
|
||||
currentStepId,
|
||||
workflowId,
|
||||
}: TaskTopBarProps) {
|
||||
const [copiedBranch, setCopiedBranch] = useState(false);
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
@@ -232,6 +235,8 @@ const TaskTopBar = memo(function TaskTopBar({
|
||||
<WorkflowStepper
|
||||
steps={workflowSteps}
|
||||
currentStepId={currentStepId ?? null}
|
||||
taskId={taskId ?? null}
|
||||
workflowId={workflowId ?? null}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,24 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useMemo } from 'react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { cn } from '@kandev/ui/lib/utils';
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@kandev/ui/hover-card';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { IconArrowRight } from '@tabler/icons-react';
|
||||
import { moveTask } from '@/lib/api';
|
||||
import { StepCapabilityIcons } from '@/components/step-capability-icons';
|
||||
import type { KanbanStepEvents } from '@/lib/state/slices/kanban/types';
|
||||
|
||||
type Step = {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
position: number;
|
||||
events?: KanbanStepEvents;
|
||||
allow_manual_move?: boolean;
|
||||
prompt?: string;
|
||||
is_start_step?: boolean;
|
||||
};
|
||||
|
||||
type WorkflowStepperProps = {
|
||||
steps: Step[];
|
||||
currentStepId: string | null;
|
||||
taskId?: string | null;
|
||||
workflowId?: string | null;
|
||||
};
|
||||
|
||||
const WorkflowStepper = memo(function WorkflowStepper({
|
||||
steps,
|
||||
currentStepId,
|
||||
taskId,
|
||||
workflowId,
|
||||
}: WorkflowStepperProps) {
|
||||
const [movingToStepId, setMovingToStepId] = useState<string | null>(null);
|
||||
|
||||
const sortedSteps = useMemo(
|
||||
() => [...steps].sort((a, b) => a.position - b.position),
|
||||
[steps]
|
||||
@@ -29,6 +49,25 @@ const WorkflowStepper = memo(function WorkflowStepper({
|
||||
[sortedSteps, currentStepId]
|
||||
);
|
||||
|
||||
const handleMove = useCallback(
|
||||
async (stepId: string) => {
|
||||
if (!taskId || !workflowId) return;
|
||||
setMovingToStepId(stepId);
|
||||
try {
|
||||
await moveTask(taskId, {
|
||||
workflow_id: workflowId,
|
||||
workflow_step_id: stepId,
|
||||
position: 0,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[WorkflowStepper] Failed to move task:', err);
|
||||
} finally {
|
||||
setMovingToStepId(null);
|
||||
}
|
||||
},
|
||||
[taskId, workflowId]
|
||||
);
|
||||
|
||||
if (sortedSteps.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -36,6 +75,11 @@ const WorkflowStepper = memo(function WorkflowStepper({
|
||||
{sortedSteps.map((step, index) => {
|
||||
const isCompleted = currentIndex >= 0 && index < currentIndex;
|
||||
const isCurrent = index === currentIndex;
|
||||
const isAdjacent =
|
||||
currentIndex >= 0 &&
|
||||
(index === currentIndex - 1 || index === currentIndex + 1);
|
||||
const canMove =
|
||||
!isCurrent && taskId && workflowId && (isAdjacent || step.allow_manual_move);
|
||||
|
||||
return (
|
||||
<div key={step.id} className="flex items-center">
|
||||
@@ -51,44 +95,69 @@ const WorkflowStepper = memo(function WorkflowStepper({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step circle + label */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs whitespace-nowrap',
|
||||
isCurrent && 'bg-muted/40'
|
||||
)}
|
||||
>
|
||||
{/* Circle indicator */}
|
||||
<span className="relative flex items-center justify-center shrink-0">
|
||||
{isCurrent ? (
|
||||
/* Current: filled circle with ring */
|
||||
<>
|
||||
<span className="absolute h-3.5 w-3.5 rounded-full border-2 border-primary/40" />
|
||||
<span className="h-2 w-2 rounded-full bg-primary" />
|
||||
</>
|
||||
) : isCompleted ? (
|
||||
/* Completed: filled circle */
|
||||
<span className="h-2 w-2 rounded-full bg-muted-foreground/60" />
|
||||
) : (
|
||||
/* Future: hollow circle */
|
||||
<span className="h-2 w-2 rounded-full border border-muted-foreground/40" />
|
||||
)}
|
||||
</span>
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
{/* Step circle + label */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs whitespace-nowrap transition-colors cursor-default',
|
||||
isCurrent ? 'bg-muted/40' : 'hover:bg-muted/30'
|
||||
)}
|
||||
>
|
||||
{/* Circle indicator */}
|
||||
<span className="relative flex items-center justify-center shrink-0">
|
||||
{isCurrent ? (
|
||||
<>
|
||||
<span className="absolute h-3.5 w-3.5 rounded-full border-2 border-primary/40" />
|
||||
<span className="h-2 w-2 rounded-full bg-primary" />
|
||||
</>
|
||||
) : isCompleted ? (
|
||||
<span className="h-2 w-2 rounded-full bg-muted-foreground/60" />
|
||||
) : (
|
||||
<span className="h-2 w-2 rounded-full border border-muted-foreground/40" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Label */}
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs leading-none',
|
||||
isCurrent
|
||||
? 'text-foreground font-medium'
|
||||
: isCompleted
|
||||
? 'text-muted-foreground'
|
||||
: 'text-muted-foreground/60'
|
||||
)}
|
||||
{/* Label */}
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs leading-none',
|
||||
isCurrent
|
||||
? 'text-foreground font-medium'
|
||||
: isCompleted
|
||||
? 'text-muted-foreground'
|
||||
: 'text-muted-foreground/60'
|
||||
)}
|
||||
>
|
||||
{step.name}
|
||||
</span>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
side="bottom"
|
||||
align="center"
|
||||
className="w-auto min-w-28 p-1.5 flex flex-col items-center gap-1.5"
|
||||
>
|
||||
{step.name}
|
||||
</span>
|
||||
</div>
|
||||
{canMove && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="cursor-pointer text-xs h-6 px-2.5 rounded-sm"
|
||||
disabled={movingToStepId === step.id}
|
||||
onClick={() => handleMove(step.id)}
|
||||
>
|
||||
<IconArrowRight className="h-3 w-3" />
|
||||
{movingToStepId === step.id ? 'Moving...' : 'Move here'}
|
||||
</Button>
|
||||
)}
|
||||
{isCurrent && (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Current step
|
||||
</div>
|
||||
)}
|
||||
<StepCapabilityIcons events={step.events} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -42,6 +42,9 @@ export function useAllWorkflowSnapshots(workspaceId: string | null) {
|
||||
color: step.color ?? 'bg-neutral-400',
|
||||
position: step.position,
|
||||
events: step.events,
|
||||
allow_manual_move: step.allow_manual_move,
|
||||
prompt: step.prompt,
|
||||
is_start_step: step.is_start_step,
|
||||
}));
|
||||
const stepIds = new Set(steps.map((s) => s.id));
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial<AppState> {
|
||||
color: step.color ?? 'bg-neutral-400',
|
||||
position: step.position,
|
||||
events: step.events,
|
||||
allow_manual_move: step.allow_manual_move,
|
||||
prompt: step.prompt,
|
||||
is_start_step: step.is_start_step,
|
||||
})),
|
||||
tasks,
|
||||
},
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import type { TaskState as TaskStatus } from '@/lib/types/http';
|
||||
|
||||
export type KanbanStepEvents = {
|
||||
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
|
||||
on_turn_start?: Array<{ type: string; config?: Record<string, unknown> }>;
|
||||
on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>;
|
||||
};
|
||||
|
||||
export type KanbanState = {
|
||||
workflowId: string | null;
|
||||
steps: Array<{ id: string; title: string; color: string; position: number; events?: { on_enter?: Array<{ type: string; config?: Record<string, unknown> }>; on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }> } }>;
|
||||
steps: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
color: string;
|
||||
position: number;
|
||||
events?: KanbanStepEvents;
|
||||
allow_manual_move?: boolean;
|
||||
prompt?: string;
|
||||
is_start_step?: boolean;
|
||||
}>;
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
workflowStepId: string;
|
||||
|
||||
@@ -239,6 +239,7 @@ export type WorkflowStepDTO = {
|
||||
prompt?: string;
|
||||
events?: StepEvents;
|
||||
allow_manual_move: boolean;
|
||||
is_start_step?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user