diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index c51c723dbc..a7fea7f1d2 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -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: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000000..37c01a1740 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,7 @@ +name: Test +on: workflow_dispatch +jobs: + test: + runs-on: homelab-runner + steps: + - run: echo "hello from homelab" diff --git a/apps/web/components/settings/workflow-pipeline-editor.tsx b/apps/web/components/settings/workflow-pipeline-editor.tsx index 2a78198ab1..e0ff91ee98 100644 --- a/apps/web/components/settings/workflow-pipeline-editor.tsx +++ b/apps/web/components/settings/workflow-pipeline-editor.tsx @@ -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 (
{/* Event icons */} -
- {hasOnTurnStart && ( - - - - - - On user message - - - )} - {hasAuto && ( - - - - - - Auto-start agent - - - )} - {hasPlan && ( - - - - - - Plan mode - - - )} - {hasTransition && ( - - - - - - Auto-transition - - - )} - {!hasAuto && !hasPlan && !hasTransition && !hasOnTurnStart && ( - manual - )} -
+ manual + } />
{/* Delete button on hover */} diff --git a/apps/web/components/step-capability-icons.tsx b/apps/web/components/step-capability-icons.tsx new file mode 100644 index 0000000000..6492028f2c --- /dev/null +++ b/apps/web/components/step-capability-icons.tsx @@ -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 ?
{fallback}
: null; + } + + return ( +
+ {hasOnTurnStart && ( + + + + + + On user message + + + )} + {hasAuto && ( + + + + + + Auto-start agent + + + )} + {hasPlan && ( + + + + + + Plan mode + + + )} + {hasTransition && ( + + + + + + Auto-transition + + + )} +
+ ); +} diff --git a/apps/web/components/task/task-page-content.tsx b/apps/web/components/task/task-page-content.tsx index 31f89ad7c2..3413331d0f 100644 --- a/apps/web/components/task/task-page-content.tsx +++ b/apps/web/components/task/task-page-content.tsx @@ -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} /> )} diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index 8111caa451..8e6c2bc8b7 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -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({ )}
diff --git a/apps/web/components/task/workflow-stepper.tsx b/apps/web/components/task/workflow-stepper.tsx index 9fa93af9fe..733474ed6f 100644 --- a/apps/web/components/task/workflow-stepper.tsx +++ b/apps/web/components/task/workflow-stepper.tsx @@ -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(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 (
@@ -51,44 +95,69 @@ const WorkflowStepper = memo(function WorkflowStepper({ /> )} - {/* Step circle + label */} -
- {/* Circle indicator */} - - {isCurrent ? ( - /* Current: filled circle with ring */ - <> - - - - ) : isCompleted ? ( - /* Completed: filled circle */ - - ) : ( - /* Future: hollow circle */ - - )} - + + + {/* Step circle + label */} +
+ {/* Circle indicator */} + + {isCurrent ? ( + <> + + + + ) : isCompleted ? ( + + ) : ( + + )} + - {/* Label */} - + {step.name} + +
+
+ - {step.name} -
-
+ {canMove && ( + + )} + {isCurrent && ( +
+ Current step +
+ )} + + +
); })} diff --git a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts index 0b4c20474c..251f073eb9 100644 --- a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts +++ b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts @@ -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)); diff --git a/apps/web/lib/ssr/mapper.ts b/apps/web/lib/ssr/mapper.ts index fa1532f034..9016913701 100644 --- a/apps/web/lib/ssr/mapper.ts +++ b/apps/web/lib/ssr/mapper.ts @@ -34,6 +34,9 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial { 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, }, diff --git a/apps/web/lib/state/slices/kanban/types.ts b/apps/web/lib/state/slices/kanban/types.ts index 5a7fa9a47d..89887909bc 100644 --- a/apps/web/lib/state/slices/kanban/types.ts +++ b/apps/web/lib/state/slices/kanban/types.ts @@ -1,8 +1,23 @@ import type { TaskState as TaskStatus } from '@/lib/types/http'; +export type KanbanStepEvents = { + on_enter?: Array<{ type: string; config?: Record }>; + on_turn_start?: Array<{ type: string; config?: Record }>; + on_turn_complete?: Array<{ type: string; config?: Record }>; +}; + export type KanbanState = { workflowId: string | null; - steps: Array<{ id: string; title: string; color: string; position: number; events?: { on_enter?: Array<{ type: string; config?: Record }>; on_turn_complete?: Array<{ type: string; config?: Record }> } }>; + 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; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 6098f68349..74f59b55c0 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -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; };