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;
};