feat: improve stepper (#229)

This commit is contained in:
Carlos Florêncio
2026-02-16 15:00:14 +00:00
committed by GitHub
parent 658bd349b5
commit 0933f4375f
11 changed files with 240 additions and 97 deletions
+4 -4
View File
@@ -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:
+7
View File
@@ -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>
);
}
+11 -1
View File
@@ -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">
+106 -37
View File
@@ -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));
+3
View File
@@ -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,
},
+16 -1
View File
@@ -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;
+1
View File
@@ -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;
};