feat: improve workflows (#228)

This commit is contained in:
Carlos Florêncio
2026-02-16 13:48:58 +00:00
committed by GitHub
parent bd88ea29bb
commit 658bd349b5
166 changed files with 8715 additions and 4593 deletions
@@ -24,7 +24,7 @@ interface DiffViewerResolverProps {
export const DiffViewerResolved = memo(function DiffViewerResolved(props: DiffViewerResolverProps) {
const provider = useEditorProvider('diff-viewer');
if (provider === 'monaco') {
const { enableComments: _, ...rest } = props;
const { enableComments: _enableComments, ...rest } = props;
return <MonacoDiffViewer {...rest} />;
}
return <PierreDiffViewer {...props} />;
@@ -250,7 +250,6 @@ export function MonacoCodeEditor({
// Cmd/Ctrl+S to save
editor.addCommand(
// eslint-disable-next-line no-bitwise
(window as { monaco?: typeof import('monaco-editor') }).monaco!.KeyMod.CtrlCmd |
(window as { monaco?: typeof import('monaco-editor') }).monaco!.KeyCode.KeyS,
() => {
+45 -45
View File
@@ -21,13 +21,13 @@ import { useResponsiveBreakpoint } from '@/hooks/use-responsive-breakpoint';
import { useAppStore } from '@/components/state-provider';
export type KanbanBoardGridProps = {
columns: WorkflowStep[];
steps: WorkflowStep[];
tasks: Task[];
onPreviewTask: (task: Task) => void;
onOpenTask: (task: Task) => void;
onEditTask: (task: Task) => void;
onDeleteTask: (task: Task) => void;
onMoveTask?: (task: Task, targetColumnId: string) => void;
onMoveTask?: (task: Task, targetStepId: string) => void;
onDragStart: (event: DragStartEvent) => void;
onDragEnd: (event: DragEndEvent) => void;
onDragCancel: () => void;
@@ -39,7 +39,7 @@ export type KanbanBoardGridProps = {
};
export function KanbanBoardGrid({
columns,
steps,
tasks,
onPreviewTask,
onOpenTask,
@@ -74,58 +74,58 @@ export function KanbanBoardGrid({
})
);
const getTasksForColumn = (columnId: string) => {
const getTasksForStep = (stepId: string) => {
return tasks
.filter((task) => task.workflowStepId === columnId)
.filter((task) => task.workflowStepId === stepId)
.map((task) => ({ ...task, position: task.position ?? 0 }))
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
};
// Calculate task counts per column for tabs
// Calculate task counts per step for tabs
const taskCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const column of columns) {
counts[column.id] = tasks.filter((task) => task.workflowStepId === column.id).length;
for (const step of steps) {
counts[step.id] = tasks.filter((task) => task.workflowStepId === step.id).length;
}
return counts;
}, [columns, tasks]);
}, [steps, tasks]);
// On mobile, select the first column with tasks on initial load
// On mobile, select the first step with tasks on initial load
const hasInitializedRef = useRef(false);
useEffect(() => {
if (!isMobile || hasInitializedRef.current || columns.length === 0) return;
if (!isMobile || hasInitializedRef.current || steps.length === 0) return;
const firstColumnWithTasks = columns.findIndex(
(column) => taskCounts[column.id] > 0
const firstStepWithTasks = steps.findIndex(
(step) => taskCounts[step.id] > 0
);
if (firstColumnWithTasks !== -1 && firstColumnWithTasks !== activeColumnIndex) {
setActiveColumnIndex(firstColumnWithTasks);
if (firstStepWithTasks !== -1 && firstStepWithTasks !== activeColumnIndex) {
setActiveColumnIndex(firstStepWithTasks);
}
hasInitializedRef.current = true;
}, [isMobile, columns, taskCounts, activeColumnIndex, setActiveColumnIndex]);
}, [isMobile, steps, taskCounts, activeColumnIndex, setActiveColumnIndex]);
// Get current column ID for mobile drop targets
const currentColumnId = columns[activeColumnIndex]?.id ?? null;
// Get current step ID for mobile drop targets
const currentStepId = steps[activeColumnIndex]?.id ?? null;
// Check if we have a board selected (from SSR or client)
const boardsActiveId = useAppStore((state) => state.boards.activeId);
const kanbanBoardId = useAppStore((state) => state.kanban.boardId);
// Check if we have a workflow selected (from SSR or client)
const workflowsActiveId = useAppStore((state) => state.workflows.activeId);
const kanbanWorkflowId = useAppStore((state) => state.kanban.workflowId);
// Show loading state when:
// 1. isLoading is explicitly true, or
// 2. We have an active board selected but the kanban data hasn't been hydrated yet
// (boards.activeId is set but kanban.boardId is null - waiting for snapshot)
// 3. isLoading is undefined AND no columns AND no board selected
// 2. We have an active workflow selected but the kanban data hasn't been hydrated yet
// (workflows.activeId is set but kanban.workflowId is null - waiting for snapshot)
// 3. isLoading is undefined AND no columns AND no workflow selected
// (still initializing before any SSR data)
const showLoading =
isLoading === true ||
(boardsActiveId && !kanbanBoardId) ||
(isLoading === undefined && columns.length === 0 && !boardsActiveId);
(workflowsActiveId && !kanbanWorkflowId) ||
(isLoading === undefined && steps.length === 0 && !workflowsActiveId);
const renderEmptyState = () => (
<div className="h-full rounded-lg border border-dashed border-border/60 flex items-center justify-center text-sm text-muted-foreground mx-4">
{showLoading ? 'Loading...' : 'No boards available yet.'}
{showLoading ? 'Loading...' : 'No workflows available yet.'}
</div>
);
@@ -139,18 +139,18 @@ export function KanbanBoardGrid({
onDragCancel={onDragCancel}
>
<div className="flex-1 min-h-0 flex flex-col">
{showLoading || columns.length === 0 ? (
{showLoading || steps.length === 0 ? (
renderEmptyState()
) : (
<>
<MobileColumnTabs
columns={columns}
steps={steps}
activeIndex={activeColumnIndex}
taskCounts={taskCounts}
onColumnChange={setActiveColumnIndex}
/>
<SwipeableColumns
columns={columns}
steps={steps}
tasks={tasks}
activeIndex={activeColumnIndex}
onIndexChange={setActiveColumnIndex}
@@ -163,8 +163,8 @@ export function KanbanBoardGrid({
deletingTaskId={deletingTaskId}
/>
<MobileDropTargets
columns={columns}
currentColumnId={currentColumnId}
steps={steps}
currentStepId={currentStepId}
isDragging={!!activeTask}
/>
</>
@@ -192,24 +192,24 @@ export function KanbanBoardGrid({
onDragCancel={onDragCancel}
>
<div className="flex-1 min-h-0 px-4 pb-4">
{showLoading || columns.length === 0 ? (
{showLoading || steps.length === 0 ? (
renderEmptyState()
) : (
<div className="flex overflow-x-auto snap-x snap-mandatory gap-2 h-full scrollbar-hide">
{columns.map((column) => (
{steps.map((step) => (
<div
key={column.id}
key={step.id}
className="flex-shrink-0 w-[calc(50%-4px)] snap-start h-full"
>
<KanbanColumn
column={column}
tasks={getTasksForColumn(column.id)}
step={step}
tasks={getTasksForStep(step.id)}
onPreviewTask={onPreviewTask}
onOpenTask={onOpenTask}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
onMoveTask={onMoveTask}
columns={columns}
steps={steps}
showMaximizeButton={showMaximizeButton}
deletingTaskId={deletingTaskId}
/>
@@ -234,24 +234,24 @@ export function KanbanBoardGrid({
onDragCancel={onDragCancel}
>
<div className="flex-1 min-h-0 px-4 pb-4">
{showLoading || columns.length === 0 ? (
{showLoading || steps.length === 0 ? (
renderEmptyState()
) : (
<div
className="grid gap-2 rounded-lg h-full"
style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(0, 1fr))` }}
style={{ gridTemplateColumns: `repeat(${steps.length}, minmax(0, 1fr))` }}
>
{columns.map((column) => (
{steps.map((step) => (
<KanbanColumn
key={column.id}
column={column}
tasks={getTasksForColumn(column.id)}
key={step.id}
step={step}
tasks={getTasksForStep(step.id)}
onPreviewTask={onPreviewTask}
onOpenTask={onOpenTask}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
onMoveTask={onMoveTask}
columns={columns}
steps={steps}
showMaximizeButton={showMaximizeButton}
deletingTaskId={deletingTaskId}
/>
+50 -60
View File
@@ -6,11 +6,12 @@ import { Task } from './kanban-card';
import { TaskCreateDialog } from './task-create-dialog';
import { useAppStore, useAppStoreApi } from '@/components/state-provider';
import type { Task as BackendTask } from '@/lib/types/http';
import type { BoardState } from '@/lib/state/slices';
import { useDragAndDrop, type WorkflowAutomation, type MoveTaskError } from '@/hooks/use-drag-and-drop';
import { KanbanBoardGrid } from './kanban-board-grid';
import type { WorkflowsState } from '@/lib/state/slices';
import { type WorkflowAutomation, type MoveTaskError } from '@/hooks/use-drag-and-drop';
import { SwimlaneContainer } from './kanban/swimlane-container';
import { KanbanHeader } from './kanban/kanban-header';
import { useKanbanData, useKanbanActions, useKanbanNavigation } from '@/hooks/domains/kanban';
import { useAllWorkflowSnapshots } from '@/hooks/domains/kanban/use-all-workflow-snapshots';
import { useResponsiveBreakpoint } from '@/hooks/use-responsive-breakpoint';
import { getWebSocketClient } from '@/lib/ws/connection';
import { linkToSession } from '@/lib/links';
@@ -40,12 +41,18 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
// Search state
const [searchQuery, setSearchQuery] = useState('');
// View mode from user settings
const kanbanViewMode = useAppStore((state) => state.userSettings.kanbanViewMode);
// Get initial state for actions hook
const kanban = useAppStore((state) => state.kanban);
const workspaceState = useAppStore((state) => state.workspaces);
const boardsState = useAppStore((state) => state.boards);
const setActiveBoard = useAppStore((state) => state.setActiveBoard);
const setBoards = useAppStore((state) => state.setBoards);
const workflowsState = useAppStore((state) => state.workflows);
const setActiveWorkflow = useAppStore((state) => state.setActiveWorkflow);
const setWorkflows = useAppStore((state) => state.setWorkflows);
// Load all workflow snapshots for swimlane views
useAllWorkflowSnapshots(workspaceState.activeId);
// Consolidated actions hook
const {
@@ -59,22 +66,21 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
handleDialogOpenChange,
handleDialogSuccess,
handleWorkspaceChange,
handleBoardChange,
handleWorkflowChange,
deletingTaskId,
} = useKanbanActions({ workspaceState, boardsState });
} = useKanbanActions({ workspaceState, workflowsState });
// Data fetching and derived state
const {
enablePreviewOnClick,
userSettings,
commitSettings,
activeColumns,
visibleTasksWithSessions,
activeSteps,
isMounted,
setTaskSessionAvailability,
} = useKanbanData({
onWorkspaceChange: handleWorkspaceChange,
onBoardChange: handleBoardChange,
onWorkflowChange: handleWorkflowChange,
searchQuery,
});
@@ -146,56 +152,44 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
[workflowAutomation]
);
// Drag and drop with workflow automation callback
const { activeTask, handleDragStart, handleDragEnd, handleDragCancel, moveTaskToColumn } = useDragAndDrop({
visibleTasks: visibleTasksWithSessions,
onWorkflowAutomation: handleWorkflowAutomation,
onMoveError: handleMoveError,
});
// Ref for userSettings to avoid reactive deps in board selection effect
// Ref for userSettings to avoid reactive deps in workflow selection effect
const userSettingsRef = useRef(userSettings);
useEffect(() => {
userSettingsRef.current = userSettings;
});
// Board selection effect
// Workflow selection effect
useEffect(() => {
const workspaceId = workspaceState.activeId;
if (!workspaceId) {
if (boardsState.items.length || boardsState.activeId) {
setBoards([]);
setActiveBoard(null);
if (workflowsState.items.length || workflowsState.activeId) {
setWorkflows([]);
setActiveWorkflow(null);
}
return;
}
const settings = userSettingsRef.current;
const workspaceBoards = boardsState.items.filter(
(board: BoardState['items'][number]) => board.workspaceId === workspaceId
const workspaceWorkflows = workflowsState.items.filter(
(workflow: WorkflowsState['items'][number]) => workflow.workspaceId === workspaceId
);
const desiredBoardId =
(settings.boardId && workspaceBoards.some((board: BoardState['items'][number]) => board.id === settings.boardId)
? settings.boardId
: workspaceBoards[0]?.id) ?? null;
setActiveBoard(desiredBoardId);
if (settings.loaded && desiredBoardId !== settings.boardId) {
commitSettings({
workspaceId,
boardId: desiredBoardId,
repositoryIds: settings.repositoryIds,
});
}
if (!desiredBoardId) {
// Default to "All Workflows" (null) unless settings explicitly specify a workflow
const desiredWorkflowId =
settings.workflowId && workspaceWorkflows.some((workflow: WorkflowsState['items'][number]) => workflow.id === settings.workflowId)
? settings.workflowId
: null;
setActiveWorkflow(desiredWorkflowId);
if (!desiredWorkflowId) {
store.getState().hydrate({
kanban: { boardId: null, steps: [], tasks: [] },
kanban: { workflowId: null, steps: [], tasks: [] },
});
}
}, [
boardsState.activeId,
boardsState.items,
workflowsState.activeId,
workflowsState.items,
commitSettings,
setActiveBoard,
setBoards,
setActiveWorkflow,
setWorkflows,
store,
workspaceState.activeId,
]);
@@ -218,9 +212,9 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
open={isDialogOpen}
onOpenChange={handleDialogOpenChange}
workspaceId={workspaceState.activeId}
boardId={kanban.boardId}
defaultColumnId={activeColumns[0]?.id ?? null}
columns={activeColumns.map((column) => ({ id: column.id, title: column.title, autoStartAgent: column.autoStartAgent }))}
workflowId={kanban.workflowId}
defaultStepId={activeSteps[0]?.id ?? null}
steps={activeSteps.map((step) => ({ id: step.id, title: step.title, events: step.events }))}
editingTask={
editingTask
? {
@@ -255,9 +249,9 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
}}
mode="session"
workspaceId={workspaceState.activeId}
boardId={kanban.boardId}
defaultColumnId={workflowAutomation?.workflowStep.id ?? null}
columns={activeColumns.map((column) => ({ id: column.id, title: column.title, autoStartAgent: column.autoStartAgent }))}
workflowId={kanban.workflowId}
defaultStepId={workflowAutomation?.workflowStep.id ?? null}
steps={activeSteps.map((step) => ({ id: step.id, title: step.title, events: step.events }))}
editingTask={null}
onCreateSession={handleWorkflowSessionCreate}
initialValues={{
@@ -287,22 +281,18 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<KanbanBoardGrid
columns={activeColumns}
tasks={visibleTasksWithSessions}
<SwimlaneContainer
viewMode={kanbanViewMode || ''}
workflowFilter={workflowsState.activeId}
onPreviewTask={handleCardClick}
onOpenTask={handleOpenTask}
onEditTask={handleEdit}
onDeleteTask={handleDelete}
onMoveTask={moveTaskToColumn}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
activeTask={activeTask}
showMaximizeButton={enablePreviewOnClick}
onMoveError={handleMoveError}
onWorkflowAutomation={handleWorkflowAutomation}
deletingTaskId={deletingTaskId}
onCreateTask={handleCreate}
isLoading={kanban.isLoading}
searchQuery={searchQuery}
selectedRepositoryIds={userSettings.repositoryIds}
/>
</div>
);
+9 -6
View File
@@ -19,6 +19,7 @@ import {
import type { TaskState, Repository } from '@/lib/types/http';
import { cn, getRepositoryDisplayName } from '@/lib/utils';
import { getTaskStateIcon } from '@/lib/ui/state-icons';
import { needsAction } from '@/lib/utils/needs-action';
import { useAppStore } from '@/components/state-provider';
export interface Task {
@@ -34,9 +35,10 @@ export interface Task {
sessionCount?: number | null;
primarySessionId?: string | null;
reviewStatus?: 'pending' | 'approved' | 'changes_requested' | 'rejected' | null;
updatedAt?: string;
}
export interface WorkflowColumn {
export interface WorkflowStep {
id: string;
title: string;
color: string;
@@ -49,8 +51,8 @@ interface KanbanCardProps {
onEdit?: (task: Task) => void;
onDelete?: (task: Task) => void;
onOpenFullPage?: (task: Task) => void;
onMove?: (task: Task, targetColumnId: string) => void;
columns?: WorkflowColumn[];
onMove?: (task: Task, targetStepId: string) => void;
steps?: WorkflowStep[];
showMaximizeButton?: boolean;
isDeleting?: boolean;
}
@@ -114,7 +116,7 @@ function KanbanCardLayout({ task, repositoryName, className }: KanbanCardProps &
);
}
export function KanbanCard({ task, repositoryName, onClick, onEdit, onDelete, onOpenFullPage, onMove, columns, showMaximizeButton = true, isDeleting }: KanbanCardProps) {
export function KanbanCard({ task, repositoryName, onClick, onEdit, onDelete, onOpenFullPage, onMove, steps, showMaximizeButton = true, isDeleting }: KanbanCardProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: task.id,
});
@@ -139,6 +141,7 @@ export function KanbanCard({ task, repositoryName, onClick, onEdit, onDelete, on
className={cn(
'max-h-48 bg-card rounded-sm data-[size=sm]:py-1 cursor-pointer mb-2 w-full py-0 relative border border-border overflow-visible shadow-none ring-0',
(task.state === 'IN_PROGRESS' || task.state === 'SCHEDULING') && 'kanban-task-pulse',
needsAction(task) && 'border-l-2 border-l-amber-500',
isDragging && 'opacity-50 z-50'
)}
onClick={() => onClick?.(task)}
@@ -193,7 +196,7 @@ export function KanbanCard({ task, repositoryName, onClick, onEdit, onDelete, on
>
Edit
</DropdownMenuItem>
{columns && columns.length > 1 && onMove && (
{steps && steps.length > 1 && onMove && (
<DropdownMenuSub>
<DropdownMenuSubTrigger
disabled={isDeleting}
@@ -204,7 +207,7 @@ export function KanbanCard({ task, repositoryName, onClick, onEdit, onDelete, on
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{columns
{steps
.filter((col) => col.id !== task.workflowStepId)
.map((col) => (
<DropdownMenuItem
+14 -13
View File
@@ -2,7 +2,7 @@
import { useMemo } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { KanbanCard, Task, WorkflowColumn } from './kanban-card';
import { KanbanCard, Task } from './kanban-card';
import { Badge } from '@kandev/ui/badge';
import { cn, getRepositoryDisplayName } from '@/lib/utils';
import { useAppStore } from '@/components/state-provider';
@@ -12,26 +12,26 @@ export interface WorkflowStep {
id: string;
title: string;
color: string;
autoStartAgent?: boolean;
events?: { on_enter?: Array<{ type: string; config?: Record<string, unknown> }>; on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }> };
}
interface KanbanColumnProps {
column: WorkflowStep;
step: WorkflowStep;
tasks: Task[];
onPreviewTask: (task: Task) => void;
onOpenTask: (task: Task) => void;
onEditTask: (task: Task) => void;
onDeleteTask: (task: Task) => void;
onMoveTask?: (task: Task, targetColumnId: string) => void;
columns?: WorkflowColumn[];
onMoveTask?: (task: Task, targetStepId: string) => void;
steps?: WorkflowStep[];
showMaximizeButton?: boolean;
deletingTaskId?: string | null;
hideHeader?: boolean;
}
export function KanbanColumn({ column, tasks, onPreviewTask, onOpenTask, onEditTask, onDeleteTask, onMoveTask, columns, showMaximizeButton, deletingTaskId, hideHeader = false }: KanbanColumnProps) {
export function KanbanColumn({ step, tasks, onPreviewTask, onOpenTask, onEditTask, onDeleteTask, onMoveTask, steps, showMaximizeButton, deletingTaskId, hideHeader = false }: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({
id: column.id,
id: step.id,
});
// Access repositories from store to pass repository names to cards
@@ -52,16 +52,17 @@ export function KanbanColumn({ column, tasks, onPreviewTask, onOpenTask, onEditT
<div
ref={setNodeRef}
className={cn(
'flex flex-col flex-1 h-full min-w-0 p-3 sm:min-h-[300px] rounded-sm border border-border',
isOver && 'ring-2 ring-primary'
'flex flex-col flex-1 h-full min-w-0 px-3 py-2 sm:min-h-[200px]',
'border-r border-dashed border-border/50 last:border-r-0',
isOver && 'bg-primary/5'
)}
>
{/* Column Header */}
{!hideHeader && (
<div className="flex items-center justify-between border-b border-border/70 pb-3 mb-4 px-1">
<div className="flex items-center justify-between pb-2 mb-3 px-1">
<div className="flex items-center gap-2">
<div className={cn('w-2 h-2 rounded-full', column.color)} />
<h2 className="font-semibold text-sm">{column.title}</h2>
<div className={cn('w-2 h-2 rounded-full', step.color)} />
<h2 className="font-semibold text-sm">{step.title}</h2>
<Badge variant="secondary" className="text-xs">
{tasks.length}
</Badge>
@@ -81,7 +82,7 @@ export function KanbanColumn({ column, tasks, onPreviewTask, onOpenTask, onEditT
onEdit={onEditTask}
onDelete={onDeleteTask}
onMove={onMoveTask}
columns={columns}
steps={steps}
showMaximizeButton={showMaximizeButton}
isDeleting={deletingTaskId === task.id}
/>
+19 -18
View File
@@ -19,22 +19,22 @@ import {
import { IconAdjustmentsHorizontal } from '@tabler/icons-react';
import { useKanbanDisplaySettings } from '@/hooks/use-kanban-display-settings';
import type { Workspace, Repository } from '@/lib/types/http';
import type { BoardState } from '@/lib/state/slices';
import type { WorkflowsState } from '@/lib/state/slices';
import { Badge } from '@kandev/ui/badge';
export function KanbanDisplayDropdown() {
const {
workspaces,
boards,
workflows,
activeWorkspaceId,
activeBoardId,
activeWorkflowId,
repositories,
repositoriesLoading,
allRepositoriesSelected,
selectedRepositoryId,
enablePreviewOnClick,
onWorkspaceChange,
onBoardChange,
onWorkflowChange,
onRepositoryChange,
onTogglePreviewOnClick,
} = useKanbanDisplaySettings();
@@ -52,12 +52,12 @@ export function KanbanDisplayDropdown() {
<DropdownMenuContent align="end" className="w-[280px] p-3">
<div className="space-y-3">
<div className="space-y-1.5">
<DropdownMenuLabel className="px-0">Workspace</DropdownMenuLabel>
<DropdownMenuLabel className="px-0 text-foreground">Workspace</DropdownMenuLabel>
<Select
value={activeWorkspaceId ?? ''}
onValueChange={(value) => onWorkspaceChange(value || null)}
>
<SelectTrigger className="w-full">
<SelectTrigger className="w-full border-border">
<SelectValue placeholder="Select workspace" />
</SelectTrigger>
<SelectContent>
@@ -71,18 +71,19 @@ export function KanbanDisplayDropdown() {
</div>
<DropdownMenuSeparator />
<div className="space-y-1.5">
<DropdownMenuLabel className="px-0">Board</DropdownMenuLabel>
<DropdownMenuLabel className="px-0 text-foreground">Workflow</DropdownMenuLabel>
<Select
value={activeBoardId ?? ''}
onValueChange={(value) => onBoardChange(value || null)}
value={activeWorkflowId ?? 'all'}
onValueChange={(value) => onWorkflowChange(value === 'all' ? null : value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select board" />
<SelectTrigger className="w-full border-border">
<SelectValue placeholder="Select workflow" />
</SelectTrigger>
<SelectContent>
{boards.map((board: BoardState['items'][number]) => (
<SelectItem key={board.id} value={board.id}>
{board.name}
<SelectItem value="all">All Workflows</SelectItem>
{workflows.map((workflow: WorkflowsState['items'][number]) => (
<SelectItem key={workflow.id} value={workflow.id}>
{workflow.name}
</SelectItem>
))}
</SelectContent>
@@ -90,13 +91,13 @@ export function KanbanDisplayDropdown() {
</div>
<DropdownMenuSeparator />
<div className="space-y-1.5">
<DropdownMenuLabel className="px-0">Repository</DropdownMenuLabel>
<DropdownMenuLabel className="px-0 text-foreground">Repository</DropdownMenuLabel>
<Select
value={repositoryValue}
onValueChange={(value) => onRepositoryChange(value as string | 'all')}
disabled={repositories.length === 0}
>
<SelectTrigger className="w-full">
<SelectTrigger className="w-full border-border">
<SelectValue
placeholder={
repositoriesLoading
@@ -119,7 +120,7 @@ export function KanbanDisplayDropdown() {
</div>
<DropdownMenuSeparator />
<div className="space-y-1.5">
<DropdownMenuLabel className="px-0">Preview Panel</DropdownMenuLabel>
<DropdownMenuLabel className="px-0 text-foreground">Preview Panel</DropdownMenuLabel>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={enablePreviewOnClick ?? false}
@@ -127,7 +128,7 @@ export function KanbanDisplayDropdown() {
onTogglePreviewOnClick?.(!!checked);
}}
/>
<span className="text-sm">
<span className="text-sm text-foreground">
Open preview on click {" "}
<Badge variant="secondary" className="mr-1">beta</Badge>
</span>
+1 -1
View File
@@ -216,7 +216,7 @@ export function KanbanWithPreview({ initialTaskId }: KanbanWithPreviewProps) {
) : (
// Inline mode: side by side with custom resize
<div className="flex-1 flex overflow-hidden">
{/* Kanban Board */}
{/* Kanban */}
<div
className="overflow-hidden"
style={{
@@ -0,0 +1,28 @@
import { cn } from '@kandev/ui/lib/utils';
export type Graph2ConnectorProps = {
type: 'past' | 'transition' | 'future';
};
export function Graph2Connector({ type }: Graph2ConnectorProps) {
return (
<div className="flex items-center shrink-0">
<div
className={cn(
'w-5 h-px',
type === 'past' && 'bg-muted-foreground/40',
type === 'transition' && 'bg-muted-foreground/30 border-t border-dashed border-muted-foreground/30',
type === 'future' && 'border-t border-dashed border-muted-foreground/20'
)}
/>
<div
className={cn(
'w-0 h-0 border-t-[3px] border-t-transparent border-b-[3px] border-b-transparent border-l-[5px]',
type === 'past' && 'border-l-muted-foreground/40',
type === 'transition' && 'border-l-muted-foreground/30',
type === 'future' && 'border-l-muted-foreground/20'
)}
/>
</div>
);
}
@@ -0,0 +1,211 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
IconCheck,
IconCircleDashed,
IconChevronLeft,
IconChevronRight,
} from '@tabler/icons-react';
import { cn } from '@kandev/ui/lib/utils';
import { getTaskStateIcon } from '@/lib/ui/state-icons';
import { linkToSession } from '@/lib/links';
import type { Task } from '@/components/kanban-card';
import type { WorkflowStep } from '@/components/kanban-column';
type StepPhase = 'past' | 'current' | 'future';
type StatusInfo = {
label: string;
color: string;
};
function getStatusInfo(task: Task): StatusInfo {
if (task.reviewStatus === 'pending' && task.state !== 'IN_PROGRESS') {
return { label: 'Needs Approval', color: 'text-amber-500' };
}
if (task.reviewStatus === 'changes_requested') {
return { label: 'Changes Requested', color: 'text-amber-500' };
}
switch (task.state) {
case 'IN_PROGRESS':
case 'SCHEDULING':
return { label: 'Running', color: 'text-blue-500' };
case 'WAITING_FOR_INPUT':
return { label: 'Waiting', color: 'text-amber-500' };
case 'COMPLETED':
return { label: 'Completed', color: 'text-green-500' };
case 'FAILED':
return { label: 'Failed', color: 'text-red-500' };
case 'CANCELLED':
return { label: 'Cancelled', color: 'text-red-500' };
case 'TODO':
case 'CREATED':
return { label: 'Todo', color: 'text-muted-foreground' };
case 'REVIEW':
return { label: 'Review', color: 'text-yellow-500' };
case 'BLOCKED':
return { label: 'Blocked', color: 'text-yellow-500' };
default:
return { label: 'Pending', color: 'text-muted-foreground' };
}
}
function isRunningState(state?: string): boolean {
return state === 'IN_PROGRESS' || state === 'SCHEDULING';
}
export type Graph2StepNodeProps = {
step: WorkflowStep;
phase: StepPhase;
task: Task;
hasPrev: boolean;
hasNext: boolean;
onMoveTask: (task: Task, targetStepId: string) => void;
onPreviewTask: (task: Task) => void;
prevStepId?: string;
nextStepId?: string;
isMoving?: boolean;
};
const NODE_CLASS = 'w-[130px] h-[44px] rounded-lg shrink-0 px-2.5 flex flex-col items-start justify-center';
export function Graph2StepNode({
step,
phase,
task,
hasPrev,
hasNext,
onMoveTask,
onPreviewTask,
prevStepId,
nextStepId,
isMoving,
}: Graph2StepNodeProps) {
const router = useRouter();
const [isHovered, setIsHovered] = useState(false);
const handleClick = () => {
if (phase !== 'current') return;
if (task.primarySessionId) {
router.push(linkToSession(task.primarySessionId));
} else {
onPreviewTask(task);
}
};
if (phase === 'past') {
return (
<div className={cn(NODE_CLASS, 'border border-muted-foreground/20 bg-muted/30')}>
<div className="flex items-center gap-1.5 w-full">
<IconCheck className="h-3 w-3 text-green-500 shrink-0" />
<span className="text-[11px] text-muted-foreground truncate">
{step.title}
</span>
</div>
</div>
);
}
if (phase === 'future') {
return (
<div className={cn(NODE_CLASS, 'border border-dashed border-muted-foreground/20 bg-muted/10')}>
<div className="flex items-center gap-1.5 w-full">
<IconCircleDashed className="h-3 w-3 text-muted-foreground/40 shrink-0" />
<span className="text-[11px] text-muted-foreground/40 truncate">
{step.title}
</span>
</div>
</div>
);
}
// Current phase
const status = getStatusInfo(task);
const running = isRunningState(task.state);
return (
<div
className="relative shrink-0"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Left move button */}
{isHovered && hasPrev && prevStepId && (
<button
type="button"
disabled={isMoving}
onClick={(e) => {
e.stopPropagation();
onMoveTask(task, prevStepId);
}}
className={cn(
'absolute -left-3 top-1/2 -translate-y-1/2 z-10',
'h-5 w-5 rounded-full bg-background border border-border shadow-sm',
'flex items-center justify-center',
'hover:bg-accent transition-colors cursor-pointer',
isMoving && 'opacity-50 cursor-not-allowed'
)}
>
<IconChevronLeft className="h-3 w-3" />
</button>
)}
{/* Current node */}
<button
type="button"
onClick={handleClick}
className={cn(
NODE_CLASS,
'bg-background cursor-pointer',
'hover:bg-accent/30 transition-colors',
running
? 'border border-muted-foreground/20 node-border-running'
: 'border-2 ring-1 ring-offset-1 ring-offset-background'
)}
style={
running
? undefined
: {
borderColor: step.color || 'hsl(var(--primary))',
// @ts-expect-error -- CSS custom property for ring color
'--tw-ring-color': step.color || 'hsl(var(--primary))',
}
}
>
<div className="flex items-center gap-1.5 w-full">
<div className="shrink-0">{getTaskStateIcon(task.state, 'h-3 w-3')}</div>
<span className={cn('text-[10px] font-medium', status.color)}>
{status.label}
</span>
</div>
<span className="text-[11px] text-foreground/80 truncate w-full text-left">
{step.title}
</span>
</button>
{/* Right move button */}
{isHovered && hasNext && nextStepId && (
<button
type="button"
disabled={isMoving}
onClick={(e) => {
e.stopPropagation();
onMoveTask(task, nextStepId);
}}
className={cn(
'absolute -right-3 top-1/2 -translate-y-1/2 z-10',
'h-5 w-5 rounded-full bg-background border border-border shadow-sm',
'flex items-center justify-center',
'hover:bg-accent transition-colors cursor-pointer',
isMoving && 'opacity-50 cursor-not-allowed'
)}
>
<IconChevronRight className="h-3 w-3" />
</button>
)}
</div>
);
}
@@ -0,0 +1,168 @@
'use client';
import { useMemo } from 'react';
import { IconDots, IconTrash } from '@tabler/icons-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@kandev/ui/dropdown-menu';
import { cn } from '@kandev/ui/lib/utils';
import { needsAction } from '@/lib/utils/needs-action';
import { Graph2StepNode } from './graph2-step-node';
import { Graph2Connector } from './graph2-connector';
import type { Task } from '@/components/kanban-card';
import type { WorkflowStep } from '@/components/kanban-column';
type ConnectorType = 'past' | 'transition' | 'future';
function formatRelativeTime(dateStr: string): string {
const now = Date.now();
const then = new Date(dateStr).getTime();
const diff = now - then;
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
return `${months}mo ago`;
}
export type Graph2TaskPipelineProps = {
task: Task;
steps: WorkflowStep[];
onMoveTask: (task: Task, targetStepId: string) => void;
onPreviewTask: (task: Task) => void;
onEditTask: (task: Task) => void;
onDeleteTask: (task: Task) => void;
isMoving?: boolean;
isDeleting?: boolean;
};
export function Graph2TaskPipeline({
task,
steps,
onMoveTask,
onPreviewTask,
onEditTask,
onDeleteTask,
isMoving,
isDeleting,
}: Graph2TaskPipelineProps) {
const currentStepIndex = useMemo(
() => steps.findIndex((s) => s.id === task.workflowStepId),
[steps, task.workflowStepId]
);
const hasAction = needsAction(task);
const sessionCount = task.sessionCount ?? 0;
return (
<div className="flex items-center justify-center rounded-lg hover:bg-muted/30 transition-colors px-3 py-2">
<div className="flex items-center gap-3">
{/* Task name card */}
<button
type="button"
onClick={() => onEditTask(task)}
className={cn(
'w-[160px] shrink-0 rounded-md px-2.5 py-1.5 text-left transition-colors cursor-pointer',
'hover:bg-accent/60 active:bg-accent/80',
'border border-transparent hover:border-border/50',
hasAction && 'border-l-2 !border-l-amber-500'
)}
>
<span className="text-xs font-medium truncate block text-foreground/80">
{task.title}
</span>
<div className="flex items-center gap-1.5 mt-0.5">
{task.updatedAt && (
<span className="text-[10px] text-muted-foreground/60">
{formatRelativeTime(task.updatedAt)}
</span>
)}
{sessionCount > 0 && (
<span className="text-[10px] text-muted-foreground/60">
{sessionCount} {sessionCount === 1 ? 'session' : 'sessions'}
</span>
)}
</div>
</button>
{/* Step nodes with connectors */}
<div className="flex items-center gap-0">
{steps.map((step, index) => {
const phase =
index < currentStepIndex
? 'past'
: index === currentStepIndex
? 'current'
: 'future';
let connectorType: ConnectorType | null = null;
if (index < steps.length - 1) {
const nextPhase =
index + 1 < currentStepIndex
? 'past'
: index + 1 === currentStepIndex
? 'current'
: 'future';
if (phase === 'past' && nextPhase === 'past') {
connectorType = 'past';
} else if (phase === 'future' && nextPhase === 'future') {
connectorType = 'future';
} else {
connectorType = 'transition';
}
}
return (
<div key={step.id} className="flex items-center">
<Graph2StepNode
step={step}
phase={phase}
task={task}
hasPrev={index > 0}
hasNext={index < steps.length - 1}
prevStepId={index > 0 ? steps[index - 1].id : undefined}
nextStepId={index < steps.length - 1 ? steps[index + 1].id : undefined}
onMoveTask={onMoveTask}
onPreviewTask={onPreviewTask}
isMoving={isMoving}
/>
{connectorType && <Graph2Connector type={connectorType} />}
</div>
);
})}
</div>
{/* Actions dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground/40 hover:text-foreground hover:bg-accent/60 transition-colors cursor-pointer"
>
<IconDots className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[160px]">
<DropdownMenuItem
onClick={() => onDeleteTask(task)}
disabled={isDeleting}
className="text-destructive focus:text-destructive cursor-pointer"
>
<IconTrash className="h-3.5 w-3.5 mr-2" />
Delete task
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
+42 -7
View File
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
import { Button } from '@kandev/ui/button';
import { ToggleGroup, ToggleGroupItem } from '@kandev/ui/toggle-group';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@kandev/ui/tooltip';
import { IconPlus, IconSettings, IconList, IconLayoutKanban, IconMenu2, IconChartBar } from '@tabler/icons-react';
import { IconPlus, IconSettings, IconList, IconLayoutKanban, IconMenu2, IconChartBar, IconTimeline } from '@tabler/icons-react';
import { KanbanDisplayDropdown } from '../kanban-display-dropdown';
import { TaskSearchInput } from './task-search-input';
import { KanbanHeaderMobile } from './kanban-header-mobile';
@@ -13,6 +13,7 @@ import { MobileMenuSheet } from './mobile-menu-sheet';
import { linkToTasks } from '@/lib/links';
import { useResponsiveBreakpoint } from '@/hooks/use-responsive-breakpoint';
import { useAppStore } from '@/components/state-provider';
import { useKanbanDisplaySettings } from '@/hooks/use-kanban-display-settings';
type KanbanHeaderProps = {
onCreateTask: () => void;
@@ -29,11 +30,19 @@ export function KanbanHeader({ onCreateTask, workspaceId, currentPage = 'kanban'
const isMenuOpen = useAppStore((state) => state.mobileKanban.isMenuOpen);
const setMenuOpen = useAppStore((state) => state.setMobileKanbanMenuOpen);
const { kanbanViewMode, onViewModeChange } = useKanbanDisplaySettings();
const toggleValue = currentPage === 'tasks' ? 'list' : (kanbanViewMode === 'graph2' ? 'pipeline' : 'kanban');
const handleViewChange = (value: string) => {
if (value === 'list' && currentPage !== 'tasks') {
router.push(linkToTasks(workspaceId));
} else if (value === 'kanban' && currentPage !== 'kanban') {
router.push('/');
if (value === 'list') {
if (currentPage !== 'tasks') router.push(linkToTasks(workspaceId));
} else if (value === 'kanban') {
if (currentPage !== 'kanban') router.push('/');
onViewModeChange('');
} else if (value === 'pipeline') {
if (currentPage !== 'kanban') router.push('/');
onViewModeChange('graph2');
}
};
@@ -75,7 +84,7 @@ export function KanbanHeader({ onCreateTask, workspaceId, currentPage = 'kanban'
<TooltipProvider>
<ToggleGroup
type="single"
value={currentPage === 'tasks' ? 'list' : 'kanban'}
value={toggleValue}
onValueChange={handleViewChange}
variant="outline"
className="h-8"
@@ -93,6 +102,19 @@ export function KanbanHeader({ onCreateTask, workspaceId, currentPage = 'kanban'
<TooltipContent>Kanban</TooltipContent>
</Tooltip>
</ToggleGroupItem>
<ToggleGroupItem
value="pipeline"
className="cursor-pointer h-8 w-8 data-[state=on]:bg-muted data-[state=on]:text-foreground"
>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center justify-center">
<IconTimeline className="h-4 w-4" />
</span>
</TooltipTrigger>
<TooltipContent>Pipeline</TooltipContent>
</Tooltip>
</ToggleGroupItem>
<ToggleGroupItem
value="list"
className="cursor-pointer h-8 w-8 data-[state=on]:bg-muted data-[state=on]:text-foreground"
@@ -158,7 +180,7 @@ export function KanbanHeader({ onCreateTask, workspaceId, currentPage = 'kanban'
<TooltipProvider>
<ToggleGroup
type="single"
value={currentPage === 'tasks' ? 'list' : 'kanban'}
value={toggleValue}
onValueChange={handleViewChange}
variant="outline"
>
@@ -175,6 +197,19 @@ export function KanbanHeader({ onCreateTask, workspaceId, currentPage = 'kanban'
<TooltipContent>Kanban</TooltipContent>
</Tooltip>
</ToggleGroupItem>
<ToggleGroupItem
value="pipeline"
className="cursor-pointer data-[state=on]:bg-muted data-[state=on]:text-foreground"
>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center justify-center">
<IconTimeline className="h-4 w-4" />
</span>
</TooltipTrigger>
<TooltipContent>Pipeline</TooltipContent>
</Tooltip>
</ToggleGroupItem>
<ToggleGroupItem
value="list"
className="cursor-pointer data-[state=on]:bg-muted data-[state=on]:text-foreground"
@@ -6,14 +6,14 @@ import { Badge } from '@kandev/ui/badge';
import type { WorkflowStep } from '../kanban-column';
type MobileColumnTabsProps = {
columns: WorkflowStep[];
steps: WorkflowStep[];
activeIndex: number;
taskCounts: Record<string, number>;
onColumnChange: (index: number) => void;
};
export function MobileColumnTabs({
columns,
steps,
activeIndex,
taskCounts,
onColumnChange,
@@ -45,9 +45,9 @@ export function MobileColumnTabs({
ref={tabsRef}
className="flex overflow-x-auto scrollbar-hide border-b border-border px-4 gap-1"
>
{columns.map((column, index) => (
{steps.map((step, index) => (
<button
key={column.id}
key={step.id}
ref={index === activeIndex ? activeTabRef : null}
onClick={() => onColumnChange(index)}
className={cn(
@@ -58,10 +58,10 @@ export function MobileColumnTabs({
: 'border-transparent text-muted-foreground hover:text-foreground'
)}
>
<div className={cn('w-2 h-2 rounded-full flex-shrink-0', column.color)} />
<span className="truncate max-w-[100px]">{column.title}</span>
<div className={cn('w-2 h-2 rounded-full flex-shrink-0', step.color)} />
<span className="truncate max-w-[100px]">{step.title}</span>
<Badge variant="secondary" className="text-xs h-5 px-1.5">
{taskCounts[column.id] ?? 0}
{taskCounts[step.id] ?? 0}
</Badge>
</button>
))}
@@ -5,13 +5,13 @@ import { cn } from '@/lib/utils';
import type { WorkflowStep } from '../kanban-column';
type MobileDropTargetProps = {
column: WorkflowStep;
isCurrentColumn: boolean;
step: WorkflowStep;
isCurrentStep: boolean;
};
function MobileDropTarget({ column, isCurrentColumn }: MobileDropTargetProps) {
function MobileDropTarget({ step, isCurrentStep }: MobileDropTargetProps) {
const { setNodeRef, isOver } = useDroppable({
id: column.id,
id: step.id,
});
return (
@@ -21,26 +21,26 @@ function MobileDropTarget({ column, isCurrentColumn }: MobileDropTargetProps) {
'flex items-center justify-center gap-2 px-3 py-3 rounded-lg border-2 border-dashed transition-all min-w-[100px]',
isOver
? 'border-primary bg-primary/10 scale-105'
: isCurrentColumn
: isCurrentStep
? 'border-muted-foreground/30 bg-muted/50 opacity-50'
: 'border-muted-foreground/40 bg-background hover:border-muted-foreground/60'
)}
>
<div className={cn('w-3 h-3 rounded-full flex-shrink-0', column.color)} />
<span className="text-sm font-medium truncate max-w-[80px]">{column.title}</span>
<div className={cn('w-3 h-3 rounded-full flex-shrink-0', step.color)} />
<span className="text-sm font-medium truncate max-w-[80px]">{step.title}</span>
</div>
);
}
type MobileDropTargetsProps = {
columns: WorkflowStep[];
currentColumnId: string | null;
steps: WorkflowStep[];
currentStepId: string | null;
isDragging: boolean;
};
export function MobileDropTargets({
columns,
currentColumnId,
steps,
currentStepId,
isDragging,
}: MobileDropTargetsProps) {
if (!isDragging) return null;
@@ -48,11 +48,11 @@ export function MobileDropTargets({
return (
<div className="fixed bottom-0 left-0 right-0 z-50 p-4 bg-gradient-to-t from-background via-background to-transparent">
<div className="flex gap-2 overflow-x-auto pb-safe scrollbar-hide">
{columns.map((column) => (
{steps.map((step) => (
<MobileDropTarget
key={column.id}
column={column}
isCurrentColumn={column.id === currentColumnId}
key={step.id}
step={step}
isCurrentStep={step.id === currentStepId}
/>
))}
</div>
@@ -24,7 +24,7 @@ import { TaskSearchInput } from './task-search-input';
import { useKanbanDisplaySettings } from '@/hooks/use-kanban-display-settings';
import { linkToTasks } from '@/lib/links';
import type { Workspace, Repository } from '@/lib/types/http';
import type { BoardState } from '@/lib/state/slices';
import type { WorkflowsState } from '@/lib/state/slices';
type MobileMenuSheetProps = {
open: boolean;
@@ -48,16 +48,16 @@ export function MobileMenuSheet({
const router = useRouter();
const {
workspaces,
boards,
workflows,
activeWorkspaceId,
activeBoardId,
activeWorkflowId,
repositories,
repositoriesLoading,
allRepositoriesSelected,
selectedRepositoryId,
enablePreviewOnClick,
onWorkspaceChange,
onBoardChange,
onWorkflowChange,
onRepositoryChange,
onTogglePreviewOnClick,
} = useKanbanDisplaySettings();
@@ -143,18 +143,18 @@ export function MobileMenuSheet({
</div>
<div className="space-y-2">
<label className="text-xs text-muted-foreground">Board</label>
<label className="text-xs text-muted-foreground">Workflow</label>
<Select
value={activeBoardId ?? ''}
onValueChange={(value) => onBoardChange(value || null)}
value={activeWorkflowId ?? ''}
onValueChange={(value) => onWorkflowChange(value || null)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select board" />
<SelectValue placeholder="Select workflow" />
</SelectTrigger>
<SelectContent>
{boards.map((board: BoardState['items'][number]) => (
<SelectItem key={board.id} value={board.id}>
{board.name}
{workflows.map((workflow: WorkflowsState['items'][number]) => (
<SelectItem key={workflow.id} value={workflow.id}>
{workflow.name}
</SelectItem>
))}
</SelectContent>
@@ -0,0 +1,166 @@
'use client';
import { useMemo } from 'react';
import { useAppStore } from '@/components/state-provider';
import { useSwimlaneCollapse } from '@/hooks/domains/kanban/use-swimlane-collapse';
import { filterTasksByRepositories, mapSelectedRepositoryIds } from '@/lib/kanban/filters';
import { SwimlaneSection } from './swimlane-section';
import { getViewByStoredValue, getDefaultView } from '@/lib/kanban/view-registry';
import type { Task } from '@/components/kanban-card';
import type { WorkflowAutomation, MoveTaskError } from '@/hooks/use-drag-and-drop';
import type { Repository } from '@/lib/types/http';
export type SwimlaneContainerProps = {
viewMode: string;
workflowFilter: string | null;
onPreviewTask: (task: Task) => void;
onOpenTask: (task: Task) => void;
onEditTask: (task: Task) => void;
onDeleteTask: (task: Task) => void;
onMoveError?: (error: MoveTaskError) => void;
onWorkflowAutomation?: (automation: WorkflowAutomation) => void;
deletingTaskId?: string | null;
searchQuery?: string;
selectedRepositoryIds?: string[];
};
export function SwimlaneContainer({
viewMode,
workflowFilter,
onPreviewTask,
onOpenTask,
onEditTask,
onDeleteTask,
onMoveError,
onWorkflowAutomation,
deletingTaskId,
searchQuery,
selectedRepositoryIds = [],
}: SwimlaneContainerProps) {
const snapshots = useAppStore((state) => state.kanbanMulti.snapshots);
const isLoading = useAppStore((state) => state.kanbanMulti.isLoading);
const workflows = useAppStore((state) => state.workflows.items);
const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
const { isCollapsed, toggleCollapse } = useSwimlaneCollapse();
const repositories = useMemo(
() => Object.values(repositoriesByWorkspace).flat() as Repository[],
[repositoriesByWorkspace]
);
const repoFilter = useMemo(
() => mapSelectedRepositoryIds(repositories, selectedRepositoryIds),
[repositories, selectedRepositoryIds]
);
// Build ordered list of workflows to render
const orderedWorkflows = useMemo(() => {
if (workflowFilter) {
// Single workflow mode
const snapshot = snapshots[workflowFilter];
if (!snapshot) return [];
return [{ id: workflowFilter, name: snapshot.workflowName }];
}
// All workflows - use workflow order, only show those with snapshots
return workflows.filter((wf) => snapshots[wf.id]);
}, [workflowFilter, workflows, snapshots]);
// Filter tasks per workflow
const getFilteredTasks = (workflowId: string): Task[] => {
const snapshot = snapshots[workflowId];
if (!snapshot) return [];
let tasks = snapshot.tasks as Task[];
// Apply repository filter
tasks = filterTasksByRepositories(tasks, repoFilter);
// Apply search filter
if (searchQuery) {
const q = searchQuery.toLowerCase();
tasks = tasks.filter(
(t) =>
t.title.toLowerCase().includes(q) ||
(t.description && t.description.toLowerCase().includes(q))
);
}
return tasks;
};
if (isLoading && Object.keys(snapshots).length === 0) {
return (
<div className="flex-1 min-h-0 px-4 pb-4">
<div className="h-full rounded-lg border border-dashed border-border/60 flex items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
</div>
);
}
if (orderedWorkflows.length === 0) {
return (
<div className="flex-1 min-h-0 px-4 pb-4">
<div className="h-full rounded-lg border border-dashed border-border/60 flex items-center justify-center text-sm text-muted-foreground">
No workflows available yet.
</div>
</div>
);
}
// Filter to only workflows with tasks (unless a specific workflow is selected)
const visibleWorkflows = workflowFilter
? orderedWorkflows
: orderedWorkflows.filter((wf) => getFilteredTasks(wf.id).length > 0);
if (visibleWorkflows.length === 0) {
return (
<div className="flex-1 min-h-0 px-4 pb-4">
<div className="h-full rounded-lg border border-dashed border-border/60 flex items-center justify-center text-sm text-muted-foreground">
No tasks yet
</div>
</div>
);
}
return (
<div className="flex-1 min-h-0 overflow-y-auto px-4 pb-4 space-y-3">
{visibleWorkflows.map((wf) => {
const snapshot = snapshots[wf.id];
if (!snapshot) return null;
const tasks = getFilteredTasks(wf.id);
const steps = [...snapshot.steps].sort((a, b) => a.position - b.position);
return (
<SwimlaneSection
key={wf.id}
workflowId={wf.id}
workflowName={wf.name}
taskCount={tasks.length}
isCollapsed={isCollapsed(wf.id)}
onToggleCollapse={() => toggleCollapse(wf.id)}
>
{(() => {
const viewEntry = getViewByStoredValue(viewMode) ?? getDefaultView();
const ViewComponent = viewEntry.component;
return (
<ViewComponent
workflowId={wf.id}
steps={steps}
tasks={tasks}
onPreviewTask={onPreviewTask}
onOpenTask={onOpenTask}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
onMoveError={onMoveError}
onWorkflowAutomation={onWorkflowAutomation}
deletingTaskId={deletingTaskId}
/>
);
})()}
</SwimlaneSection>
);
})}
</div>
);
}
@@ -0,0 +1,344 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
useDroppable,
useDraggable,
type Modifier,
} from '@dnd-kit/core';
import { cn } from '@kandev/ui/lib/utils';
import { Badge } from '@kandev/ui/badge';
import { getTaskStateIcon } from '@/lib/ui/state-icons';
import { needsAction } from '@/lib/utils/needs-action';
import { useTaskActions } from '@/hooks/use-task-actions';
import { useAppStoreApi } from '@/components/state-provider';
import { getWebSocketClient } from '@/lib/ws/connection';
import type { Task } from '@/components/kanban-card';
import type { WorkflowStep } from '@/components/kanban-column';
import type { WorkflowAutomation, MoveTaskError } from '@/hooks/use-drag-and-drop';
import type { KanbanState } from '@/lib/state/slices/kanban/types';
export type SwimlaneGraphContentProps = {
workflowId: string;
steps: WorkflowStep[];
tasks: Task[];
onPreviewTask: (task: Task) => void;
onOpenTask?: (task: Task) => void;
onEditTask?: (task: Task) => void;
onDeleteTask?: (task: Task) => void;
onMoveError?: (error: MoveTaskError) => void;
onWorkflowAutomation?: (automation: WorkflowAutomation) => void;
deletingTaskId?: string | null;
};
function DroppableStepZone({
stepId,
isDragging,
children,
}: {
stepId: string;
isDragging: boolean;
children: React.ReactNode;
}) {
const { setNodeRef, isOver } = useDroppable({ id: stepId });
return (
<div
ref={setNodeRef}
className={cn(
'flex flex-col items-stretch rounded-lg transition-colors flex-1',
isDragging && 'border border-dashed border-transparent',
isDragging && !isOver && 'border-border/40 bg-muted/10',
isOver && 'border-primary/50 bg-primary/10'
)}
>
{children}
</div>
);
}
function DraggableTaskChip({
task,
onPreviewTask,
}: {
task: Task;
onPreviewTask: (task: Task) => void;
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: task.id,
});
const statusIcon = getTaskStateIcon(task.state, 'h-3 w-3');
return (
<button
ref={setNodeRef}
type="button"
onClick={() => onPreviewTask(task)}
{...listeners}
{...attributes}
className={cn(
'w-full flex items-center gap-1.5 px-2 py-1 rounded-md text-left',
'hover:bg-accent/60 transition-colors cursor-grab',
'border border-border/50',
needsAction(task) && 'border-l-2 border-l-amber-500',
isDragging && 'opacity-30'
)}
style={
transform
? { transform: `translate(${transform.x}px, ${transform.y}px)` }
: undefined
}
>
<div className="shrink-0">{statusIcon}</div>
<span className="text-xs truncate">{task.title}</span>
</button>
);
}
function TaskChipPreview({ task }: { task: Task }) {
const statusIcon = getTaskStateIcon(task.state, 'h-3 w-3');
return (
<div
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md',
'bg-background border border-border shadow-lg cursor-grabbing',
'pointer-events-none min-w-[120px] max-w-[220px]',
needsAction(task) && 'border-l-2 border-l-amber-500'
)}
>
<div className="shrink-0">{statusIcon}</div>
<span className="text-xs truncate">{task.title}</span>
</div>
);
}
export function SwimlaneGraphContent({
workflowId,
steps,
tasks,
onPreviewTask,
onMoveError,
onWorkflowAutomation,
}: SwimlaneGraphContentProps) {
const store = useAppStoreApi();
const { moveTaskById } = useTaskActions();
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
);
const clampVertical: Modifier[] = useMemo(
() => [({ transform }) => ({ ...transform, y: Math.max(-20, Math.min(20, transform.y)) })],
[]
);
const tasksByStep = useMemo(() => {
const map: Record<string, Task[]> = {};
for (const col of steps) {
map[col.id] = tasks
.filter((t) => t.workflowStepId === col.id)
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
}
return map;
}, [steps, tasks]);
// Build set of step IDs that are valid drop targets for each step (adjacent only)
const adjacentSteps = useMemo(() => {
const map: Record<string, Set<string>> = {};
for (let i = 0; i < steps.length; i++) {
const adj = new Set<string>();
if (i > 0) adj.add(steps[i - 1].id);
if (i < steps.length - 1) adj.add(steps[i + 1].id);
map[steps[i].id] = adj;
}
return map;
}, [steps]);
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveTaskId(event.active.id as string);
}, []);
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event;
setActiveTaskId(null);
if (!over) return;
const taskId = active.id as string;
const targetColumnId = over.id as string;
const task = tasks.find((t) => t.id === taskId);
if (!task || task.workflowStepId === targetColumnId) return;
// Only allow moves to adjacent steps
const allowed = adjacentSteps[task.workflowStepId];
if (!allowed || !allowed.has(targetColumnId)) return;
// Optimistic update
const state = store.getState();
const snapshot = state.kanbanMulti.snapshots[workflowId];
if (!snapshot) return;
const targetTasks = snapshot.tasks
.filter((t: KanbanState['tasks'][number]) => t.workflowStepId === targetColumnId && t.id !== taskId)
.sort((a: KanbanState['tasks'][number], b: KanbanState['tasks'][number]) => a.position - b.position);
const nextPosition = targetTasks.length;
const originalTasks = snapshot.tasks;
state.setWorkflowSnapshot(workflowId, {
...snapshot,
tasks: snapshot.tasks.map((t: KanbanState['tasks'][number]) =>
t.id === taskId
? { ...t, workflowStepId: targetColumnId, position: nextPosition }
: t
),
});
try {
const response = await moveTaskById(taskId, {
workflow_id: workflowId,
workflow_step_id: targetColumnId,
position: nextPosition,
});
if (response?.workflow_step?.events?.on_enter?.some((a: { type: string }) => a.type === 'auto_start_agent')) {
const sessionId = task.primarySessionId ?? null;
if (sessionId) {
const client = getWebSocketClient();
if (client) {
try {
await client.request(
'orchestrator.start',
{
task_id: task.id,
session_id: sessionId,
workflow_step_id: response.workflow_step.id,
},
15000
);
} catch (err) {
console.error('Failed to auto-start session for workflow step:', err);
}
}
} else {
onWorkflowAutomation?.({
taskId: task.id,
sessionId: null,
workflowStep: response.workflow_step,
taskDescription: task.description ?? '',
});
}
}
} catch (error) {
const currentSnapshot = store.getState().kanbanMulti.snapshots[workflowId];
if (currentSnapshot) {
store.getState().setWorkflowSnapshot(workflowId, {
...currentSnapshot,
tasks: originalTasks,
});
}
const message = error instanceof Error ? error.message : 'Failed to move task';
onMoveError?.({ message, taskId, sessionId: task.primarySessionId ?? null });
}
},
[tasks, workflowId, store, moveTaskById, adjacentSteps, onWorkflowAutomation, onMoveError]
);
const handleDragCancel = useCallback(() => {
setActiveTaskId(null);
}, []);
const activeTask = useMemo(
() => tasks.find((t) => t.id === activeTaskId) ?? null,
[tasks, activeTaskId]
);
if (tasks.length === 0) {
return (
<div className="px-3 pb-3">
<div className="text-xs text-muted-foreground text-center py-4">No tasks</div>
</div>
);
}
return (
<DndContext
sensors={sensors}
modifiers={clampVertical}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="px-3 pb-3 overflow-x-auto">
<div className="flex items-stretch justify-center gap-2 min-w-min">
{steps.map((step, index) => {
const stepTasks = tasksByStep[step.id] ?? [];
const hasActiveTasks = stepTasks.length > 0;
return (
<div key={step.id} className="flex items-stretch gap-2">
{/* Step node + tasks column */}
<DroppableStepZone stepId={step.id} isDragging={activeTaskId !== null}>
<div className="w-[240px]">
{/* Node header */}
<div
className={cn(
'rounded-lg border-2 px-3 py-2',
hasActiveTasks
? 'border-primary/60 bg-primary/5'
: 'border-border bg-muted/30'
)}
>
<div className="flex items-center justify-between gap-1.5">
<span className="text-xs font-semibold truncate">{step.title}</span>
<Badge
variant={hasActiveTasks ? 'default' : 'secondary'}
className="text-[10px] shrink-0"
>
{stepTasks.length}
</Badge>
</div>
</div>
{/* Task chips under the node */}
{stepTasks.length > 0 && (
<div className="mt-2 space-y-1">
{stepTasks.map((task) => (
<DraggableTaskChip
key={task.id}
task={task}
onPreviewTask={onPreviewTask}
/>
))}
</div>
)}
</div>
</DroppableStepZone>
{/* Connector arrow — vertically centered to the node header */}
{index < steps.length - 1 && (
<div className="flex items-center h-[40px] self-start">
<div className="w-6 h-px bg-border" />
<div className="w-0 h-0 border-t-[4px] border-t-transparent border-b-[4px] border-b-transparent border-l-[6px] border-l-border" />
</div>
)}
</div>
);
})}
</div>
</div>
<DragOverlay dropAnimation={null}>
{activeTask ? <TaskChipPreview task={activeTask} /> : null}
</DragOverlay>
</DndContext>
);
}
@@ -0,0 +1,72 @@
'use client';
import { useMemo, useState } from 'react';
import { useSwimlaneMove } from '@/hooks/domains/kanban/use-swimlane-move';
import { Graph2TaskPipeline } from './graph2-task-pipeline';
import type { ViewContentProps } from '@/lib/kanban/view-registry';
export function SwimlaneGraph2Content({
workflowId,
steps,
tasks,
onPreviewTask,
onEditTask,
onDeleteTask,
onMoveError,
onWorkflowAutomation,
deletingTaskId,
}: ViewContentProps) {
const { moveTask } = useSwimlaneMove(workflowId, {
onMoveError,
onWorkflowAutomation,
});
const [movingTaskId, setMovingTaskId] = useState<string | null>(null);
const sortedTasks = useMemo(
() =>
[...tasks].sort((a, b) => {
const aStepIdx = steps.findIndex((c) => c.id === a.workflowStepId);
const bStepIdx = steps.findIndex((c) => c.id === b.workflowStepId);
if (aStepIdx !== bStepIdx) return aStepIdx - bStepIdx;
return (a.position ?? 0) - (b.position ?? 0);
}),
[tasks, steps]
);
const handleMoveTask = async (task: typeof tasks[number], targetStepId: string) => {
setMovingTaskId(task.id);
try {
await moveTask(task, targetStepId);
} finally {
setMovingTaskId(null);
}
};
if (tasks.length === 0) {
return (
<div className="px-3 pb-3">
<div className="text-xs text-muted-foreground text-center py-4">No tasks</div>
</div>
);
}
return (
<div className="px-3 pb-3 overflow-x-auto">
<div className="space-y-1">
{sortedTasks.map((task) => (
<Graph2TaskPipeline
key={task.id}
task={task}
steps={steps}
onMoveTask={handleMoveTask}
onPreviewTask={onPreviewTask}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
isMoving={movingTaskId === task.id}
isDeleting={deletingTaskId === task.id}
/>
))}
</div>
</div>
);
}
@@ -0,0 +1,40 @@
'use client';
import { Badge } from '@kandev/ui/badge';
import { IconChevronRight } from '@tabler/icons-react';
import { cn } from '@kandev/ui/lib/utils';
export type SwimlaneHeaderProps = {
workflowName: string;
taskCount: number;
isCollapsed: boolean;
onToggleCollapse: () => void;
};
export function SwimlaneHeader({
workflowName,
taskCount,
isCollapsed,
onToggleCollapse,
}: SwimlaneHeaderProps) {
return (
<button
type="button"
onClick={onToggleCollapse}
className="flex items-center gap-2 py-1.5 w-full text-left cursor-pointer group"
>
<div className="flex-1 border-t border-dashed border-border/50" />
<Badge variant="secondary" className="text-xs shrink-0 gap-1.5 px-2.5 py-0.5">
<IconChevronRight
className={cn(
'h-3 w-3 text-muted-foreground transition-transform shrink-0',
!isCollapsed && 'rotate-90'
)}
/>
{workflowName}
<span className="text-muted-foreground/60">{taskCount}</span>
</Badge>
<div className="flex-1 border-t border-dashed border-border/50" />
</button>
);
}
@@ -0,0 +1,208 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { KanbanColumn } from '@/components/kanban-column';
import { KanbanCardPreview, type Task } from '@/components/kanban-card';
import type { WorkflowStep } from '@/components/kanban-column';
import type { WorkflowAutomation, MoveTaskError } from '@/hooks/use-drag-and-drop';
import { useTaskActions } from '@/hooks/use-task-actions';
import { useAppStoreApi } from '@/components/state-provider';
import { getWebSocketClient } from '@/lib/ws/connection';
import type { KanbanState } from '@/lib/state/slices/kanban/types';
export type SwimlaneKanbanContentProps = {
workflowId: string;
steps: WorkflowStep[];
tasks: Task[];
onPreviewTask: (task: Task) => void;
onOpenTask: (task: Task) => void;
onEditTask: (task: Task) => void;
onDeleteTask: (task: Task) => void;
onMoveError?: (error: MoveTaskError) => void;
onWorkflowAutomation?: (automation: WorkflowAutomation) => void;
deletingTaskId?: string | null;
};
export function SwimlaneKanbanContent({
workflowId,
steps,
tasks,
onPreviewTask,
onOpenTask,
onEditTask,
onDeleteTask,
onMoveError,
onWorkflowAutomation,
deletingTaskId,
}: SwimlaneKanbanContentProps) {
const store = useAppStoreApi();
const { moveTaskById } = useTaskActions();
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
);
const getTasksForStep = (stepId: string) => {
const colTasks = tasks.filter((t) => t.workflowStepId === stepId);
return colTasks.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
};
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveTaskId(event.active.id as string);
}, []);
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event;
setActiveTaskId(null);
if (!over) return;
const taskId = active.id as string;
const targetStepId = over.id as string;
const task = tasks.find((t) => t.id === taskId);
if (!task || task.workflowStepId === targetStepId) return;
// Optimistic update on the multi snapshot
const state = store.getState();
const snapshot = state.kanbanMulti.snapshots[workflowId];
if (!snapshot) return;
const targetTasks = snapshot.tasks
.filter((t: KanbanState['tasks'][number]) => t.workflowStepId === targetStepId && t.id !== taskId)
.sort((a: KanbanState['tasks'][number], b: KanbanState['tasks'][number]) => a.position - b.position);
const nextPosition = targetTasks.length;
// Save for rollback
const originalTasks = snapshot.tasks;
state.setWorkflowSnapshot(workflowId, {
...snapshot,
tasks: snapshot.tasks.map((t: KanbanState['tasks'][number]) =>
t.id === taskId
? { ...t, workflowStepId: targetStepId, position: nextPosition }
: t
),
});
try {
const response = await moveTaskById(taskId, {
workflow_id: workflowId,
workflow_step_id: targetStepId,
position: nextPosition,
});
// Handle workflow automation
if (response?.workflow_step?.events?.on_enter?.some((a: { type: string }) => a.type === 'auto_start_agent')) {
const sessionId = task.primarySessionId ?? null;
if (sessionId) {
const client = getWebSocketClient();
if (client) {
try {
await client.request(
'orchestrator.start',
{
task_id: task.id,
session_id: sessionId,
workflow_step_id: response.workflow_step.id,
},
15000
);
} catch (err) {
console.error('Failed to auto-start session for workflow step:', err);
}
}
} else {
onWorkflowAutomation?.({
taskId: task.id,
sessionId: null,
workflowStep: response.workflow_step,
taskDescription: task.description ?? '',
});
}
}
} catch (error) {
// Rollback
const currentSnapshot = store.getState().kanbanMulti.snapshots[workflowId];
if (currentSnapshot) {
store.getState().setWorkflowSnapshot(workflowId, {
...currentSnapshot,
tasks: originalTasks,
});
}
const message = error instanceof Error ? error.message : 'Failed to move task';
onMoveError?.({ message, taskId, sessionId: task.primarySessionId ?? null });
}
},
[tasks, workflowId, store, moveTaskById, onWorkflowAutomation, onMoveError]
);
const handleDragCancel = useCallback(() => {
setActiveTaskId(null);
}, []);
const moveTaskToStep = useCallback(
async (task: Task, targetStepId: string) => {
if (task.workflowStepId === targetStepId) return;
// Reuse drag end logic
const fakeEvent = {
active: { id: task.id },
over: { id: targetStepId },
} as DragEndEvent;
await handleDragEnd(fakeEvent);
},
[handleDragEnd]
);
const activeTask = useMemo(
() => tasks.find((t) => t.id === activeTaskId) ?? null,
[tasks, activeTaskId]
);
if (steps.length === 0) return null;
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="">
<div
className="grid gap-0"
style={{ gridTemplateColumns: `repeat(${steps.length}, minmax(0, 1fr))` }}
>
{steps.map((step) => (
<KanbanColumn
key={step.id}
step={step}
tasks={getTasksForStep(step.id)}
onPreviewTask={onPreviewTask}
onOpenTask={onOpenTask}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
onMoveTask={moveTaskToStep}
steps={steps}
deletingTaskId={deletingTaskId}
/>
))}
</div>
</div>
<DragOverlay dropAnimation={null}>
{activeTask ? <KanbanCardPreview task={activeTask} /> : null}
</DragOverlay>
</DndContext>
);
}
@@ -0,0 +1,33 @@
'use client';
import type { ReactNode } from 'react';
import { SwimlaneHeader } from './swimlane-header';
export type SwimlaneSectionProps = {
workflowId: string;
workflowName: string;
taskCount: number;
isCollapsed: boolean;
onToggleCollapse: () => void;
children: ReactNode;
};
export function SwimlaneSection({
workflowName,
taskCount,
isCollapsed,
onToggleCollapse,
children,
}: SwimlaneSectionProps) {
return (
<div>
<SwimlaneHeader
workflowName={workflowName}
taskCount={taskCount}
isCollapsed={isCollapsed}
onToggleCollapse={onToggleCollapse}
/>
{!isCollapsed && children}
</div>
);
}
@@ -6,7 +6,7 @@ import { KanbanColumn, WorkflowStep } from '../kanban-column';
import { Task } from '../kanban-card';
type SwipeableColumnsProps = {
columns: WorkflowStep[];
steps: WorkflowStep[];
tasks: Task[];
activeIndex: number;
onIndexChange: (index: number) => void;
@@ -14,13 +14,13 @@ type SwipeableColumnsProps = {
onOpenTask: (task: Task) => void;
onEditTask: (task: Task) => void;
onDeleteTask: (task: Task) => void;
onMoveTask?: (task: Task, targetColumnId: string) => void;
onMoveTask?: (task: Task, targetStepId: string) => void;
showMaximizeButton?: boolean;
deletingTaskId?: string | null;
};
export function SwipeableColumns({
columns,
steps,
tasks,
activeIndex,
onIndexChange,
@@ -38,10 +38,10 @@ export function SwipeableColumns({
watchDrag: true,
});
const getTasksForColumn = useCallback(
(columnId: string) => {
const getTasksForStep = useCallback(
(stepId: string) => {
return tasks
.filter((task) => task.workflowStepId === columnId)
.filter((task) => task.workflowStepId === stepId)
.map((task) => ({ ...task, position: task.position ?? 0 }))
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
},
@@ -81,20 +81,20 @@ export function SwipeableColumns({
style={{ height: 'calc(100dvh - 100px - env(safe-area-inset-bottom, 0px))' }}
>
<div className="flex h-full touch-pan-y">
{columns.map((column) => (
{steps.map((step) => (
<div
key={column.id}
key={step.id}
className="flex-shrink-0 w-full h-full min-w-0 px-4 py-2 flex flex-col"
>
<KanbanColumn
column={column}
tasks={getTasksForColumn(column.id)}
step={step}
tasks={getTasksForStep(step.id)}
onPreviewTask={onPreviewTask}
onOpenTask={onOpenTask}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
onMoveTask={onMoveTask}
columns={columns}
steps={steps}
showMaximizeButton={showMaximizeButton}
deletingTaskId={deletingTaskId}
hideHeader
+2 -2
View File
@@ -62,9 +62,9 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
<div className="h-2 w-2 rounded-full bg-muted" />
</div>
<div>
<p className="font-medium text-sm">Board Columns</p>
<p className="font-medium text-sm">Workflow Steps</p>
<p className="text-xs text-muted-foreground">
Customize your columns anytime to match your workflow.
Customize your workflow steps anytime to match your process.
</p>
</div>
</div>
-274
View File
@@ -1,274 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { IconTrash } from '@tabler/icons-react';
import { Card, CardContent } from '@kandev/ui/card';
import { Button } from '@kandev/ui/button';
import { Input } from '@kandev/ui/input';
import { Label } from '@kandev/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@kandev/ui/dialog';
import type { Board, WorkflowStep } from '@/lib/types/http';
import { useRequest } from '@/lib/http/use-request';
import { useToast } from '@/components/toast-provider';
import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator';
import { WorkflowStepEditor } from '@/components/settings/workflow-step-editor';
import {
listWorkflowStepsAction,
createWorkflowStepAction,
updateWorkflowStepAction,
deleteWorkflowStepAction,
reorderWorkflowStepsAction,
} from '@/app/actions/workspaces';
type BoardCardProps = {
board: Board;
isBoardDirty: boolean;
initialWorkflowSteps?: WorkflowStep[];
onUpdateBoard: (updates: { name?: string; description?: string }) => void;
onDeleteBoard: () => Promise<unknown>;
onSaveBoard: () => Promise<unknown>;
};
export function BoardCard({
board,
isBoardDirty,
initialWorkflowSteps,
onUpdateBoard,
onDeleteBoard,
onSaveBoard,
}: BoardCardProps) {
const { toast } = useToast();
const [deleteOpen, setDeleteOpen] = useState(false);
const saveBoardRequest = useRequest(onSaveBoard);
const deleteBoardRequest = useRequest(onDeleteBoard);
// Workflow state
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>(initialWorkflowSteps ?? []);
const [workflowLoading, setWorkflowLoading] = useState(false);
const isNewBoard = board.id.startsWith('temp-');
// Load workflow steps on mount (only for saved boards)
useEffect(() => {
if (isNewBoard) {
setWorkflowSteps(initialWorkflowSteps ?? []);
setWorkflowLoading(false);
return;
}
let cancelled = false;
const load = async () => {
setWorkflowLoading(true);
try {
const res = await listWorkflowStepsAction(board.id);
if (!cancelled) {
setWorkflowSteps(res.steps ?? []);
}
} catch {
if (!cancelled) {
toast({
title: 'Failed to load workflow steps',
variant: 'error',
});
}
} finally {
if (!cancelled) {
setWorkflowLoading(false);
}
}
};
load();
return () => {
cancelled = true;
};
}, [board.id, initialWorkflowSteps, isNewBoard, toast]);
const refreshWorkflowSteps = async () => {
try {
const res = await listWorkflowStepsAction(board.id);
setWorkflowSteps(res.steps ?? []);
} catch {
// Ignore errors on refresh
}
};
const handleUpdateWorkflowStep = async (stepId: string, updates: Partial<WorkflowStep>) => {
if (isNewBoard) return;
try {
await updateWorkflowStepAction(stepId, updates);
await refreshWorkflowSteps();
} catch (error) {
toast({
title: 'Failed to update workflow step',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
const handleAddWorkflowStep = async () => {
if (isNewBoard) return;
try {
await createWorkflowStepAction({
board_id: board.id,
name: 'New Step',
step_type: 'implementation',
position: workflowSteps.length,
color: 'bg-slate-500',
});
await refreshWorkflowSteps();
} catch (error) {
toast({
title: 'Failed to add workflow step',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
const handleRemoveWorkflowStep = async (stepId: string) => {
if (isNewBoard) return;
try {
await deleteWorkflowStepAction(stepId);
await refreshWorkflowSteps();
} catch (error) {
toast({
title: 'Failed to delete workflow step',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
const handleReorderWorkflowSteps = async (reorderedSteps: WorkflowStep[]) => {
if (isNewBoard) return;
// Optimistically update the UI
setWorkflowSteps(reorderedSteps);
try {
const stepIds = reorderedSteps.map((step) => step.id);
await reorderWorkflowStepsAction(board.id, stepIds);
} catch (error) {
toast({
title: 'Failed to reorder workflow steps',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
// Refresh to get the actual order
await refreshWorkflowSteps();
}
};
const handleDeleteBoard = async () => {
try {
await deleteBoardRequest.run();
setDeleteOpen(false);
} catch (error) {
toast({
title: 'Failed to delete board',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
const handleSaveBoard = async () => {
try {
await saveBoardRequest.run();
} catch (error) {
toast({
title: 'Failed to save board changes',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
return (
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="space-y-2 flex-1">
<Label className="flex items-center gap-2">
<span>Board Name</span>
{isBoardDirty && <UnsavedChangesBadge />}
</Label>
<div className="flex items-center gap-2">
<Input
value={board.name}
onChange={(e) => onUpdateBoard({ name: e.target.value })}
/>
<UnsavedSaveButton
isDirty={isBoardDirty}
isLoading={saveBoardRequest.isLoading}
status={saveBoardRequest.status}
onClick={handleSaveBoard}
/>
</div>
</div>
</div>
{/* Workflow Steps Section - only show for saved boards */}
<div className="space-y-2">
<Label>Workflow Steps</Label>
{isNewBoard && (
<p className="text-xs text-muted-foreground">
Steps will be created when you save the board.
</p>
)}
{workflowLoading ? (
<div className="text-sm text-muted-foreground">Loading workflow steps...</div>
) : (
<WorkflowStepEditor
steps={workflowSteps}
onUpdateStep={handleUpdateWorkflowStep}
onAddStep={handleAddWorkflowStep}
onRemoveStep={handleRemoveWorkflowStep}
onReorderSteps={handleReorderWorkflowSteps}
readOnly={isNewBoard}
/>
)}
</div>
<div className="flex justify-end">
<Button
type="button"
variant="destructive"
onClick={() => setDeleteOpen(true)}
disabled={deleteBoardRequest.isLoading}
>
<IconTrash className="h-4 w-4 mr-2" />
Delete Board
</Button>
</div>
</div>
</CardContent>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete board</DialogTitle>
<DialogDescription>
This will remove the board and its workflow steps. Tasks will not be deleted. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDeleteOpen(false)}>
Cancel
</Button>
<Button type="button" variant="destructive" onClick={handleDeleteBoard}>
Delete Board
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}
@@ -477,7 +477,6 @@ export function EditorsSettings() {
if (parsedConfigs === null) return; // Invalid JSON, don't save
const payload: Parameters<typeof updateUserSettings>[0] = {
workspace_id: currentUserSettings.workspaceId ?? '',
board_id: currentUserSettings.boardId ?? '',
repository_ids: currentUserSettings.repositoryIds ?? [],
default_editor_id: defaultEditorId || undefined,
lsp_auto_start_languages: lspAutoStartLanguages,
@@ -492,7 +491,8 @@ export function EditorsSettings() {
if (response?.settings) {
setUserSettings({
workspaceId: response.settings.workspace_id || null,
boardId: response.settings.board_id || null,
workflowId: response.settings.workflow_filter_id || null,
kanbanViewMode: response.settings.kanban_view_mode || null,
repositoryIds: response.settings.repository_ids ?? [],
preferredShell: response.settings.preferred_shell || null,
shellOptions: currentUserSettings.shellOptions ?? [],
@@ -40,7 +40,6 @@ export function GeneralSettings() {
await updateUserSettings({
workspace_id: userSettings.workspaceId || '',
board_id: userSettings.boardId || '',
repository_ids: userSettings.repositoryIds || [],
chat_submit_key: value,
});
@@ -120,7 +120,7 @@ export function SettingsAppSidebar() {
<SidebarMenuSub className="ml-3 mt-1">
{workspaces.map((workspace: Workspace) => {
const workspacePath = `/settings/workspace/${workspace.id}`;
const boardsPath = `${workspacePath}/boards`;
const workflowsPath = `${workspacePath}/workflows`;
const repositoriesPath = `${workspacePath}/repositories`;
return (
@@ -149,10 +149,10 @@ export function SettingsAppSidebar() {
<SidebarMenuSubButton
asChild
size="sm"
isActive={pathname === boardsPath}
isActive={pathname === workflowsPath}
>
<Link href={boardsPath}>
<span>Boards</span>
<Link href={workflowsPath}>
<span>Workflows</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
@@ -46,7 +46,6 @@ export function ShellSettingsCard() {
const currentSettings = storeApi.getState().userSettings;
await updateUserSettings({
workspace_id: currentSettings.workspaceId ?? '',
board_id: currentSettings.boardId ?? '',
repository_ids: currentSettings.repositoryIds,
preferred_shell: trimmed,
});
@@ -0,0 +1,600 @@
'use client';
import { useEffect, useState } from 'react';
import { IconTrash } from '@tabler/icons-react';
import { Card, CardContent } from '@kandev/ui/card';
import { Button } from '@kandev/ui/button';
import { Input } from '@kandev/ui/input';
import { Label } from '@kandev/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@kandev/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@kandev/ui/select';
import type { Workflow, WorkflowStep } from '@/lib/types/http';
import { useRequest } from '@/lib/http/use-request';
import { useToast } from '@/components/toast-provider';
import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator';
import { WorkflowPipelineEditor } from '@/components/settings/workflow-pipeline-editor';
import { generateUUID } from '@/lib/utils';
import {
createWorkflowAction,
listWorkflowStepsAction,
createWorkflowStepAction,
updateWorkflowStepAction,
deleteWorkflowStepAction,
reorderWorkflowStepsAction,
getWorkflowTaskCount,
getStepTaskCount,
bulkMoveTasks,
} from '@/app/actions/workspaces';
type WorkflowCardProps = {
workflow: Workflow;
isWorkflowDirty: boolean;
initialWorkflowSteps?: WorkflowStep[];
otherWorkflows?: Workflow[];
onUpdateWorkflow: (updates: { name?: string; description?: string }) => void;
onDeleteWorkflow: () => Promise<unknown>;
onSaveWorkflow: () => Promise<unknown>;
onWorkflowCreated?: (created: Workflow) => void;
};
export function WorkflowCard({
workflow,
isWorkflowDirty,
initialWorkflowSteps,
otherWorkflows = [],
onUpdateWorkflow,
onDeleteWorkflow,
onSaveWorkflow,
onWorkflowCreated,
}: WorkflowCardProps) {
const { toast } = useToast();
const [deleteOpen, setDeleteOpen] = useState(false);
// Workflow deletion migration state
const [workflowTaskCount, setWorkflowTaskCount] = useState<number | null>(null);
const [workflowDeleteLoading, setWorkflowDeleteLoading] = useState(false);
const [targetWorkflowId, setTargetWorkflowId] = useState<string>('');
const [targetWorkflowSteps, setTargetWorkflowSteps] = useState<WorkflowStep[]>([]);
const [targetStepId, setTargetStepId] = useState<string>('');
const [migrateLoading, setMigrateLoading] = useState(false);
// Step deletion migration state
const [stepDeleteOpen, setStepDeleteOpen] = useState(false);
const [stepToDelete, setStepToDelete] = useState<string | null>(null);
const [stepTaskCount, setStepTaskCount] = useState<number | null>(null);
const [targetStepForMigration, setTargetStepForMigration] = useState<string>('');
const [stepMigrateLoading, setStepMigrateLoading] = useState(false);
const saveWorkflowRequest = useRequest(onSaveWorkflow);
const deleteWorkflowRequest = useRequest(onDeleteWorkflow);
// Workflow state
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>(initialWorkflowSteps ?? []);
const [workflowLoading, setWorkflowLoading] = useState(false);
const isNewWorkflow = workflow.id.startsWith('temp-');
// Load workflow steps on mount (only for saved workflows)
useEffect(() => {
if (isNewWorkflow) {
setWorkflowSteps(initialWorkflowSteps ?? []);
setWorkflowLoading(false);
return;
}
let cancelled = false;
const load = async () => {
setWorkflowLoading(true);
try {
const res = await listWorkflowStepsAction(workflow.id);
if (!cancelled) {
setWorkflowSteps(res.steps ?? []);
}
} catch {
if (!cancelled) {
toast({
title: 'Failed to load workflow steps',
variant: 'error',
});
}
} finally {
if (!cancelled) {
setWorkflowLoading(false);
}
}
};
load();
return () => {
cancelled = true;
};
}, [workflow.id, initialWorkflowSteps, isNewWorkflow, toast]);
// Load target workflow steps when target workflow changes
useEffect(() => {
if (!targetWorkflowId) {
setTargetWorkflowSteps([]);
setTargetStepId('');
return;
}
let cancelled = false;
listWorkflowStepsAction(targetWorkflowId).then((res) => {
if (!cancelled) {
const steps = res.steps ?? [];
setTargetWorkflowSteps(steps);
setTargetStepId(steps.length > 0 ? steps[0].id : '');
}
}).catch(() => {
if (!cancelled) setTargetWorkflowSteps([]);
});
return () => { cancelled = true; };
}, [targetWorkflowId]);
const refreshWorkflowSteps = async () => {
try {
const res = await listWorkflowStepsAction(workflow.id);
setWorkflowSteps(res.steps ?? []);
} catch {
// Ignore errors on refresh
}
};
const handleUpdateWorkflowStep = async (stepId: string, updates: Partial<WorkflowStep>) => {
if (isNewWorkflow) {
setWorkflowSteps((prev) =>
prev.map((s) => (s.id === stepId ? { ...s, ...updates } : s))
);
return;
}
try {
await updateWorkflowStepAction(stepId, updates);
await refreshWorkflowSteps();
} catch (error) {
toast({
title: 'Failed to update workflow step',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
const handleAddWorkflowStep = async () => {
if (isNewWorkflow) {
setWorkflowSteps((prev) => [
...prev,
{
id: `temp-step-${generateUUID()}`,
workflow_id: workflow.id,
name: 'New Step',
position: prev.length,
color: 'bg-slate-500',
allow_manual_move: true,
created_at: '',
updated_at: '',
},
]);
return;
}
try {
await createWorkflowStepAction({
workflow_id: workflow.id,
name: 'New Step',
position: workflowSteps.length,
color: 'bg-slate-500',
});
await refreshWorkflowSteps();
} catch (error) {
toast({
title: 'Failed to add workflow step',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
const handleRemoveWorkflowStep = async (stepId: string) => {
if (isNewWorkflow) {
setWorkflowSteps((prev) =>
prev.filter((s) => s.id !== stepId).map((s, i) => ({ ...s, position: i }))
);
return;
}
try {
const { task_count } = await getStepTaskCount(stepId);
if (task_count === 0) {
await deleteWorkflowStepAction(stepId);
await refreshWorkflowSteps();
return;
}
// Has tasks — show migration dialog
setStepToDelete(stepId);
setStepTaskCount(task_count);
const otherSteps = workflowSteps.filter((s) => s.id !== stepId);
setTargetStepForMigration(otherSteps.length > 0 ? otherSteps[0].id : '');
setStepDeleteOpen(true);
} catch (error) {
toast({
title: 'Failed to check step tasks',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
const handleReorderWorkflowSteps = async (reorderedSteps: WorkflowStep[]) => {
if (isNewWorkflow) {
setWorkflowSteps(reorderedSteps);
return;
}
// Optimistically update the UI
setWorkflowSteps(reorderedSteps);
try {
const stepIds = reorderedSteps.map((step) => step.id);
await reorderWorkflowStepsAction(workflow.id, stepIds);
} catch (error) {
toast({
title: 'Failed to reorder workflow steps',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
// Refresh to get the actual order
await refreshWorkflowSteps();
}
};
// Workflow deletion: check task count, then show appropriate dialog
const handleDeleteWorkflowClick = async () => {
if (isNewWorkflow) {
setWorkflowTaskCount(0);
setDeleteOpen(true);
return;
}
setWorkflowDeleteLoading(true);
try {
const { task_count } = await getWorkflowTaskCount(workflow.id);
setWorkflowTaskCount(task_count);
if (task_count > 0 && otherWorkflows.length > 0) {
setTargetWorkflowId(otherWorkflows[0].id);
}
setDeleteOpen(true);
} catch (error) {
toast({
title: 'Failed to check workflow tasks',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
} finally {
setWorkflowDeleteLoading(false);
}
};
const handleDeleteWorkflow = async () => {
try {
await deleteWorkflowRequest.run();
setDeleteOpen(false);
} catch (error) {
toast({
title: 'Failed to delete workflow',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
const handleMigrateAndDeleteWorkflow = async () => {
if (!targetWorkflowId || !targetStepId) return;
setMigrateLoading(true);
try {
await bulkMoveTasks({
source_workflow_id: workflow.id,
target_workflow_id: targetWorkflowId,
target_step_id: targetStepId,
});
await deleteWorkflowRequest.run();
setDeleteOpen(false);
} catch (error) {
toast({
title: 'Failed to migrate tasks',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
} finally {
setMigrateLoading(false);
}
};
const handleMigrateAndDeleteStep = async () => {
if (!stepToDelete || !targetStepForMigration) return;
setStepMigrateLoading(true);
try {
await bulkMoveTasks({
source_workflow_id: workflow.id,
source_step_id: stepToDelete,
target_workflow_id: workflow.id,
target_step_id: targetStepForMigration,
});
await deleteWorkflowStepAction(stepToDelete);
await refreshWorkflowSteps();
setStepDeleteOpen(false);
setStepToDelete(null);
} catch (error) {
toast({
title: 'Failed to migrate tasks',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
} finally {
setStepMigrateLoading(false);
}
};
const handleDeleteStepAndTasks = async () => {
if (!stepToDelete) return;
setStepMigrateLoading(true);
try {
await deleteWorkflowStepAction(stepToDelete);
await refreshWorkflowSteps();
setStepDeleteOpen(false);
setStepToDelete(null);
} catch (error) {
toast({
title: 'Failed to delete step',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
} finally {
setStepMigrateLoading(false);
}
};
const handleSaveNewWorkflow = async () => {
const name = workflow.name.trim() || 'New Workflow';
const created = await createWorkflowAction({
workspace_id: workflow.workspace_id,
name,
});
// Create each step with the user's customizations
for (const step of workflowSteps) {
await createWorkflowStepAction({
workflow_id: created.id,
name: step.name,
position: step.position,
color: step.color,
prompt: step.prompt,
events: step.events,
is_start_step: step.is_start_step,
allow_manual_move: step.allow_manual_move,
});
}
onWorkflowCreated?.(created);
};
const saveNewWorkflowRequest = useRequest(handleSaveNewWorkflow);
const handleSaveWorkflow = async () => {
try {
if (isNewWorkflow) {
await saveNewWorkflowRequest.run();
} else {
await saveWorkflowRequest.run();
}
} catch (error) {
toast({
title: 'Failed to save workflow changes',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
const activeSaveRequest = isNewWorkflow ? saveNewWorkflowRequest : saveWorkflowRequest;
const hasTasks = workflowTaskCount !== null && workflowTaskCount > 0;
const stepsForStepMigration = stepToDelete
? workflowSteps.filter((s) => s.id !== stepToDelete)
: [];
return (
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="space-y-2 flex-1">
<Label className="flex items-center gap-2">
<span>Workflow Name</span>
{isWorkflowDirty && <UnsavedChangesBadge />}
</Label>
<div className="flex items-center gap-2">
<Input
value={workflow.name}
onChange={(e) => onUpdateWorkflow({ name: e.target.value })}
/>
<UnsavedSaveButton
isDirty={isWorkflowDirty}
isLoading={activeSaveRequest.isLoading}
status={activeSaveRequest.status}
onClick={handleSaveWorkflow}
/>
</div>
</div>
</div>
{/* Workflow Steps Section */}
<div className="space-y-2">
<Label>Workflow Steps</Label>
{workflowLoading ? (
<div className="text-sm text-muted-foreground">Loading workflow steps...</div>
) : (
<WorkflowPipelineEditor
steps={workflowSteps}
onUpdateStep={handleUpdateWorkflowStep}
onAddStep={handleAddWorkflowStep}
onRemoveStep={handleRemoveWorkflowStep}
onReorderSteps={handleReorderWorkflowSteps}
/>
)}
</div>
<div className="flex justify-end">
<Button
type="button"
variant="destructive"
onClick={handleDeleteWorkflowClick}
disabled={deleteWorkflowRequest.isLoading || workflowDeleteLoading}
className="cursor-pointer"
>
<IconTrash className="h-4 w-4 mr-2" />
Delete Workflow
</Button>
</div>
</div>
</CardContent>
{/* Workflow Delete Dialog */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete workflow</DialogTitle>
<DialogDescription>
{hasTasks
? `This workflow has ${workflowTaskCount} task${workflowTaskCount === 1 ? '' : 's'}. Choose where to migrate them, or delete everything.`
: 'This will permanently delete the workflow and all its steps.'}
</DialogDescription>
</DialogHeader>
{hasTasks && otherWorkflows.length > 0 && (
<div className="space-y-3 py-2">
<div className="space-y-2">
<Label>Target Workflow</Label>
<Select value={targetWorkflowId} onValueChange={setTargetWorkflowId}>
<SelectTrigger>
<SelectValue placeholder="Select workflow" />
</SelectTrigger>
<SelectContent>
{otherWorkflows.map((w) => (
<SelectItem key={w.id} value={w.id}>
{w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{targetWorkflowSteps.length > 0 && (
<div className="space-y-2">
<Label>Target Step</Label>
<Select value={targetStepId} onValueChange={setTargetStepId}>
<SelectTrigger>
<SelectValue placeholder="Select step" />
</SelectTrigger>
<SelectContent>
{targetWorkflowSteps.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDeleteOpen(false)} className="cursor-pointer">
Cancel
</Button>
{hasTasks && otherWorkflows.length > 0 && (
<Button
type="button"
onClick={handleMigrateAndDeleteWorkflow}
disabled={!targetWorkflowId || !targetStepId || migrateLoading || deleteWorkflowRequest.isLoading}
className="cursor-pointer"
>
{migrateLoading ? 'Migrating...' : 'Migrate & Delete'}
</Button>
)}
<Button
type="button"
variant="destructive"
onClick={handleDeleteWorkflow}
disabled={deleteWorkflowRequest.isLoading || migrateLoading}
className="cursor-pointer"
>
{hasTasks ? 'Delete Everything' : 'Delete Workflow'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Step Delete Migration Dialog */}
<Dialog open={stepDeleteOpen} onOpenChange={setStepDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete step</DialogTitle>
<DialogDescription>
This step has {stepTaskCount} task{stepTaskCount === 1 ? '' : 's'}.
{stepsForStepMigration.length > 0
? ' Choose where to migrate them, or delete the step and its tasks.'
: ' Deleting this step will affect these tasks.'}
</DialogDescription>
</DialogHeader>
{stepsForStepMigration.length > 0 && (
<div className="space-y-2 py-2">
<Label>Target Step</Label>
<Select value={targetStepForMigration} onValueChange={setTargetStepForMigration}>
<SelectTrigger>
<SelectValue placeholder="Select step" />
</SelectTrigger>
<SelectContent>
{stepsForStepMigration.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setStepDeleteOpen(false)} className="cursor-pointer">
Cancel
</Button>
{stepsForStepMigration.length > 0 && (
<Button
type="button"
onClick={handleMigrateAndDeleteStep}
disabled={!targetStepForMigration || stepMigrateLoading}
className="cursor-pointer"
>
{stepMigrateLoading ? 'Migrating...' : 'Migrate & Delete Step'}
</Button>
)}
<Button
type="button"
variant="destructive"
onClick={handleDeleteStepAndTasks}
disabled={stepMigrateLoading}
className="cursor-pointer"
>
Delete Step & Tasks
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}
@@ -0,0 +1,846 @@
'use client';
import { useMemo, useState, useSyncExternalStore } from 'react';
import {
DndContext,
closestCenter,
type DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
horizontalListSortingStrategy,
arrayMove,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
IconGripVertical,
IconPlus,
IconTrash,
IconRobot,
IconClipboard,
IconArrowRight,
IconChevronRight,
IconInfoCircle,
IconRosetteNumber1,
IconMessageForward,
} from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import { Input } from '@kandev/ui/input';
import { Textarea } from '@kandev/ui/textarea';
import { Checkbox } from '@kandev/ui/checkbox';
import { Label } from '@kandev/ui/label';
import { ScrollArea, ScrollBar } from '@kandev/ui/scroll-area';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@kandev/ui/select';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@kandev/ui/tooltip';
import type { WorkflowStep, OnEnterAction, OnTurnStartAction, OnTurnCompleteAction } from '@/lib/types/http';
import { useDebouncedCallback } from '@/hooks/use-debounce';
import { cn } from '@/lib/utils';
const STEP_COLORS = [
{ value: 'bg-slate-500', label: 'Gray' },
{ value: 'bg-red-500', label: 'Red' },
{ value: 'bg-orange-500', label: 'Orange' },
{ value: 'bg-yellow-500', label: 'Yellow' },
{ value: 'bg-green-500', label: 'Green' },
{ value: 'bg-cyan-500', label: 'Cyan' },
{ value: 'bg-blue-500', label: 'Blue' },
{ value: 'bg-indigo-500', label: 'Indigo' },
{ value: 'bg-purple-500', label: 'Purple' },
];
type WorkflowPipelineEditorProps = {
steps: WorkflowStep[];
onUpdateStep: (stepId: string, updates: Partial<WorkflowStep>) => void;
onAddStep: () => void;
onRemoveStep: (stepId: string) => void;
onReorderSteps: (steps: WorkflowStep[]) => void;
readOnly?: boolean;
};
// --- Helpers ---
function hasOnEnterAction(step: WorkflowStep, type: string): boolean {
return step.events?.on_enter?.some((a) => a.type === type) ?? false;
}
function getTransitionType(step: WorkflowStep): string {
const action = step.events?.on_turn_complete?.find((a) =>
['move_to_next', 'move_to_previous', 'move_to_step'].includes(a.type)
);
return action?.type ?? 'none';
}
function getOnTurnStartTransitionType(step: WorkflowStep): string {
const action = step.events?.on_turn_start?.find((a) =>
['move_to_next', 'move_to_previous', 'move_to_step'].includes(a.type)
);
return action?.type ?? 'none';
}
function hasDisablePlanMode(step: WorkflowStep): boolean {
return step.events?.on_turn_complete?.some((a) => a.type === 'disable_plan_mode') ?? false;
}
function getTransitionLabel(step: WorkflowStep): string {
const t = getTransitionType(step);
if (t === 'move_to_next') return 'auto';
if (t === 'move_to_previous') return 'back';
if (t === 'move_to_step') return 'goto';
return 'manual';
}
// --- Prompt Templates ---
const PROMPT_TEMPLATES = [
{
label: 'Plan',
prompt: `Analyze the task and create a detailed implementation plan.
{{task_prompt}}
INSTRUCTIONS:
1. Break the task into clear, ordered steps
2. For each step, describe what needs to be done and which files are affected
3. Identify potential risks or blockers
4. Estimate relative complexity for each step (low/medium/high)
Output the plan as a numbered list. Be specific about file paths, function names, and the approach for each step. Do NOT implement anything yet — only plan.`,
},
{
label: 'Code Review',
prompt: `Please review the changed files in the current git worktree.
STEP 1: Determine what to review
- First, check if there are any uncommitted changes (dirty working directory)
- If there are uncommitted/staged changes: review those files
- If the working directory is clean: review the commits that have diverged from the master/main branch
STEP 2: Review the changes, then output your findings in EXACTLY 4 sections: BUG, IMPROVEMENT, NITPICK, PERFORMANCE.
Rules:
- Each section is OPTIONAL — only include it if you have findings for that category
- If a section has no findings, omit it entirely
- Format each finding as: filename:line_number - Description
- Be specific and reference exact line numbers
- Keep descriptions concise but actionable
- Sort findings by severity within each section
- Focus on logic and design issues, NOT formatting or style that automated tools handle
Section definitions:
BUG: Critical issues that will cause runtime errors, crashes, incorrect behavior, data corruption, or logic errors
- Examples: null/nil dereference, race conditions, incorrect algorithms, type mismatches, resource leaks, deadlocks
IMPROVEMENT: Code quality, architecture, security, or maintainability concerns
- Examples: missing error handling, incorrect access modifiers, SQL injection vulnerabilities, hardcoded credentials, tight coupling, missing validation
NITPICK: Significant readability or maintainability issues that impact code understanding
- Examples: misleading variable/function names, overly complex logic that should be refactored, missing critical comments for complex algorithms
- EXCLUDE: formatting, whitespace, import ordering, trivial naming preferences, style issues handled by linters/formatters
PERFORMANCE: Algorithmic or resource usage problems with measurable impact
- Examples: O(n\u00B2) where O(n) or O(1) is possible, unnecessary allocations in loops, missing indexes for database queries, blocking I/O in hot paths
- Concurrency-specific: unprotected shared state, missing synchronization, goroutine leaks, missing context cancellation
Now review the changes.`,
},
{
label: 'Security Audit',
prompt: `Perform a security audit on the changed files in the current git worktree.
{{task_prompt}}
Review all changes and check for the following categories:
1. **Injection Vulnerabilities**: SQL injection, command injection, XSS, template injection, path traversal
2. **Authentication & Authorization**: Missing auth checks, broken access control, privilege escalation, insecure session handling
3. **Data Exposure**: Hardcoded secrets, credentials in logs, sensitive data in error messages, missing encryption
4. **Input Validation**: Missing or insufficient validation at system boundaries, unsafe deserialization, unrestricted file uploads
5. **Dependency Risks**: Known vulnerable dependencies, unsafe use of third-party libraries
6. **Concurrency Issues**: Race conditions on shared state, TOCTOU bugs, unsafe concurrent access to resources
For each finding, output:
- **Severity**: CRITICAL / HIGH / MEDIUM / LOW
- **Location**: filename:line_number
- **Issue**: What the vulnerability is
- **Impact**: What an attacker could do
- **Fix**: Specific remediation steps
Only report real, actionable findings. Do not flag theoretical issues without evidence in the code.`,
},
];
// --- Inline help tooltip ---
function HelpTip({ text }: { text: string }) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground/50 hover:text-muted-foreground cursor-help shrink-0" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">{text}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
// --- Pipeline Node ---
type PipelineNodeProps = {
step: WorkflowStep;
isSelected: boolean;
onSelect: () => void;
onRemove: () => void;
readOnly?: boolean;
};
function PipelineNode({ step, isSelected, onSelect, onRemove, readOnly = false }: PipelineNodeProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: step.id,
});
const style = {
transform: CSS.Transform.toString(transform),
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}
style={style}
className={cn(
'group relative flex items-center gap-1.5 rounded-lg border-2 px-3 py-2 min-w-[120px] max-w-[160px] cursor-pointer transition-colors select-none',
isSelected
? 'border-primary bg-primary/5'
: 'border-border bg-card hover:border-primary/50',
isDragging && 'opacity-50 z-50',
)}
onClick={onSelect}
>
{/* Start step indicator */}
{step.is_start_step && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="absolute -top-2 -left-2 flex items-center justify-center w-5 h-5 rounded-full bg-amber-500 text-white">
<IconRosetteNumber1 className="h-3.5 w-3.5" />
</div>
</TooltipTrigger>
<TooltipContent>Start step</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Drag handle */}
<button
type="button"
className={cn(
'shrink-0 p-0.5 rounded text-muted-foreground/40 hover:text-muted-foreground',
readOnly ? 'cursor-default' : 'cursor-grab',
)}
{...(readOnly ? {} : attributes)}
{...(readOnly ? {} : listeners)}
aria-disabled={readOnly}
onClick={(e) => e.stopPropagation()}
>
<IconGripVertical className="h-3.5 w-3.5" />
</button>
{/* Content */}
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
{/* Color dot + Name */}
<div className="flex items-center gap-1.5">
<div className={cn('w-3 h-3 rounded-full shrink-0', step.color)} />
<span className="text-sm font-medium truncate">{step.name}</span>
</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>
</div>
{/* Delete button on hover */}
{!readOnly && (
<button
type="button"
className="absolute -top-2 -right-2 hidden group-hover:flex items-center justify-center w-5 h-5 rounded-full bg-destructive text-destructive-foreground hover:bg-destructive/90 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<IconTrash className="h-3 w-3" />
</button>
)}
</div>
);
}
// --- Connector ---
function PipelineConnector({ label }: { label: string }) {
return (
<div className="flex flex-col items-center justify-center shrink-0 px-1">
<div className="flex items-center gap-0.5 text-muted-foreground/60">
<div className="w-4 h-px bg-border" />
<IconChevronRight className="h-3 w-3" />
</div>
<span className="text-[10px] text-muted-foreground/50 leading-none mt-0.5">{label}</span>
</div>
);
}
// --- Step Config Panel ---
type StepConfigPanelProps = {
step: WorkflowStep;
steps: WorkflowStep[];
onUpdate: (updates: Partial<WorkflowStep>) => void;
onRemove: () => void;
readOnly?: boolean;
};
function StepConfigPanel({ step, steps, onUpdate, onRemove, readOnly = false }: StepConfigPanelProps) {
const [localName, setLocalName] = useState(step.name);
const [localPrompt, setLocalPrompt] = useState(step.prompt ?? '');
const debouncedUpdateName = useDebouncedCallback((name: string) => {
onUpdate({ name });
}, 500);
const debouncedUpdatePrompt = useDebouncedCallback((prompt: string) => {
onUpdate({ prompt });
}, 500);
const toggleOnEnterAction = (type: string) => {
const currentEvents = step.events ?? {};
const onEnter = currentEvents.on_enter ?? [];
const exists = onEnter.some((a) => a.type === type);
const newOnEnter = exists
? onEnter.filter((a) => a.type !== type)
: [...onEnter, { type } as OnEnterAction];
onUpdate({ events: { ...currentEvents, on_enter: newOnEnter } });
};
const setTransition = (type: string) => {
const currentEvents = step.events ?? {};
const onTurnComplete = (currentEvents.on_turn_complete ?? []).filter(
(a) => !['move_to_next', 'move_to_previous', 'move_to_step'].includes(a.type)
);
if (type !== 'none') {
onTurnComplete.push({ type } as OnTurnCompleteAction);
}
onUpdate({ events: { ...currentEvents, on_turn_complete: onTurnComplete } });
};
const setOnTurnStartTransition = (type: string) => {
const currentEvents = step.events ?? {};
const onTurnStart = (currentEvents.on_turn_start ?? []).filter(
(a) => !['move_to_next', 'move_to_previous', 'move_to_step'].includes(a.type)
);
if (type !== 'none') {
onTurnStart.push({ type } as OnTurnStartAction);
}
onUpdate({ events: { ...currentEvents, on_turn_start: onTurnStart } });
};
const toggleDisablePlanMode = () => {
const currentEvents = step.events ?? {};
const onTurnComplete = currentEvents.on_turn_complete ?? [];
const exists = onTurnComplete.some((a) => a.type === 'disable_plan_mode');
const newOnTurnComplete = exists
? onTurnComplete.filter((a) => a.type !== 'disable_plan_mode')
: [...onTurnComplete, { type: 'disable_plan_mode' } as OnTurnCompleteAction];
onUpdate({ events: { ...currentEvents, on_turn_complete: newOnTurnComplete } });
};
// Reset local state only when the step identity changes (not on local edits round-tripping)
const resetKey = step.id;
return (
<div key={resetKey} className="rounded-lg border bg-card animate-in fade-in-0 slide-in-from-top-2 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Input
id={`${step.id}-name`}
value={localName}
onChange={(e) => {
if (readOnly) return;
setLocalName(e.target.value);
debouncedUpdateName(e.target.value);
}}
placeholder="Step name"
disabled={readOnly}
className="max-w-[240px] h-8"
/>
<Select
value={step.color}
onValueChange={(value) => {
if (readOnly) return;
onUpdate({ color: value });
}}
disabled={readOnly}
>
<SelectTrigger className="w-[120px] h-8">
<SelectValue placeholder="Color" />
</SelectTrigger>
<SelectContent position="popper" side="bottom" align="start">
{STEP_COLORS.map((color) => (
<SelectItem key={color.value} value={color.value}>
<div className="flex items-center gap-2">
<div className={cn('w-3 h-3 rounded-full', color.value)} />
{color.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onRemove}
disabled={readOnly}
className="text-destructive hover:text-destructive h-8"
>
<IconTrash className="h-3.5 w-3.5 mr-1" />
Delete
</Button>
</div>
{/* Body */}
<div className="p-4 space-y-5">
{/* Behavior section */}
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
<div className="flex items-center gap-2">
<Checkbox
id={`${step.id}-start-step`}
checked={step.is_start_step === true}
onCheckedChange={(checked) => {
if (readOnly) return;
onUpdate({ is_start_step: checked === true });
}}
disabled={readOnly}
/>
<Label htmlFor={`${step.id}-start-step`} className="text-sm">Start step</Label>
<HelpTip text="New tasks start in this step. Only one step per workflow can be the start step." />
</div>
<div className="flex items-center gap-2">
<Checkbox
id={`${step.id}-auto-start`}
checked={hasOnEnterAction(step, 'auto_start_agent')}
onCheckedChange={() => {
if (readOnly) return;
toggleOnEnterAction('auto_start_agent');
}}
disabled={readOnly}
/>
<Label htmlFor={`${step.id}-auto-start`} className="text-sm">Auto-start agent</Label>
<HelpTip text="Automatically start the agent when a task enters this step." />
</div>
<div className="flex items-center gap-2">
<Checkbox
id={`${step.id}-plan-mode`}
checked={hasOnEnterAction(step, 'enable_plan_mode')}
onCheckedChange={() => {
if (readOnly) return;
toggleOnEnterAction('enable_plan_mode');
}}
disabled={readOnly}
/>
<Label htmlFor={`${step.id}-plan-mode`} className="text-sm">Plan mode</Label>
<HelpTip text="Agent proposes a plan instead of making changes directly." />
</div>
<div className="flex items-center gap-2">
<Checkbox
id={`${step.id}-manual-move`}
checked={step.allow_manual_move !== false}
onCheckedChange={(checked) => {
if (readOnly) return;
onUpdate({ allow_manual_move: checked === true });
}}
disabled={readOnly}
/>
<Label htmlFor={`${step.id}-manual-move`} className="text-sm">Allow manual move</Label>
<HelpTip text="Allow dragging tasks into this step on the board." />
</div>
</div>
</div>
{/* Transitions section */}
<div className="space-y-3">
<div className="flex items-center gap-1.5">
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Transitions</Label>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* On Turn Start */}
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs font-medium">On Turn Start</Label>
<HelpTip text="Runs when a user sends a message. Use for review cycles (e.g., move back to In Progress on feedback)." />
</div>
<Select
value={getOnTurnStartTransitionType(step)}
onValueChange={(value) => {
if (readOnly) return;
setOnTurnStartTransition(value);
}}
disabled={readOnly}
>
<SelectTrigger className="w-full h-8">
<SelectValue placeholder="Select action" />
</SelectTrigger>
<SelectContent position="popper" side="bottom" align="start">
<SelectItem value="none">Do nothing</SelectItem>
<SelectItem value="move_to_next">Move to next step</SelectItem>
<SelectItem value="move_to_previous">Move to previous step</SelectItem>
<SelectItem value="move_to_step">Move to specific step</SelectItem>
</SelectContent>
</Select>
{getOnTurnStartTransitionType(step) === 'move_to_step' && (
<Select
value={
step.events?.on_turn_start?.find((a) => a.type === 'move_to_step')?.config?.step_id as string ?? ''
}
onValueChange={(value) => {
if (readOnly) return;
const currentEvents = step.events ?? {};
const onTurnStart = (currentEvents.on_turn_start ?? []).map((a) =>
a.type === 'move_to_step' ? { ...a, config: { step_id: value } } : a
);
onUpdate({ events: { ...currentEvents, on_turn_start: onTurnStart } });
}}
disabled={readOnly}
>
<SelectTrigger className="w-full h-8">
<SelectValue placeholder="Select step" />
</SelectTrigger>
<SelectContent position="popper" side="bottom" align="start">
{steps
.filter((s) => s.id !== step.id)
.map((s) => (
<SelectItem key={s.id} value={s.id}>
<div className="flex items-center gap-2">
<div className={cn('w-2 h-2 rounded-full', s.color)} />
{s.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* On Turn Complete */}
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs font-medium">On Turn Complete</Label>
<HelpTip text="Runs after the agent finishes a turn. Use to auto-advance tasks through the pipeline." />
</div>
<Select
value={getTransitionType(step)}
onValueChange={(value) => {
if (readOnly) return;
setTransition(value);
}}
disabled={readOnly}
>
<SelectTrigger className="w-full h-8">
<SelectValue placeholder="Select action" />
</SelectTrigger>
<SelectContent position="popper" side="bottom" align="start">
<SelectItem value="none">Do nothing (wait for user)</SelectItem>
<SelectItem value="move_to_next">Move to next step</SelectItem>
<SelectItem value="move_to_previous">Move to previous step</SelectItem>
<SelectItem value="move_to_step">Move to specific step</SelectItem>
</SelectContent>
</Select>
{getTransitionType(step) === 'move_to_step' && (
<Select
value={
step.events?.on_turn_complete?.find((a) => a.type === 'move_to_step')?.config?.step_id as string ?? ''
}
onValueChange={(value) => {
if (readOnly) return;
const currentEvents = step.events ?? {};
const onTurnComplete = (currentEvents.on_turn_complete ?? []).map((a) =>
a.type === 'move_to_step' ? { ...a, config: { step_id: value } } : a
);
onUpdate({ events: { ...currentEvents, on_turn_complete: onTurnComplete } });
}}
disabled={readOnly}
>
<SelectTrigger className="w-full h-8">
<SelectValue placeholder="Select step" />
</SelectTrigger>
<SelectContent position="popper" side="bottom" align="start">
{steps
.filter((s) => s.id !== step.id)
.map((s) => (
<SelectItem key={s.id} value={s.id}>
<div className="flex items-center gap-2">
<div className={cn('w-2 h-2 rounded-full', s.color)} />
{s.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
{hasOnEnterAction(step, 'enable_plan_mode') && (
<div className="flex items-center gap-2 pt-1">
<Checkbox
id={`${step.id}-disable-plan`}
checked={hasDisablePlanMode(step)}
onCheckedChange={() => {
if (readOnly) return;
toggleDisablePlanMode();
}}
disabled={readOnly}
/>
<Label htmlFor={`${step.id}-disable-plan`} className="text-sm">Disable plan mode on complete</Label>
<HelpTip text="Turn off plan mode after the agent finishes this step." />
</div>
)}
</div>
</div>
</div>
{/* Prompt section */}
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label htmlFor={`${step.id}-prompt`} className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Step Prompt</Label>
<HelpTip text="Custom instructions for the agent on this step. Use {{task_prompt}} to include the task description." />
</div>
{!readOnly && (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] text-muted-foreground/60">Templates:</span>
{PROMPT_TEMPLATES.map((template) => (
<button
key={template.label}
type="button"
onClick={() => {
setLocalPrompt(template.prompt);
debouncedUpdatePrompt(template.prompt);
}}
className="text-[11px] px-2 py-0.5 rounded-md border border-border bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
{template.label}
</button>
))}
</div>
)}
<Textarea
id={`${step.id}-prompt`}
value={localPrompt}
onChange={(e) => {
if (readOnly) return;
setLocalPrompt(e.target.value);
debouncedUpdatePrompt(e.target.value);
}}
placeholder={'Instructions for the agent on this step.\nUse {{task_prompt}} to include the task description.'}
rows={3}
disabled={readOnly}
className="font-mono text-xs max-h-[200px] overflow-y-auto resize-y"
/>
<p className="text-[11px] text-muted-foreground/60">
If set, this prompt will be used instead of the task description. Use <code className="bg-muted px-1 py-0.5 rounded text-[10px]">{'{{task_prompt}}'}</code> to include the original task description within it.
</p>
</div>
</div>
</div>
);
}
// --- Main Pipeline Editor ---
export function WorkflowPipelineEditor({
steps,
onUpdateStep,
onAddStep,
onRemoveStep,
onReorderSteps,
readOnly = false,
}: WorkflowPipelineEditorProps) {
const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
const [prevStepCount, setPrevStepCount] = useState(steps.length);
// Auto-select newly added step (render-time state adjustment)
if (steps.length !== prevStepCount) {
if (steps.length > prevStepCount && steps.length > 0) {
setSelectedStepId(steps[steps.length - 1].id);
}
setPrevStepCount(steps.length);
}
const stepItems = useMemo(() => steps.map((step) => step.id), [steps]);
const isMounted = useSyncExternalStore(
() => () => { },
() => true,
() => false,
);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
);
const handleDragEnd = (event: DragEndEvent) => {
if (readOnly) return;
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = steps.findIndex((step) => step.id === active.id);
const newIndex = steps.findIndex((step) => step.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const nextSteps = arrayMove(steps, oldIndex, newIndex).map((step, index) => ({
...step,
position: index,
}));
onReorderSteps(nextSteps);
};
const selectedStep = steps.find((s) => s.id === selectedStepId);
const handleSelectStep = (stepId: string) => {
setSelectedStepId((prev) => (prev === stepId ? null : stepId));
};
const pipelineContent = (
<div className="flex items-center gap-0 py-2 px-1">
{steps.map((step, index) => (
<div key={step.id} className="flex items-center">
{index > 0 && <PipelineConnector label={getTransitionLabel(steps[index - 1])} />}
<PipelineNode
step={step}
isSelected={selectedStepId === step.id}
onSelect={() => handleSelectStep(step.id)}
onRemove={() => {
onRemoveStep(step.id);
if (selectedStepId === step.id) setSelectedStepId(null);
}}
readOnly={readOnly}
/>
</div>
))}
{/* Add step button */}
{steps.length > 0 && (
<div className="flex items-center">
<div className="w-4 h-px bg-border shrink-0" />
</div>
)}
<button
type="button"
onClick={readOnly ? undefined : onAddStep}
disabled={readOnly}
className="shrink-0 h-10 w-10 rounded-lg border border-dashed border-border text-muted-foreground hover:border-primary/50 hover:text-foreground flex items-center justify-center cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<IconPlus className="h-4 w-4" />
</button>
</div>
);
return (
<div className="space-y-3">
{/* Horizontal scrollable pipeline */}
<ScrollArea className="w-full">
{isMounted ? (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd} sensors={sensors}>
<SortableContext items={stepItems} strategy={horizontalListSortingStrategy}>
{pipelineContent}
</SortableContext>
</DndContext>
) : (
pipelineContent
)}
<ScrollBar orientation="horizontal" />
</ScrollArea>
{/* Config panel for selected step */}
{selectedStep && (
<StepConfigPanel
key={selectedStep.id}
step={selectedStep}
steps={steps}
onUpdate={(updates) => onUpdateStep(selectedStep.id, updates)}
onRemove={() => {
onRemoveStep(selectedStep.id);
setSelectedStepId(null);
}}
readOnly={readOnly}
/>
)}
</div>
);
}
@@ -1,387 +0,0 @@
'use client';
import { useMemo, useState, useSyncExternalStore } from 'react';
import {
DndContext,
closestCenter,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
IconGripVertical,
IconPlus,
IconTrash,
IconChevronDown,
IconChevronUp,
IconRobot,
IconCheck,
IconClipboard,
} from '@tabler/icons-react';
import { Card, CardContent } from '@kandev/ui/card';
import { Button } from '@kandev/ui/button';
import { Input } from '@kandev/ui/input';
import { Textarea } from '@kandev/ui/textarea';
import { Checkbox } from '@kandev/ui/checkbox';
import { Label } from '@kandev/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@kandev/ui/select';
import type { WorkflowStep, WorkflowStepType, StepBehaviors } from '@/lib/types/http';
import { useDebouncedCallback } from '@/hooks/use-debounce';
const STEP_COLORS = [
{ value: 'bg-slate-500', label: 'Gray' },
{ value: 'bg-red-500', label: 'Red' },
{ value: 'bg-orange-500', label: 'Orange' },
{ value: 'bg-yellow-500', label: 'Yellow' },
{ value: 'bg-green-500', label: 'Green' },
{ value: 'bg-cyan-500', label: 'Cyan' },
{ value: 'bg-blue-500', label: 'Blue' },
{ value: 'bg-indigo-500', label: 'Indigo' },
{ value: 'bg-purple-500', label: 'Purple' },
];
const STEP_TYPES: { value: WorkflowStepType; label: string }[] = [
{ value: 'backlog', label: 'Backlog' },
{ value: 'planning', label: 'Planning' },
{ value: 'implementation', label: 'Implementation' },
{ value: 'review', label: 'Review' },
{ value: 'verification', label: 'Verification' },
{ value: 'done', label: 'Done' },
{ value: 'blocked', label: 'Blocked' },
];
type WorkflowStepEditorProps = {
steps: WorkflowStep[];
onUpdateStep: (stepId: string, updates: Partial<WorkflowStep>) => void;
onAddStep: () => void;
onRemoveStep: (stepId: string) => void;
onReorderSteps: (steps: WorkflowStep[]) => void;
readOnly?: boolean;
};
type SortableStepCardProps = {
step: WorkflowStep;
onUpdate: (updates: Partial<WorkflowStep>) => void;
onRemove: () => void;
readOnly?: boolean;
};
// Inner component that uses step values directly - reset via key prop from parent
function SortableStepCardInner({ step, onUpdate, onRemove, readOnly = false }: SortableStepCardProps) {
const [expanded, setExpanded] = useState(false);
// Local state for text inputs to avoid API calls on every keystroke
const [localName, setLocalName] = useState(step.name);
const [localPromptPrefix, setLocalPromptPrefix] = useState(step.behaviors?.promptPrefix ?? '');
const [localPromptSuffix, setLocalPromptSuffix] = useState(step.behaviors?.promptSuffix ?? '');
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: step.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const behaviors = step.behaviors ?? {};
// Debounced update functions
const debouncedUpdateName = useDebouncedCallback((name: string) => {
onUpdate({ name });
}, 500);
const debouncedUpdatePromptPrefix = useDebouncedCallback((promptPrefix: string) => {
onUpdate({ behaviors: { ...behaviors, promptPrefix } });
}, 500);
const debouncedUpdatePromptSuffix = useDebouncedCallback((promptSuffix: string) => {
onUpdate({ behaviors: { ...behaviors, promptSuffix } });
}, 500);
const updateBehaviors = (updates: Partial<StepBehaviors>) => {
onUpdate({ behaviors: { ...behaviors, ...updates } });
};
return (
<Card ref={setNodeRef} style={style} className="mb-2">
<CardContent className="p-3">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[auto_auto_1fr_160px_72px_32px_32px] sm:items-center min-w-0">
<button
type="button"
className={`p-1 rounded-md text-muted-foreground hover:text-foreground ${readOnly ? 'cursor-default' : 'cursor-grab'}`}
{...(readOnly ? {} : attributes)}
{...(readOnly ? {} : listeners)}
aria-disabled={readOnly}
>
<IconGripVertical className="h-4 w-4" />
</button>
<div className={`w-4 h-4 rounded shrink-0 ${step.color}`} />
<Input
value={localName}
onChange={(e) => {
if (readOnly) return;
setLocalName(e.target.value);
debouncedUpdateName(e.target.value);
}}
className="w-full min-w-0"
placeholder="Step name"
disabled={readOnly}
/>
<Select
value={step.step_type}
onValueChange={(value: WorkflowStepType) => {
if (readOnly) return;
onUpdate({ step_type: value });
}}
disabled={readOnly}
>
<SelectTrigger className="w-full sm:w-[160px] shrink-0">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
{STEP_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1 text-muted-foreground w-[72px] shrink-0">
{behaviors.autoStartAgent && <IconRobot className="h-4 w-4" title="Auto-start agent" />}
{behaviors.requireApproval && <IconCheck className="h-4 w-4" title="Require approval" />}
{behaviors.planMode && <IconClipboard className="h-4 w-4" title="Plan mode" />}
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setExpanded(!expanded)}
className="shrink-0"
>
{expanded ? <IconChevronUp className="h-4 w-4" /> : <IconChevronDown className="h-4 w-4" />}
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onRemove}
disabled={readOnly}
className="shrink-0"
>
<IconTrash className="h-4 w-4" />
</Button>
</div>
{expanded && (
<div className="mt-4 pt-4 border-t space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="flex items-center space-x-2">
<Checkbox
id={`${step.id}-auto-start`}
checked={behaviors.autoStartAgent ?? false}
onCheckedChange={(checked) => {
if (readOnly) return;
updateBehaviors({ autoStartAgent: checked === true });
}}
disabled={readOnly}
/>
<Label htmlFor={`${step.id}-auto-start`}>Auto-start agent</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id={`${step.id}-plan-mode`}
checked={behaviors.planMode ?? false}
onCheckedChange={(checked) => {
if (readOnly) return;
updateBehaviors({ planMode: checked === true });
}}
disabled={readOnly}
/>
<Label htmlFor={`${step.id}-plan-mode`}>Plan mode</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id={`${step.id}-require-approval`}
checked={behaviors.requireApproval ?? false}
onCheckedChange={(checked) => {
if (readOnly) return;
updateBehaviors({ requireApproval: checked === true });
}}
disabled={readOnly}
/>
<Label htmlFor={`${step.id}-require-approval`}>Require approval</Label>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`${step.id}-prompt-prefix`}>Prompt Prefix</Label>
<Textarea
id={`${step.id}-prompt-prefix`}
value={localPromptPrefix}
onChange={(e) => {
if (readOnly) return;
setLocalPromptPrefix(e.target.value);
debouncedUpdatePromptPrefix(e.target.value);
}}
placeholder="Text prepended to the task description when agent starts..."
rows={3}
disabled={readOnly}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${step.id}-prompt-suffix`}>Prompt Suffix</Label>
<Textarea
id={`${step.id}-prompt-suffix`}
value={localPromptSuffix}
onChange={(e) => {
if (readOnly) return;
setLocalPromptSuffix(e.target.value);
debouncedUpdatePromptSuffix(e.target.value);
}}
placeholder="Text appended to the task description when agent starts..."
rows={3}
disabled={readOnly}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${step.id}-color`}>Color</Label>
<Select
value={step.color}
onValueChange={(value) => {
if (readOnly) return;
onUpdate({ color: value });
}}
disabled={readOnly}
>
<SelectTrigger className="w-[180px]">
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded ${step.color}`} />
<SelectValue placeholder="Select color" />
</div>
</SelectTrigger>
<SelectContent>
{STEP_COLORS.map((color) => (
<SelectItem key={color.value} value={color.value}>
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded ${color.value}`} />
{color.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
</CardContent>
</Card>
);
}
// Wrapper component that uses key to reset local state when step data changes externally
function SortableStepCard({ step, onUpdate, onRemove, readOnly = false }: SortableStepCardProps) {
// Generate a stable key based on step values that should trigger a reset
// This resets local state when the step is reordered or updated externally
const resetKey = `${step.id}-${step.name}-${step.behaviors?.promptPrefix ?? ''}-${step.behaviors?.promptSuffix ?? ''}`;
return <SortableStepCardInner key={resetKey} step={step} onUpdate={onUpdate} onRemove={onRemove} readOnly={readOnly} />;
}
export function WorkflowStepEditor({
steps,
onUpdateStep,
onAddStep,
onRemoveStep,
onReorderSteps,
readOnly = false,
}: WorkflowStepEditorProps) {
const stepItems = useMemo(() => steps.map((step) => step.id), [steps]);
const isMounted = useSyncExternalStore(
() => () => {},
() => true,
() => false
);
const handleDragEnd = (event: DragEndEvent) => {
if (readOnly) return;
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = steps.findIndex((step) => step.id === active.id);
const newIndex = steps.findIndex((step) => step.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const nextSteps = arrayMove(steps, oldIndex, newIndex).map((step, index) => ({
...step,
position: index,
}));
onReorderSteps(nextSteps);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Workflow Steps</h3>
<Button variant="outline" size="sm" onClick={onAddStep} disabled={readOnly}>
<IconPlus className="mr-2 h-4 w-4" />
Add Step
</Button>
</div>
<div className="space-y-2">
{isMounted ? (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={stepItems}>
{steps.map((step) => (
<SortableStepCard
key={step.id}
step={step}
onUpdate={(updates) => {
if (readOnly) return;
onUpdateStep(step.id, updates);
}}
onRemove={() => {
if (readOnly) return;
onRemoveStep(step.id);
}}
readOnly={readOnly}
/>
))}
</SortableContext>
</DndContext>
) : (
steps.map((step) => (
<SortableStepCard
key={step.id}
step={step}
onUpdate={(updates) => {
if (readOnly) return;
onUpdateStep(step.id, updates);
}}
onRemove={() => {
if (readOnly) return;
onRemoveStep(step.id);
}}
readOnly={readOnly}
/>
))
)}
</div>
</div>
);
}
+102 -36
View File
@@ -48,6 +48,8 @@ import { STORAGE_KEYS } from '@/lib/settings/constants';
import { linkToSession } from '@/lib/links';
import { getExecutorIcon } from '@/lib/executor-icons';
import { AgentLogo } from '@/components/agent-logo';
import { listWorkflowSteps } from '@/lib/api/domains/workflow-api';
import { WorkflowSelectorRow } from '@/components/workflow-selector-row';
import { useDockviewStore } from '@/lib/state/dockview-store';
import { useContextFilesStore } from '@/lib/state/context-files-store';
@@ -57,9 +59,9 @@ interface TaskCreateDialogProps {
onOpenChange: (open: boolean) => void;
mode?: 'create' | 'edit' | 'session';
workspaceId: string | null;
boardId: string | null;
defaultColumnId: string | null;
columns: Array<{ id: string; title: string; autoStartAgent?: boolean }>;
workflowId: string | null;
defaultStepId: string | null;
steps: Array<{ id: string; title: string; events?: { on_enter?: Array<{ type: string; config?: Record<string, unknown> }>; on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }> } }>;
editingTask?: { id: string; title: string; description?: string; workflowStepId: string; state?: Task['state']; repositoryId?: string } | null;
onSuccess?: (task: Task, mode: 'create' | 'edit', meta?: { taskSessionId?: string | null }) => void;
onCreateSession?: (data: {
@@ -291,6 +293,7 @@ type TaskFormInputsProps = {
onDescriptionChange: (hasContent: boolean) => void;
onKeyDown: (e: React.KeyboardEvent) => void;
descriptionValueRef: React.RefObject<{ getValue: () => string } | null>;
disabled?: boolean;
};
const TaskFormInputs = memo(function TaskFormInputs({
@@ -300,6 +303,7 @@ const TaskFormInputs = memo(function TaskFormInputs({
onDescriptionChange,
onKeyDown,
descriptionValueRef,
disabled,
}: TaskFormInputsProps) {
const [description, setDescription] = useState(initialDescription);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -348,6 +352,7 @@ const TaskFormInputs = memo(function TaskFormInputs({
rows={2}
className={isSessionMode ? 'min-h-[120px] max-h-[240px] resize-none overflow-auto' : 'min-h-[96px] max-h-[240px] resize-y overflow-auto'}
required={isSessionMode}
disabled={disabled}
/>
</div>
);
@@ -358,9 +363,9 @@ export function TaskCreateDialog({
onOpenChange,
mode = 'create',
workspaceId,
boardId,
defaultColumnId,
columns,
workflowId,
defaultStepId,
steps: _steps,
editingTask,
onSuccess,
onCreateSession,
@@ -371,6 +376,8 @@ export function TaskCreateDialog({
const isSessionMode = mode === 'session';
const isEditMode = mode === 'edit';
const isCreateMode = mode === 'create';
// A task is "started" if it has progressed beyond initial states
const isTaskStarted = isEditMode && editingTask?.state != null && editingTask.state !== 'TODO' && editingTask.state !== 'CREATED';
const [isCreatingSession, setIsCreatingSession] = useState(false);
const [isCreatingTask, setIsCreatingTask] = useState(false);
const [taskName, setTaskName] = useState('');
@@ -390,6 +397,9 @@ export function TaskCreateDialog({
const [localBranchesLoading, setLocalBranchesLoading] = useState(false);
const [discoverReposLoading, setDiscoverReposLoading] = useState(false);
const [discoverReposLoaded, setDiscoverReposLoaded] = useState(false);
const [selectedWorkflowId, setSelectedWorkflowId] = useState(workflowId);
const [fetchedSteps, setFetchedSteps] = useState<Array<{ id: string; title: string; events?: { on_enter?: Array<{ type: string; config?: Record<string, unknown> }>; on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }> } }> | null>(null);
const workflows = useAppStore((state) => state.workflows.items);
const workspaces = useAppStore((state) => state.workspaces.items);
const agentProfiles = useAppStore((state) => state.agentProfiles.items);
const executors = useAppStore((state) => state.executors.items);
@@ -399,6 +409,7 @@ export function TaskCreateDialog({
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
const kanbanTasks = useAppStore((state) => state.kanban.tasks);
const reposByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
const snapshots = useAppStore((state) => state.kanbanMulti.snapshots);
const sessionRepoName = useMemo(() => {
if (!isSessionMode) return undefined;
const activeTask = activeTaskId ? kanbanTasks.find((t) => t.id === activeTaskId) : null;
@@ -420,6 +431,33 @@ export function TaskCreateDialog({
const agentProfilesLoading = open && !settingsData.agentsLoaded;
const executorsLoading = open && !settingsData.executorsLoaded;
// Effective workflow/steps: use fetched steps when a different workflow is selected
const effectiveWorkflowId = selectedWorkflowId ?? workflowId;
const effectiveDefaultStepId = (selectedWorkflowId && selectedWorkflowId !== workflowId && fetchedSteps)
? fetchedSteps[0]?.id ?? null
: defaultStepId;
// Fetch steps when selectedWorkflowId changes to a different workflow
useEffect(() => {
if (!selectedWorkflowId || selectedWorkflowId === workflowId) {
setFetchedSteps(null);
return;
}
let cancelled = false;
listWorkflowSteps(selectedWorkflowId).then((response) => {
if (cancelled) return;
const sorted = [...response.steps].sort((a, b) => a.position - b.position);
setFetchedSteps(sorted.map((s) => ({
id: s.id,
title: s.name,
events: s.events,
})));
}).catch(() => {
if (!cancelled) setFetchedSteps(null);
});
return () => { cancelled = true; };
}, [selectedWorkflowId, workflowId]);
useEffect(() => {
if (!open) return;
const name = initialValues?.title || '';
@@ -431,12 +469,15 @@ export function TaskCreateDialog({
setAgentProfileId('');
setEnvironmentId('');
setExecutorId('');
setSelectedWorkflowId(workflowId);
setFetchedSteps(null);
}, [
initialValues?.branch,
initialValues?.description,
initialValues?.repositoryId,
initialValues?.title,
open,
workflowId,
]);
useEffect(() => {
@@ -682,6 +723,10 @@ export function TaskCreateDialog({
return null;
}, [executors, executorId]);
const handleWorkflowChange = useCallback((value: string) => {
setSelectedWorkflowId(value);
}, []);
useEffect(() => {
if (!open || !workspaceId || !selectedLocalRepo) return;
setLocalBranchesLoading(true);
@@ -767,7 +812,9 @@ export function TaskCreateDialog({
setAgentProfileId('');
setEnvironmentId('');
setExecutorId('');
}, []);
setSelectedWorkflowId(workflowId);
setFetchedSteps(null);
}, [workflowId]);
const getRepositoriesPayload = useCallback(() => {
if (repositoryId) {
@@ -853,7 +900,6 @@ export function TaskCreateDialog({
let taskSessionId: string | null = null;
if (agentProfileId) {
const autoStartStep = columns.find((column) => column.autoStartAgent);
const client = getWebSocketClient();
if (client) {
try {
@@ -864,7 +910,6 @@ export function TaskCreateDialog({
agent_profile_id: agentProfileId,
executor_id: executorId,
prompt: trimmedDescription || '',
...(autoStartStep && { workflow_step_id: autoStartStep.id }),
},
15000,
);
@@ -883,7 +928,7 @@ export function TaskCreateDialog({
setIsCreatingTask(false);
onOpenChange(false);
}
}, [performTaskUpdate, agentProfileId, executorId, columns, onSuccess, resetForm, onOpenChange, toast]);
}, [performTaskUpdate, agentProfileId, executorId, onSuccess, resetForm, onOpenChange, toast]);
const handleUpdateWithoutAgent = useCallback(async () => {
setIsCreatingTask(true);
@@ -903,11 +948,9 @@ export function TaskCreateDialog({
const handleCreateSubmit = useCallback(async () => {
const trimmedTitle = taskName.trim();
if (!trimmedTitle) return;
if (!workspaceId || !boardId) return;
if (!workspaceId || !effectiveWorkflowId) return;
if (!repositoryId && !selectedLocalRepo) return;
if (!agentProfileId) return;
const columnId = editingTask?.workflowStepId ?? defaultColumnId;
if (!columnId) return;
const description = descriptionInputRef.current?.getValue() ?? '';
const trimmedDescription = description.trim();
@@ -916,14 +959,11 @@ export function TaskCreateDialog({
setIsCreatingTask(true);
try {
if (trimmedDescription) {
// "Start" mode: create task + start agent in background, stay on board
const autoStartStep = columns.find((column) => column.autoStartAgent);
const targetColumnId = autoStartStep?.id ?? columnId;
// "Start" mode: create task + start agent in background, stay on kanban
// Backend resolves the start step automatically
const taskResponse = await createTask({
workspace_id: workspaceId,
board_id: boardId,
workflow_step_id: targetColumnId,
workflow_id: effectiveWorkflowId,
title: trimmedTitle,
description: trimmedDescription,
repositories: repositoriesPayload,
@@ -938,10 +978,10 @@ export function TaskCreateDialog({
onOpenChange(false);
} else {
// "Plan" mode: create task + session only (no agent), navigate to session
// Backend resolves the start step automatically
const taskResponse = await createTask({
workspace_id: workspaceId,
board_id: boardId,
workflow_step_id: columnId,
workflow_id: effectiveWorkflowId,
title: trimmedTitle,
description: '',
repositories: repositoriesPayload,
@@ -978,8 +1018,8 @@ export function TaskCreateDialog({
setIsCreatingTask(false);
}
}, [
taskName, workspaceId, boardId, repositoryId, selectedLocalRepo, agentProfileId, executorId,
editingTask, defaultColumnId, columns, getRepositoriesPayload, onSuccess, resetForm, onOpenChange,
taskName, workspaceId, effectiveWorkflowId, repositoryId, selectedLocalRepo, agentProfileId, executorId,
getRepositoriesPayload, onSuccess, resetForm, onOpenChange,
setActiveDocument, setPlanMode, router, toast,
]);
@@ -988,19 +1028,19 @@ export function TaskCreateDialog({
const description = descriptionInputRef.current?.getValue() ?? '';
const trimmedDescription = description.trim();
if (!trimmedTitle || !trimmedDescription) return;
if (!workspaceId || !boardId) return;
if (!workspaceId || !effectiveWorkflowId) return;
if (!repositoryId && !selectedLocalRepo) return;
if (!agentProfileId) return;
const columnId = defaultColumnId;
if (!columnId) return;
const stepId = effectiveDefaultStepId;
if (!stepId) return;
setIsCreatingTask(true);
try {
const reposPayload = getRepositoriesPayload();
const taskResponse = await createTask({
workspace_id: workspaceId,
board_id: boardId,
workflow_step_id: columnId,
workflow_id: effectiveWorkflowId,
workflow_step_id: stepId,
title: trimmedTitle,
description: trimmedDescription,
repositories: reposPayload,
@@ -1017,8 +1057,8 @@ export function TaskCreateDialog({
setIsCreatingTask(false);
}
}, [
taskName, workspaceId, boardId, repositoryId, selectedLocalRepo,
agentProfileId, defaultColumnId, executorId, getRepositoriesPayload,
taskName, workspaceId, effectiveWorkflowId, repositoryId, selectedLocalRepo,
agentProfileId, effectiveDefaultStepId, executorId, getRepositoriesPayload,
onSuccess, onOpenChange, resetForm, toast,
]);
@@ -1042,7 +1082,7 @@ export function TaskCreateDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full rounded-none sm:w-[900px] sm:h-auto sm:max-w-none sm:max-h-[85vh] sm:rounded-lg flex flex-col bg-card">
<DialogContent className="w-full h-full max-w-full max-h-full rounded-none sm:w-[900px] sm:h-auto sm:max-w-none sm:max-h-[85vh] sm:rounded-lg flex flex-col">
<DialogHeader>
{isCreateMode || isEditMode ? (
<DialogTitle asChild>
@@ -1064,7 +1104,7 @@ export function TaskCreateDialog({
? 'Loading repositories...'
: 'No repositories found.'
}
disabled={!workspaceId || repositoriesLoading || discoverReposLoading}
disabled={isTaskStarted || !workspaceId || repositoriesLoading || discoverReposLoading}
triggerClassName="w-auto text-sm"
/>
<span className="text-muted-foreground mr-2">/</span>
@@ -1092,13 +1132,14 @@ export function TaskCreateDialog({
<TaskFormInputs
key={`${open}-${initialValues?.description ?? ''}`}
isSessionMode={isSessionMode}
autoFocus={isEditMode}
autoFocus={isEditMode && !isTaskStarted}
initialDescription={initialValues?.description ?? ''}
onDescriptionChange={setHasDescription}
onKeyDown={handleKeyDown}
descriptionValueRef={descriptionInputRef}
disabled={isTaskStarted}
/>
{!isSessionMode && (
{!isSessionMode && !isTaskStarted && (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-3">
<div>
<BranchSelector
@@ -1166,6 +1207,14 @@ export function TaskCreateDialog({
</div>
</div>
)}
{isCreateMode && workflows.length > 1 && !isTaskStarted && (
<WorkflowSelectorRow
workflows={workflows}
snapshots={snapshots}
selectedWorkflowId={effectiveWorkflowId ?? null}
onWorkflowChange={handleWorkflowChange}
/>
)}
{/* Agent Profile + Executor - shown in session mode */}
{isSessionMode && (
@@ -1189,7 +1238,7 @@ export function TaskCreateDialog({
</div>
<DialogFooter className="border-t border-border pt-3 flex-col gap-3 sm:flex-row sm:gap-2">
{!isSessionMode && executorHint && (
{!isSessionMode && !isTaskStarted && executorHint && (
<div className="flex flex-1 items-center gap-3 text-sm text-muted-foreground">
<span className="text-xs text-muted-foreground">
{executorHint}
@@ -1202,7 +1251,24 @@ export function TaskCreateDialog({
</Button>
</DialogClose>
<KeyboardShortcutTooltip shortcut={SHORTCUTS.SUBMIT}>
{(isCreateMode && hasDescription) || (isEditMode && agentProfileId) ? (
{isTaskStarted ? (
<Button
type="button"
variant="default"
className="w-full h-10 cursor-pointer sm:w-auto sm:h-7 gap-1.5"
disabled={isCreatingTask || !hasTitle}
onClick={handleUpdateWithoutAgent}
>
{isCreatingTask ? (
<>
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
Updating...
</>
) : (
'Update'
)}
</Button>
) : (isCreateMode && hasDescription) || (isEditMode && agentProfileId) ? (
<div className="inline-flex rounded-md border border-border overflow-hidden sm:h-7 h-10">
<Button
type="submit"
@@ -394,9 +394,9 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
onOpenChange={setShowNewSessionDialog}
mode="session"
workspaceId={null}
boardId={null}
defaultColumnId={null}
columns={[]}
workflowId={null}
defaultStepId={null}
steps={[]}
taskId={taskId}
initialValues={{
title: taskTitle ?? '',
@@ -45,6 +45,16 @@ export const VirtualizedMessageList = memo(function VirtualizedMessageList({
const previousWidthRef = useRef(0);
/** True while the panel is hidden (0 dimensions) to avoid stale scroll updates */
const isHiddenRef = useRef(false);
/**
* Scroll snapshot continuously updated by the scroll handler.
* When the panel goes hidden, the browser resets scrollTop to 0 BEFORE
* ResizeObserver fires, so we can't read scroll state at that point.
* Instead, we always have the latest good state from the scroll handler.
*/
const scrollSnapshot = useRef<{ wasAtBottom: boolean; firstVisibleIndex: number }>({
wasAtBottom: true,
firstVisibleIndex: 0,
});
const isInitialLoading = messagesLoading && messages.length === 0;
const isCreatedSession = sessionState === 'CREATED';
@@ -78,7 +88,13 @@ export const VirtualizedMessageList = memo(function VirtualizedMessageList({
const loadingRef = useRef({ hasMore, isLoadingMore });
loadingRef.current = { hasMore, isLoadingMore };
// Combined scroll handler: check if at bottom AND trigger lazy loading at top
// Keep a ref so the ResizeObserver closure always has the current virtualizer
const virtualizerRef = useRef(virtualizer);
virtualizerRef.current = virtualizer;
// Combined scroll handler: check if at bottom AND trigger lazy loading at top.
// Also continuously updates scrollSnapshot so we always have the latest good
// scroll state before the browser resets it on display:none.
const handleScroll = useCallback(() => {
const element = messagesContainerRef.current;
if (!element) return;
@@ -89,10 +105,18 @@ export const VirtualizedMessageList = memo(function VirtualizedMessageList({
const { scrollTop, scrollHeight, clientHeight } = element;
// Track if we're at the bottom for auto-scroll behavior (100px threshold)
// User is considered "at bottom" if within 100px of the bottom
wasAtBottomRef.current = scrollHeight - scrollTop - clientHeight < 100;
savedScrollTopRef.current = scrollTop;
// Continuously save snapshot for panel hide/show restoration.
// This runs on every scroll so we always have pre-reset values.
const virt = virtualizerRef.current;
const visibleItems = virt.getVirtualItems();
scrollSnapshot.current = {
wasAtBottom: wasAtBottomRef.current,
firstVisibleIndex: visibleItems.length > 0 ? visibleItems[0].index : 0,
};
// Trigger lazy load when scrolled near top
if (scrollTop < 40 && loadingRef.current.hasMore && !loadingRef.current.isLoadingMore) {
const prevScrollHeight = scrollHeight;
@@ -114,10 +138,6 @@ export const VirtualizedMessageList = memo(function VirtualizedMessageList({
return () => element.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
// Keep a ref so the ResizeObserver closure always has the current virtualizer
const virtualizerRef = useRef(virtualizer);
virtualizerRef.current = virtualizer;
// Maintain scroll position at bottom when container resizes (e.g., chat input grows)
useEffect(() => {
const element = messagesContainerRef.current;
@@ -130,39 +150,41 @@ export const VirtualizedMessageList = memo(function VirtualizedMessageList({
const previousHeight = previousHeightRef.current;
const previousWidth = previousWidthRef.current;
// Panel being hidden by dockview — mark as hidden, don't update refs
// Panel being hidden by dockview — mark as hidden.
// Don't read scroll state here: the browser already reset scrollTop to 0.
// We use scrollSnapshot which was saved by the scroll handler.
if (currentWidth === 0 || currentHeight === 0) {
isHiddenRef.current = true;
return;
}
// Panel becoming visible again — restore scroll position
// Panel becoming visible again — restore scroll position from snapshot
if (isHiddenRef.current) {
isHiddenRef.current = false;
previousHeightRef.current = currentHeight;
previousWidthRef.current = currentWidth;
// Invalidate stale measurements from when the panel was display:none,
// then use the virtualizer's scrollToIndex which persists across
// re-measurement cycles (raw scrollTop gets overwritten by the
// virtualizer as it re-measures items after becoming visible).
const virt = virtualizerRef.current;
const snapshot = scrollSnapshot.current;
virt.measure();
requestAnimationFrame(() => {
if (wasAtBottomRef.current) {
if (snapshot.wasAtBottom) {
const count = virt.options.count;
if (count > 0) {
virt.scrollToIndex(count - 1, { align: 'end' });
}
} else {
element.scrollTop = savedScrollTopRef.current;
virt.scrollToIndex(snapshot.firstVisibleIndex, { align: 'start' });
}
});
return;
}
// If width genuinely changed, invalidate cached row measurements (text rewraps)
if (previousWidth > 0 && currentWidth !== previousWidth) {
// If width genuinely changed, invalidate cached row measurements (text rewraps).
// Ignore small changes (< 20px) caused by scrollbar appearing/disappearing —
// measure() resets to estimated sizes which can toggle the scrollbar, creating
// an infinite resize → measure → resize feedback loop.
if (previousWidth > 0 && Math.abs(currentWidth - previousWidth) > 20) {
virtualizerRef.current.measure();
}
@@ -85,7 +85,7 @@ const LAYOUT_STORAGE_KEY = 'dockview-layout-v1';
function SidebarPanel(props: IDockviewPanelProps) {
const workspaceId = useAppStore((state) => state.workspaces.activeId);
const boardId = useAppStore((state) => state.boards.activeId);
const workflowId = useAppStore((state) => state.workflows.activeId);
const workspaceName = useAppStore((state) => {
const ws = state.workspaces.items.find((w: { id: string }) => w.id === workspaceId);
return ws?.name ?? 'Workspace';
@@ -98,7 +98,7 @@ function SidebarPanel(props: IDockviewPanelProps) {
}
}, [props.api, workspaceName]);
return <TaskSessionSidebar workspaceId={workspaceId} boardId={boardId} />;
return <TaskSessionSidebar workspaceId={workspaceId} workflowId={workflowId} />;
}
function ChatPanel(props: IDockviewPanelProps) {
@@ -288,16 +288,16 @@ function RightHeaderActions(props: IDockviewHeaderActionsProps) {
function SidebarRightActions() {
const workspaceId = useAppStore((state) => state.workspaces.activeId);
const boardId = useAppStore((state) => state.boards.activeId);
const workflowId = useAppStore((state) => state.workflows.activeId);
const kanban = useAppStore((state) => state.kanban);
const setActiveTask = useAppStore((state) => state.setActiveTask);
const setActiveSession = useAppStore((state) => state.setActiveSession);
const appStore = useAppStoreApi();
const columns = (kanban?.steps ?? []).map((s: { id: string; title: string; color?: string; autoStartAgent?: boolean }) => ({
const steps = (kanban?.steps ?? []).map((s: { id: string; title: string; color?: string; events?: { on_enter?: Array<{ type: string; config?: Record<string, unknown> }>; on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }> } }) => ({
id: s.id,
title: s.title,
color: s.color,
autoStartAgent: s.autoStartAgent,
events: s.events,
}));
const handleTaskCreated = useCallback(
@@ -317,8 +317,8 @@ function SidebarRightActions() {
<div className="flex items-center pr-2">
<NewTaskButton
workspaceId={workspaceId}
boardId={boardId}
columns={columns}
workflowId={workflowId}
steps={steps}
onSuccess={handleTaskCreated}
/>
</div>
@@ -482,7 +482,7 @@ function TerminalGroupRightActions() {
// --- MAIN LAYOUT COMPONENT ---
type DockviewDesktopLayoutProps = {
workspaceId: string | null;
boardId: string | null;
workflowId: string | null;
sessionId?: string | null;
repository?: Repository | null;
initialScripts?: RepositoryScript[];
@@ -189,9 +189,9 @@ const ImplementPlanDropdown = memo(function ImplementPlanDropdown({
onOpenChange={setShowNewSessionDialog}
mode="session"
workspaceId={null}
boardId={null}
defaultColumnId={null}
columns={[]}
workflowId={null}
defaultStepId={null}
steps={[]}
taskId={taskId}
initialValues={{
title: task?.title ?? '',
@@ -15,7 +15,7 @@ import { useSessionLayoutState } from '@/hooks/use-session-layout-state';
type SessionMobileLayoutProps = {
workspaceId: string | null;
boardId: string | null;
workflowId: string | null;
sessionId?: string | null;
baseBranch?: string;
worktreeBranch?: string | null;
@@ -24,7 +24,7 @@ type SessionMobileLayoutProps = {
export const SessionMobileLayout = memo(function SessionMobileLayout({
workspaceId,
boardId,
workflowId,
sessionId,
baseBranch,
worktreeBranch,
@@ -164,7 +164,7 @@ export const SessionMobileLayout = memo(function SessionMobileLayout({
open={isTaskSwitcherOpen}
onOpenChange={setMobileSessionTaskSwitcherOpen}
workspaceId={workspaceId}
boardId={boardId}
workflowId={workflowId}
/>
</div>
);
@@ -26,7 +26,7 @@ const DEFAULT_PREVIEW_LAYOUT: Record<string, number> = {
type SessionTabletLayoutProps = {
workspaceId: string | null;
boardId: string | null;
workflowId: string | null;
sessionId?: string | null;
repository?: Repository | null;
defaultLayouts?: Record<string, Layout>;
@@ -34,7 +34,7 @@ type SessionTabletLayoutProps = {
export const SessionTabletLayout = memo(function SessionTabletLayout({
workspaceId,
boardId,
workflowId,
sessionId = null,
repository = null,
defaultLayouts = {},
@@ -144,7 +144,7 @@ export const SessionTabletLayout = memo(function SessionTabletLayout({
open={isTaskSwitcherOpen}
onOpenChange={setMobileSessionTaskSwitcherOpen}
workspaceId={workspaceId}
boardId={boardId}
workflowId={workflowId}
/>
</div>
);
@@ -11,34 +11,33 @@ import {
import { Button } from '@kandev/ui/button';
import { TaskSwitcher } from '../task-switcher';
import { WorkspaceSwitcher } from '../workspace-switcher';
import { BoardSwitcher } from '../board-switcher';
import { TaskCreateDialog } from '@/components/task-create-dialog';
import { useAppStore, useAppStoreApi } from '@/components/state-provider';
import { linkToSession } from '@/lib/links';
import { listTaskSessions, fetchBoardSnapshot, listBoards } from '@/lib/api';
import { listTaskSessions, fetchWorkflowSnapshot, listWorkflows } from '@/lib/api';
import { useTasks } from '@/hooks/use-tasks';
import { useTaskActions } from '@/hooks/use-task-actions';
import type { TaskState, Workspace, Repository, TaskSession, BoardSnapshot, Task } from '@/lib/types/http';
import type { TaskState, Workspace, Repository, TaskSession, Task, WorkflowSnapshot } from '@/lib/types/http';
import type { KanbanState } from '@/lib/state/slices';
type SessionTaskSwitcherSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
workspaceId: string | null;
boardId: string | null;
workflowId: string | null;
};
// Helper to map board snapshot to kanban state
function mapSnapshotToKanban(snapshot: BoardSnapshot, newBoardId: string) {
// Helper to map workflow snapshot to kanban state
function mapSnapshotToKanban(snapshot: WorkflowSnapshot, newWorkflowId: string) {
return {
boardId: newBoardId,
workflowId: newWorkflowId,
isLoading: false,
steps: snapshot.steps.map((step) => ({
id: step.id,
title: step.name,
color: step.color,
position: step.position,
autoStartAgent: step.auto_start_agent,
events: step.events,
})),
tasks: snapshot.tasks.map((task) => ({
id: task.id,
@@ -69,7 +68,7 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
open,
onOpenChange,
workspaceId,
boardId,
workflowId,
}: SessionTaskSwitcherSheetProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [deletingTaskId, setDeletingTaskId] = useState<string | null>(null);
@@ -79,9 +78,8 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
const sessionsById = useAppStore((state) => state.taskSessions.items);
const sessionsByTaskId = useAppStore((state) => state.taskSessionsByTask.itemsByTaskId);
const gitStatusBySessionId = useAppStore((state) => state.gitStatus.bySessionId);
const { tasks } = useTasks(boardId);
const columns = useAppStore((state) => state.kanban.steps);
const boards = useAppStore((state) => state.boards.items);
const { tasks } = useTasks(workflowId);
const steps = useAppStore((state) => state.kanban.steps);
const workspaces = useAppStore((state) => state.workspaces.items);
const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
const setActiveTask = useAppStore((state) => state.setActiveTask);
@@ -127,12 +125,6 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
[sessionsByTaskId, gitStatusBySessionId]
);
// Get boards for the current workspace
const workspaceBoards = useMemo(() => {
if (!workspaceId) return [];
return boards.filter((board: { id: string; workspaceId: string; name: string }) => board.workspaceId === workspaceId);
}, [boards, workspaceId]);
const tasksWithRepositories = useMemo(() => {
const repositories = workspaceId ? repositoriesByWorkspace[workspaceId] ?? [] : [];
const repositoryPathsById = new Map(repositories.map((repo: Repository) => [repo.id, repo.local_path]));
@@ -256,47 +248,6 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
[deleteTaskById, store, setActiveSession, setActiveTask, loadTaskSessionsForTask]
);
const handleBoardChange = useCallback(
async (newBoardId: string) => {
if (newBoardId === boardId) return;
store.setState((state) => ({
...state,
kanban: { ...state.kanban, isLoading: true },
}));
try {
const snapshot = await fetchBoardSnapshot(newBoardId);
store.setState((state) => ({
...state,
kanban: mapSnapshotToKanban(snapshot, newBoardId),
boards: { ...state.boards, activeId: newBoardId },
}));
const mostRecentTask = sortByUpdatedAtDesc(snapshot.tasks)[0];
if (mostRecentTask) {
const sessions = await loadTaskSessionsForTask(mostRecentTask.id);
const mostRecentSession = sortByUpdatedAtDesc(sessions)[0];
if (mostRecentSession) {
setActiveSession(mostRecentTask.id, mostRecentSession.id);
updateUrl(mostRecentSession.id);
} else {
setActiveTask(mostRecentTask.id);
}
}
onOpenChange(false);
} catch (error) {
console.error('Failed to switch board:', error);
store.setState((state) => ({
...state,
kanban: { ...state.kanban, isLoading: false },
}));
}
},
[boardId, store, loadTaskSessionsForTask, setActiveSession, setActiveTask, updateUrl, onOpenChange]
);
const handleWorkspaceChange = useCallback(
async (newWorkspaceId: string) => {
if (newWorkspaceId === workspaceId) return;
@@ -307,11 +258,11 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
}));
try {
const boardsResponse = await listBoards(newWorkspaceId, { cache: 'no-store' });
const newWorkspaceBoards = boardsResponse.boards ?? [];
const workflowsResponse = await listWorkflows(newWorkspaceId, { cache: 'no-store' });
const newWorkspaceWorkflows = workflowsResponse.workflows ?? [];
const firstBoard = newWorkspaceBoards[0];
if (!firstBoard) {
const firstWorkflow = newWorkspaceWorkflows[0];
if (!firstWorkflow) {
store.setState((state) => ({
...state,
kanban: { ...state.kanban, isLoading: false },
@@ -319,19 +270,19 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
return;
}
const snapshot = await fetchBoardSnapshot(firstBoard.id);
const snapshot = await fetchWorkflowSnapshot(firstWorkflow.id);
store.setState((state) => ({
...state,
boards: {
...state.boards,
workflows: {
...state.workflows,
items: [
...state.boards.items.filter((b: { workspaceId: string }) => b.workspaceId !== newWorkspaceId),
...newWorkspaceBoards.map((b) => ({ id: b.id, workspaceId: b.workspace_id, name: b.name })),
...state.workflows.items.filter((w: { workspaceId: string }) => w.workspaceId !== newWorkspaceId),
...newWorkspaceWorkflows.map((w) => ({ id: w.id, workspaceId: w.workspace_id, name: w.name })),
],
activeId: firstBoard.id,
activeId: firstWorkflow.id,
},
kanban: mapSnapshotToKanban(snapshot, firstBoard.id),
kanban: mapSnapshotToKanban(snapshot, firstWorkflow.id),
}));
const mostRecentTask = sortByUpdatedAtDesc(snapshot.tasks)[0];
@@ -357,20 +308,20 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
[workspaceId, store, loadTaskSessionsForTask, setActiveSession, setActiveTask, updateUrl, onOpenChange]
);
const dialogColumns = useMemo(
() => columns.map((step: KanbanState['steps'][number]) => ({
const dialogSteps = useMemo(
() => steps.map((step: KanbanState['steps'][number]) => ({
id: step.id,
title: step.title,
color: step.color,
autoStartAgent: step.autoStartAgent,
events: step.events,
})),
[columns]
[steps]
);
const handleTaskCreated = useCallback(
(task: Task, _mode: 'create' | 'edit', meta?: { taskSessionId?: string | null }) => {
store.setState((state) => {
if (state.kanban.boardId !== task.board_id) return state;
if (state.kanban.workflowId !== task.workflow_id) return state;
const nextTask = {
id: task.id,
workflowStepId: task.workflow_step_id,
@@ -429,7 +380,7 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
<div className="flex-1 min-h-0 overflow-y-auto p-2">
<TaskSwitcher
tasks={tasksWithRepositories}
columns={columns.map((step: KanbanState['steps'][number]) => ({ id: step.id, title: step.title, color: step.color }))}
steps={steps.map((step: KanbanState['steps'][number]) => ({ id: step.id, title: step.title, color: step.color }))}
activeTaskId={activeTaskId}
selectedTaskId={selectedTaskId}
onSelectTask={handleSelectTask}
@@ -439,16 +390,6 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
/>
</div>
{/* Board Switcher at bottom */}
{workspaceBoards.length > 1 && (
<div className="p-2 border-t border-border">
<BoardSwitcher
boards={workspaceBoards.map((b: { id: string; name: string }) => ({ id: b.id, name: b.name }))}
activeBoardId={boardId}
onSelect={handleBoardChange}
/>
</div>
)}
</SheetContent>
<TaskCreateDialog
@@ -456,9 +397,9 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
onOpenChange={setDialogOpen}
mode="create"
workspaceId={workspaceId}
boardId={boardId}
defaultColumnId={dialogColumns[0]?.id ?? null}
columns={dialogColumns}
workflowId={workflowId}
defaultStepId={dialogSteps[0]?.id ?? null}
steps={dialogSteps}
onSuccess={handleTaskCreated}
/>
</Sheet>
@@ -276,9 +276,9 @@ export const SessionsDropdown = memo(function SessionsDropdown({
onOpenChange={setShowNewSessionDialog}
mode="session"
workspaceId={null}
boardId={null}
defaultColumnId={null}
columns={[]}
workflowId={null}
defaultStepId={null}
steps={[]}
taskId={taskId}
initialValues={{
title: taskTitle,
@@ -89,7 +89,7 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
}
// Check if the new step has auto_start_agent enabled
if (response?.workflow_step?.auto_start_agent) {
if (response?.workflow_step?.events?.on_enter?.some((a: { type: string }) => a.type === 'auto_start_agent')) {
const client = getWebSocketClient();
if (client) {
client.send({
+1 -1
View File
@@ -30,7 +30,7 @@ export function TaskItemMenu({
isDeleting,
}: TaskItemMenuProps) {
return (
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<DropdownMenu open={open} onOpenChange={onOpenChange} modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
+37 -46
View File
@@ -1,7 +1,7 @@
'use client';
import { memo, useState } from 'react';
import { IconArchive, IconLoader2 } from '@tabler/icons-react';
import { IconLoader2 } from '@tabler/icons-react';
import { cn } from '@/lib/utils';
import type { TaskState } from '@/lib/types/http';
import { TaskItemMenu } from './task-item-menu';
@@ -14,12 +14,12 @@ type DiffStats = {
type TaskItemProps = {
title: string;
description?: string;
stepName?: string;
state?: TaskState;
isSelected?: boolean;
onClick?: () => void;
diffStats?: DiffStats;
updatedAt?: string;
onArchive?: () => void;
onRename?: () => void;
onDuplicate?: () => void;
onReview?: () => void;
@@ -47,12 +47,12 @@ function formatRelativeTime(dateString: string): string {
export const TaskItem = memo(function TaskItem({
title,
description,
stepName,
state,
isSelected = false,
onClick,
diffStats,
updatedAt,
onArchive,
onRename,
onDuplicate,
onReview,
@@ -67,24 +67,23 @@ export const TaskItem = memo(function TaskItem({
const isInProgress = state === 'IN_PROGRESS' || state === 'SCHEDULING';
const hasDiffStats = diffStats && (diffStats.additions > 0 || diffStats.deletions > 0);
// Determine what to show on the right side
const renderRightContent = () => {
// Determine what to show on the right side (below step name)
const renderRightMeta = () => {
if (isInProgress) {
return (
<IconLoader2 className="h-4 w-4 text-blue-500 animate-spin" />
);
return <IconLoader2 className="h-3.5 w-3.5 text-blue-500 animate-spin" />;
}
if (hasDiffStats) {
return (
<div className="flex items-center gap-1 text-xs font-mono rounded-md border border-border/50 px-1.5 py-0.5">
<span className="text-[11px] font-mono text-muted-foreground">
<span className="text-emerald-500">+{diffStats.additions}</span>
{' '}
<span className="text-rose-500">-{diffStats.deletions}</span>
</div>
</span>
);
}
if (updatedAt) {
return (
<span className="text-xs text-muted-foreground">
<span className="text-[11px] text-muted-foreground/60">
{formatRelativeTime(updatedAt)}
</span>
);
@@ -104,50 +103,59 @@ export const TaskItem = memo(function TaskItem({
}
}}
className={cn(
'group relative flex w-full items-start gap-2 rounded-md border p-2 text-left text-sm outline-none cursor-pointer',
'transition-all duration-100',
isSelected
? 'bg-primary/10 border-primary/30'
: 'bg-background/50 border-border hover:bg-background/80 hover:border-border'
'group relative flex w-full items-center gap-2 px-3 py-2 text-left text-sm outline-none cursor-pointer',
'transition-colors duration-75',
'hover:bg-foreground/[0.05]',
isSelected && 'bg-primary/10'
)}
>
{/* Selection indicator */}
<div
className={cn(
'absolute left-0 top-0 bottom-0 w-[2px] transition-opacity',
isSelected ? 'bg-primary opacity-100' : 'opacity-0'
)}
/>
{/* Content */}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="line-clamp-2 min-w-0 font-medium font-sans text-foreground">
<div className="flex min-w-0 flex-1 flex-col">
<span className="line-clamp-1 min-w-0 text-[13px] font-medium text-foreground">
{title}
</span>
{description && (
<span className="flex items-center gap-1.5 text-xs text-foreground/60 truncate">
<span className="text-[11px] text-muted-foreground/60 truncate">
{description}
</span>
)}
</div>
{/* Right side: Spinner / Diff stats / Time (default) or Action buttons (on hover) */}
<div className="relative flex items-center shrink-0 self-stretch">
{/* Default content - visible by default, hidden on hover */}
{/* Right side: step name + meta, or action buttons on hover */}
<div className="relative flex items-center shrink-0">
<div
className={cn(
'transition-opacity duration-150',
!effectiveMenuOpen && 'group-hover:opacity-0'
'flex flex-col items-end gap-0.5 transition-opacity duration-100',
effectiveMenuOpen ? 'opacity-0' : 'group-hover:opacity-0'
)}
>
{renderRightContent()}
{stepName && (
<span className="text-[10px] text-muted-foreground/70 bg-foreground/[0.06] px-1.5 py-px rounded-[6px]">
{stepName}
</span>
)}
{renderRightMeta()}
</div>
{/* Action buttons - hidden by default, visible on hover */}
<div
className={cn(
'absolute right-0 top-0 bottom-0 flex flex-col justify-center gap-0.5',
'transition-opacity duration-150',
'absolute right-0 flex items-center gap-0.5',
'transition-opacity duration-100',
effectiveMenuOpen ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
)}
>
{/* 3-dot menu button */}
<TaskItemMenu
open={effectiveMenuOpen}
onOpenChange={(open) => {
// Prevent closing while deleting
if (!open && isDeleting) return;
setMenuOpen(open);
}}
@@ -157,23 +165,6 @@ export const TaskItem = memo(function TaskItem({
onDelete={onDelete}
isDeleting={isDeleting}
/>
{/* Archive button */}
<button
type="button"
className={cn(
'flex h-6 w-6 items-center justify-center rounded-md cursor-pointer',
'text-muted-foreground hover:text-foreground hover:bg-foreground/10',
'transition-colors'
)}
onClick={(e) => {
e.stopPropagation();
onArchive?.();
}}
aria-label="Archive task"
>
<IconArchive className="h-4 w-4" />
</button>
</div>
</div>
</div>
+5 -5
View File
@@ -19,7 +19,7 @@ const DockviewDesktopLayout = dynamic(
type TaskLayoutProps = {
workspaceId: string | null;
boardId: string | null;
workflowId: string | null;
sessionId?: string | null;
repository?: Repository | null;
initialScripts?: RepositoryScript[];
@@ -32,7 +32,7 @@ type TaskLayoutProps = {
export const TaskLayout = memo(function TaskLayout({
workspaceId,
boardId,
workflowId,
sessionId = null,
repository = null,
initialScripts = [],
@@ -49,7 +49,7 @@ export const TaskLayout = memo(function TaskLayout({
return (
<SessionMobileLayout
workspaceId={workspaceId}
boardId={boardId}
workflowId={workflowId}
sessionId={sessionId}
baseBranch={baseBranch}
worktreeBranch={worktreeBranch}
@@ -63,7 +63,7 @@ export const TaskLayout = memo(function TaskLayout({
return (
<SessionTabletLayout
workspaceId={workspaceId}
boardId={boardId}
workflowId={workflowId}
sessionId={sessionId}
repository={repository}
defaultLayouts={defaultLayouts}
@@ -75,7 +75,7 @@ export const TaskLayout = memo(function TaskLayout({
return (
<DockviewDesktopLayout
workspaceId={workspaceId}
boardId={boardId}
workflowId={workflowId}
sessionId={sessionId}
repository={repository}
initialScripts={initialScripts}
+17 -10
View File
@@ -79,9 +79,9 @@ export function TaskPageContent({
// Fallback: construct minimal task from kanban data while full details load
// This allows immediate UI response when switching tasks
if (kanbanTask) {
// Reuse workspace/board from previous task context (same board)
// Reuse workspace/workflow from previous task context (same workflow)
const prevWorkspaceId = taskDetails?.workspace_id ?? initialTask?.workspace_id;
const prevBoardId = taskDetails?.board_id ?? initialTask?.board_id;
const prevBoardId = taskDetails?.workflow_id ?? initialTask?.workflow_id;
return {
id: kanbanTask.id,
title: kanbanTask.title,
@@ -90,7 +90,7 @@ export function TaskPageContent({
position: kanbanTask.position,
state: kanbanTask.state ?? 'CREATED',
workspace_id: prevWorkspaceId ?? '',
board_id: prevBoardId ?? '',
workflow_id: prevBoardId ?? '',
priority: 0,
repositories: [],
created_at: '',
@@ -100,7 +100,12 @@ export function TaskPageContent({
return null;
}, [taskDetails, initialTask, kanbanTask, effectiveTaskId]);
useTasks(task?.board_id ?? null);
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]
);
const connectionStatus = useAppStore((state) => state.connection.status);
// Regular task agent hook for non-resumed sessions
@@ -180,22 +185,22 @@ export function TaskPageContent({
useEffect(() => {
if (!activeTaskId) return;
if (taskDetails?.id === activeTaskId) return;
// Skip fetch if we already have task in kanban state (same board) and we have essential data
// Skip fetch if we already have task in kanban state (same workflow) and we have essential data
// The kanban state already has title, description, state which covers most UI needs
// Only fetch for additional details like repositories, metadata, etc.
if (kanbanTask && taskDetails?.workspace_id && taskDetails?.board_id) {
// We have kanban data and can reuse workspace/board from current context
if (kanbanTask && taskDetails?.workspace_id && taskDetails?.workflow_id) {
// We have kanban data and can reuse workspace/workflow from current context
// Fetch in background without blocking (low priority)
fetchTask(activeTaskId, { cache: 'no-store' })
.then((response) => setTaskDetails(response))
.catch((error) => console.error('[TaskPageContent] Failed to load task details:', error));
return;
}
// Full fetch needed for first load or cross-board navigation
// Full fetch needed for first load or cross-workflow navigation
fetchTask(activeTaskId, { cache: 'no-store' })
.then((response) => setTaskDetails(response))
.catch((error) => console.error('[TaskPageContent] Failed to load task details:', error));
}, [activeTaskId, taskDetails?.id, taskDetails?.workspace_id, taskDetails?.board_id, kanbanTask]);
}, [activeTaskId, taskDetails?.id, taskDetails?.workspace_id, taskDetails?.workflow_id, kanbanTask]);
const { repositories } = useRepositories(task?.workspace_id ?? null, Boolean(task?.workspace_id));
const effectiveRepositories = repositories.length ? repositories : initialRepositories;
const repository = useMemo(
@@ -253,12 +258,14 @@ export function TaskPageContent({
repositoryName={repository?.name ?? null}
showDebugOverlay={showDebugOverlay}
onToggleDebugOverlay={() => setShowDebugOverlay((prev) => !prev)}
workflowSteps={workflowSteps}
currentStepId={task?.workflow_step_id ?? null}
/>
)}
<TaskLayout
workspaceId={task?.workspace_id ?? null}
boardId={task?.board_id ?? null}
workflowId={task?.workflow_id ?? null}
sessionId={effectiveSessionId ?? null}
repository={repository ?? null}
initialScripts={initialScripts}
+81 -148
View File
@@ -1,33 +1,32 @@
'use client';
import { useCallback, useMemo, useState, memo } from 'react';
import type { TaskState, Repository, TaskSession, BoardSnapshot, Task } from '@/lib/types/http';
import type { TaskState, Repository, TaskSession, Task } from '@/lib/types/http';
import type { KanbanState } from '@/lib/state/slices';
import { TaskSwitcher } from './task-switcher';
import { BoardSwitcher } from './board-switcher';
import { Button } from '@kandev/ui/button';
import { PanelRoot, PanelBody } from './panel-primitives';
import { IconPlus } from '@tabler/icons-react';
import { TaskCreateDialog } from '@/components/task-create-dialog';
import { useAppStore, useAppStoreApi } from '@/components/state-provider';
import { linkToSession } from '@/lib/links';
import { listTaskSessions, fetchBoardSnapshot } from '@/lib/api';
import { useTasks } from '@/hooks/use-tasks';
import { listTaskSessions } from '@/lib/api';
import { useAllWorkflowSnapshots } from '@/hooks/domains/kanban/use-all-workflow-snapshots';
import { useTaskActions } from '@/hooks/use-task-actions';
import { performLayoutSwitch } from '@/lib/state/dockview-store';
// Extracted component to isolate dialog state from sidebar
type NewTaskButtonProps = {
workspaceId: string | null;
boardId: string | null;
columns: Array<{ id: string; title: string; color?: string; autoStartAgent?: boolean }>;
workflowId: string | null;
steps: Array<{ id: string; title: string; color?: string; events?: { on_enter?: Array<{ type: string; config?: Record<string, unknown> }>; on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }> } }>;
onSuccess: (task: Task, mode: 'create' | 'edit', meta?: { taskSessionId?: string | null }) => void;
};
export const NewTaskButton = memo(function NewTaskButton({
workspaceId,
boardId,
columns,
workflowId,
steps,
onSuccess,
}: NewTaskButtonProps) {
const [dialogOpen, setDialogOpen] = useState(false);
@@ -48,9 +47,9 @@ export const NewTaskButton = memo(function NewTaskButton({
onOpenChange={setDialogOpen}
mode="create"
workspaceId={workspaceId}
boardId={boardId}
defaultColumnId={columns[0]?.id ?? null}
columns={columns}
workflowId={workflowId}
defaultStepId={steps[0]?.id ?? null}
steps={steps}
onSuccess={onSuccess}
/>
</>
@@ -59,55 +58,17 @@ export const NewTaskButton = memo(function NewTaskButton({
type TaskSessionSidebarProps = {
workspaceId: string | null;
boardId: string | null;
workflowId: string | null;
};
// Helper to map board snapshot to kanban state
function mapSnapshotToKanban(snapshot: BoardSnapshot, newBoardId: string) {
return {
boardId: newBoardId,
isLoading: false,
steps: snapshot.steps.map((step) => ({
id: step.id,
title: step.name,
color: step.color,
position: step.position,
autoStartAgent: step.auto_start_agent,
})),
tasks: snapshot.tasks.map((task) => ({
id: task.id,
workflowStepId: task.workflow_step_id,
title: task.title,
description: task.description ?? undefined,
position: task.position ?? 0,
state: task.state,
repositoryId: task.repositories?.[0]?.repository_id ?? undefined,
primarySessionId: task.primary_session_id ?? undefined,
sessionCount: task.session_count ?? undefined,
reviewStatus: task.review_status ?? undefined,
updatedAt: task.updated_at,
})),
};
}
// Helper to sort by updated_at descending (most recent first)
function sortByUpdatedAtDesc<T extends { updated_at?: string | null }>(items: T[]): T[] {
return [...items].sort((a, b) => {
const aDate = a.updated_at ? new Date(a.updated_at).getTime() : 0;
const bDate = b.updated_at ? new Date(b.updated_at).getTime() : 0;
return bDate - aDate;
});
}
export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workspaceId, boardId }: TaskSessionSidebarProps) {
export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workspaceId, workflowId: _workflowId }: TaskSessionSidebarProps) {
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const sessionsById = useAppStore((state) => state.taskSessions.items);
const sessionsByTaskId = useAppStore((state) => state.taskSessionsByTask.itemsByTaskId);
const gitStatusBySessionId = useAppStore((state) => state.gitStatus.bySessionId);
const { tasks } = useTasks(boardId);
const columns = useAppStore((state) => state.kanban.steps);
const boards = useAppStore((state) => state.boards.items);
const snapshots = useAppStore((state) => state.kanbanMulti.snapshots);
const isMultiLoading = useAppStore((state) => state.kanbanMulti.isLoading);
const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
const setActiveTask = useAppStore((state) => state.setActiveTask);
const setActiveSession = useAppStore((state) => state.setActiveSession);
@@ -119,10 +80,31 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workspaceId
}
return activeTaskId;
}, [activeSessionId, activeTaskId, sessionsById]);
const kanbanBoardId = useAppStore((state) => state.kanban.boardId);
const kanbanIsLoading = useAppStore((state) => state.kanban.isLoading ?? false);
// Consider loading if explicitly loading OR if board IDs mismatch (mid-switch)
const isLoadingBoard = kanbanIsLoading || (boardId !== null && kanbanBoardId !== boardId);
const snapshotKeys = Object.keys(snapshots);
const isLoadingWorkflow = isMultiLoading && snapshotKeys.length === 0;
// Load all workflow snapshots for the workspace
useAllWorkflowSnapshots(workspaceId);
// Merge tasks and steps from all workflow snapshots
const { allTasks, allSteps } = useMemo(() => {
const tasks: KanbanState['tasks'] = [];
const stepMap = new Map<string, { id: string; title: string; color: string; position: number }>();
for (const snapshot of Object.values(snapshots)) {
for (const step of snapshot.steps) {
if (!stepMap.has(step.id)) {
stepMap.set(step.id, step);
}
}
tasks.push(...snapshot.tasks);
}
// Sort steps by position for consistent ordering
const sortedSteps = [...stepMap.values()].sort((a, b) => a.position - b.position);
return { allTasks: tasks, allSteps: sortedSteps };
}, [snapshots]);
const store = useAppStoreApi();
// Task deletion state and handler
@@ -161,16 +143,10 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workspaceId
[sessionsByTaskId, gitStatusBySessionId]
);
// Get boards for the current workspace
const workspaceBoards = useMemo(() => {
if (!workspaceId) return [];
return boards.filter((board: { id: string; workspaceId: string; name: string }) => board.workspaceId === workspaceId);
}, [boards, workspaceId]);
const tasksWithRepositories = useMemo(() => {
const repositories = workspaceId ? repositoriesByWorkspace[workspaceId] ?? [] : [];
const repositoryPathsById = new Map(repositories.map((repo: Repository) => [repo.id, repo.local_path]));
return tasks.map((task: KanbanState['tasks'][number]) => {
return allTasks.map((task: KanbanState['tasks'][number]) => {
const sessionInfo = getSessionInfoForTask(task.id);
return {
id: task.id,
@@ -180,11 +156,11 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workspaceId
workflowStepId: task.workflowStepId,
repositoryPath: task.repositoryId ? repositoryPathsById.get(task.repositoryId) : undefined,
diffStats: sessionInfo.diffStats,
// Use session's updatedAt if available, otherwise fall back to task's updatedAt
updatedAt: sessionInfo.updatedAt ?? task.updatedAt,
};
});
}, [repositoriesByWorkspace, tasks, workspaceId, getSessionInfoForTask]);
}, [repositoriesByWorkspace, allTasks, workspaceId, getSessionInfoForTask]);
const updateUrl = useCallback((sessionId: string) => {
if (typeof window === 'undefined') return;
@@ -222,35 +198,47 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workspaceId
try {
await deleteTaskById(taskId);
// Get remaining tasks after filtering out deleted one
const currentTasks = store.getState().kanban.tasks;
const remainingTasks = currentTasks.filter(
(item: KanbanState['tasks'][number]) => item.id !== taskId
);
// Remove task from multi snapshots
const currentSnapshots = store.getState().kanbanMulti.snapshots;
for (const [wfId, snapshot] of Object.entries(currentSnapshots)) {
const hadTask = snapshot.tasks.some((t: KanbanState['tasks'][number]) => t.id === taskId);
if (hadTask) {
store.getState().setWorkflowSnapshot(wfId, {
...snapshot,
tasks: snapshot.tasks.filter((t: KanbanState['tasks'][number]) => t.id !== taskId),
});
}
}
// Update UI after successful delete
store.setState((state) => ({
...state,
kanban: {
...state.kanban,
tasks: remainingTasks,
},
}));
// Also update single kanban state if task was there
const currentKanbanTasks = store.getState().kanban.tasks;
if (currentKanbanTasks.some((t: KanbanState['tasks'][number]) => t.id === taskId)) {
store.setState((state) => ({
...state,
kanban: {
...state.kanban,
tasks: state.kanban.tasks.filter((t: KanbanState['tasks'][number]) => t.id !== taskId),
},
}));
}
// Collect all remaining tasks across snapshots
const allRemainingTasks: KanbanState['tasks'] = [];
for (const snapshot of Object.values(store.getState().kanbanMulti.snapshots)) {
allRemainingTasks.push(...snapshot.tasks);
}
// If deleted task was active, switch to another task or go home
const currentActiveTaskId = store.getState().tasks.activeTaskId;
if (currentActiveTaskId === taskId) {
const oldSessionId = store.getState().tasks.activeSessionId;
if (remainingTasks.length > 0) {
// Switch to the first remaining task
const nextTask = remainingTasks[0];
// Use handleSelectTask pattern - check for primarySessionId first
if (allRemainingTasks.length > 0) {
const nextTask = allRemainingTasks[0];
if (nextTask.primarySessionId) {
setActiveSession(nextTask.id, nextTask.primarySessionId);
performLayoutSwitch(oldSessionId, nextTask.primarySessionId);
window.history.replaceState({}, '', linkToSession(nextTask.primarySessionId));
} else {
// Load sessions to find one to navigate to
const sessions = await loadTaskSessionsForTask(nextTask.id);
const sessionId = sessions[0]?.id ?? null;
if (sessionId) {
@@ -262,7 +250,6 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workspaceId
}
}
} else {
// No tasks left, go to homepage
window.location.href = '/';
}
}
@@ -276,9 +263,12 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workspaceId
const handleSelectTask = useCallback(
(taskId: string) => {
const oldSessionId = store.getState().tasks.activeSessionId;
// Get task from kanban state to check for primarySessionId
const kanbanTasks = store.getState().kanban.tasks;
const task = kanbanTasks.find((t) => t.id === taskId);
// Search for task across all workflow snapshots
let task: KanbanState['tasks'][number] | undefined;
for (const snapshot of Object.values(store.getState().kanbanMulti.snapshots)) {
task = snapshot.tasks.find((t: KanbanState['tasks'][number]) => t.id === taskId);
if (task) break;
}
// If task has primarySessionId, switch immediately (instant UX)
if (task?.primarySessionId) {
@@ -306,57 +296,11 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workspaceId
[loadTaskSessionsForTask, setActiveSession, setActiveTask, updateUrl, store]
);
// Handle board change - fetch new board data and navigate to most recent task's session
const handleBoardChange = useCallback(
async (newBoardId: string) => {
if (newBoardId === boardId) return;
// Set loading state in store (synchronous)
store.setState((state) => ({
...state,
kanban: { ...state.kanban, isLoading: true },
}));
try {
const snapshot = await fetchBoardSnapshot(newBoardId);
// Update the kanban state with the new board data
store.setState((state) => ({
...state,
kanban: mapSnapshotToKanban(snapshot, newBoardId),
boards: { ...state.boards, activeId: newBoardId },
}));
// Navigate to the most recent task's most recent session
const oldSessionId = store.getState().tasks.activeSessionId;
const mostRecentTask = sortByUpdatedAtDesc(snapshot.tasks)[0];
if (mostRecentTask) {
const sessions = await loadTaskSessionsForTask(mostRecentTask.id);
const mostRecentSession = sortByUpdatedAtDesc(sessions)[0];
if (mostRecentSession) {
setActiveSession(mostRecentTask.id, mostRecentSession.id);
performLayoutSwitch(oldSessionId, mostRecentSession.id);
updateUrl(mostRecentSession.id);
} else {
setActiveTask(mostRecentTask.id);
}
}
} catch (error) {
console.error('Failed to switch board:', error);
store.setState((state) => ({
...state,
kanban: { ...state.kanban, isLoading: false },
}));
}
},
[boardId, store, loadTaskSessionsForTask, setActiveSession, setActiveTask, updateUrl]
);
const taskSwitcherContent = (
<>
<TaskSwitcher
tasks={tasksWithRepositories}
columns={columns.map((step: KanbanState['steps'][number]) => ({ id: step.id, title: step.title, color: step.color }))}
steps={allSteps.map((step) => ({ id: step.id, title: step.title, color: step.color }))}
activeTaskId={activeTaskId}
selectedTaskId={selectedTaskId}
onSelectTask={(taskId) => {
@@ -364,27 +308,16 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workspaceId
}}
onDeleteTask={handleDeleteTask}
deletingTaskId={deletingTaskId}
isLoading={isLoadingBoard}
isLoading={isLoadingWorkflow}
/>
</>
);
const boardSwitcher = workspaceBoards.length > 1 ? (
<div className="mt-auto pt-2 border-t border-border/30">
<BoardSwitcher
boards={workspaceBoards.map((b: { id: string; name: string }) => ({ id: b.id, name: b.name }))}
activeBoardId={boardId}
onSelect={handleBoardChange}
/>
</div>
) : null;
return (
<PanelRoot>
<PanelBody className="space-y-4">
<PanelBody className="space-y-4 p-0">
{taskSwitcherContent}
</PanelBody>
{boardSwitcher}
</PanelRoot>
);
});
+50 -108
View File
@@ -1,9 +1,8 @@
'use client';
import { memo, useMemo, useState } from 'react';
import { memo, useMemo } from 'react';
import type { TaskState } from '@/lib/types/http';
import { truncateRepoPath } from '@/lib/utils';
import { SidebarButton } from './sidebar-button';
import { TaskItem } from './task-item';
type DiffStats = {
@@ -24,7 +23,7 @@ type TaskSwitcherItem = {
type TaskSwitcherProps = {
tasks: TaskSwitcherItem[];
columns: Array<{ id: string; title: string; color?: string }>;
steps: Array<{ id: string; title: string; color?: string }>;
activeTaskId: string | null;
selectedTaskId: string | null;
onSelectTask: (taskId: string) => void;
@@ -35,29 +34,18 @@ type TaskSwitcherProps = {
function TaskSwitcherSkeleton() {
return (
<div className="space-y-3 animate-pulse">
{/* Column skeleton */}
<div className="space-y-1">
<div className="h-7 bg-foreground/5 rounded-md" />
<div className="space-y-1 pl-0">
<div className="h-14 bg-foreground/5 rounded-lg" />
<div className="h-14 bg-foreground/5 rounded-lg" />
</div>
</div>
{/* Another column skeleton */}
<div className="space-y-1">
<div className="h-7 bg-foreground/5 rounded-md" />
<div className="space-y-1 pl-0">
<div className="h-14 bg-foreground/5 rounded-lg" />
</div>
</div>
<div className="animate-pulse">
<div className="h-10 bg-foreground/5" />
<div className="h-10 bg-foreground/5 mt-px" />
<div className="h-10 bg-foreground/5 mt-px" />
<div className="h-10 bg-foreground/5 mt-px" />
</div>
);
}
export const TaskSwitcher = memo(function TaskSwitcher({
tasks,
columns,
steps,
activeTaskId,
selectedTaskId,
onSelectTask,
@@ -65,105 +53,59 @@ export const TaskSwitcher = memo(function TaskSwitcher({
deletingTaskId,
isLoading = false,
}: TaskSwitcherProps) {
const [collapsedColumnIds, setCollapsedColumnIds] = useState<Set<string>>(() => new Set());
const stepNameById = useMemo(() => {
const map = new Map<string, string>();
for (const col of steps) {
map.set(col.id, col.title);
}
return map;
}, [steps]);
// Sort tasks: by step position first, then alphabetically
const sortedTasks = useMemo(() => {
return [...tasks].sort((a, b) => a.title.localeCompare(b.title));
}, [tasks]);
const tasksByColumn = useMemo(() => {
const grouped: Record<string, TaskSwitcherItem[]> = {};
for (const task of sortedTasks) {
const key = task.workflowStepId ?? 'unknown';
if (!grouped[key]) grouped[key] = [];
grouped[key].push(task);
}
return grouped;
}, [sortedTasks]);
const columnsWithFallback = useMemo(() => {
const seen = new Set(columns.map((column) => column.id));
const extra: Array<{ id: string; title: string; color?: string }> = [];
if (tasksByColumn.unknown?.length) {
extra.push({ id: 'unknown', title: 'Other', color: 'bg-neutral-400' });
}
return [...columns, ...extra].filter((column) => {
if (seen.has(column.id)) return true;
seen.add(column.id);
return true;
const stepOrder = new Map(steps.map((col, i) => [col.id, i]));
return [...tasks].sort((a, b) => {
const aOrder = stepOrder.get(a.workflowStepId ?? '') ?? 999;
const bOrder = stepOrder.get(b.workflowStepId ?? '') ?? 999;
if (aOrder !== bOrder) return aOrder - bOrder;
return a.title.localeCompare(b.title);
});
}, [columns, tasksByColumn]);
const handleToggleColumn = (columnId: string) => {
setCollapsedColumnIds((prev) => {
const next = new Set(prev);
if (next.has(columnId)) next.delete(columnId);
else next.add(columnId);
return next;
});
};
}, [tasks, steps]);
if (isLoading) {
return <TaskSwitcherSkeleton />;
}
return (
<div className="space-y-1">
<div>
{sortedTasks.length === 0 ? (
<div className="px-2 text-xs text-muted-foreground">No tasks on this board.</div>
<div className="px-3 py-3 text-xs text-muted-foreground">No tasks yet.</div>
) : (
<div className="space-y-1">
{columnsWithFallback.map((column) => {
const columnTasks = tasksByColumn[column.id] ?? [];
if (columnTasks.length === 0) return null;
const isColumnOpen = !collapsedColumnIds.has(column.id);
return (
<div key={column.id}>
<SidebarButton
label={column.title}
count={columnTasks.length}
isExpanded={isColumnOpen}
onToggle={() => handleToggleColumn(column.id)}
/>
{/* Expanded content with CSS transition */}
<div
className={`overflow-hidden transition-[opacity,grid-template-rows] duration-200 ease-in-out ${isColumnOpen
? 'grid grid-rows-[1fr] opacity-100'
: 'grid grid-rows-[0fr] opacity-0'
}`}
>
<div className="min-h-0">
<div className="pt-1">
<div className="flex flex-col gap-1">
{columnTasks.map((task) => {
const isActive = task.id === activeTaskId;
const isSelected = task.id === selectedTaskId || isActive;
const repoLabel = task.repositoryPath
? truncateRepoPath(task.repositoryPath)
: undefined;
return (
<TaskItem
key={task.id}
title={task.title}
description={repoLabel}
state={task.state}
isSelected={isSelected}
diffStats={task.diffStats}
updatedAt={task.updatedAt}
onClick={() => onSelectTask(task.id)}
onDelete={onDeleteTask ? () => onDeleteTask(task.id) : undefined}
isDeleting={deletingTaskId === task.id}
/>
);
})}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
sortedTasks.map((task) => {
const isActive = task.id === activeTaskId;
const isSelected = task.id === selectedTaskId || isActive;
const repoLabel = task.repositoryPath
? truncateRepoPath(task.repositoryPath)
: undefined;
const stepName = task.workflowStepId
? stepNameById.get(task.workflowStepId)
: undefined;
return (
<TaskItem
key={task.id}
title={task.title}
description={repoLabel}
stepName={stepName}
state={task.state}
isSelected={isSelected}
diffStats={task.diffStats}
updatedAt={task.updatedAt}
onClick={() => onSelectTask(task.id)}
onDelete={onDeleteTask ? () => onDeleteTask(task.id) : undefined}
isDeleting={deletingTaskId === task.id}
/>
);
})
)}
</div>
);
+11
View File
@@ -28,6 +28,7 @@ import { formatUserHomePath } from '@/lib/utils';
import { EditorsMenu } from '@/components/task/editors-menu';
import { DocumentControls } from '@/components/task/document/document-controls';
import { VcsSplitButton } from '@/components/vcs-split-button';
import { WorkflowStepper, type WorkflowStepperStep } from '@/components/task/workflow-stepper';
import { DEBUG_UI } from '@/lib/config';
type TaskTopBarProps = {
@@ -46,6 +47,8 @@ type TaskTopBarProps = {
repositoryName?: string | null;
showDebugOverlay?: boolean;
onToggleDebugOverlay?: () => void;
workflowSteps?: WorkflowStepperStep[];
currentStepId?: string | null;
};
const TaskTopBar = memo(function TaskTopBar({
@@ -58,6 +61,8 @@ const TaskTopBar = memo(function TaskTopBar({
repositoryName,
showDebugOverlay,
onToggleDebugOverlay,
workflowSteps,
currentStepId,
}: TaskTopBarProps) {
const [copiedBranch, setCopiedBranch] = useState(false);
const [popoverOpen, setPopoverOpen] = useState(false);
@@ -223,6 +228,12 @@ const TaskTopBar = memo(function TaskTopBar({
)}
</div>
{workflowSteps && workflowSteps.length > 0 && (
<WorkflowStepper
steps={workflowSteps}
currentStepId={currentStepId ?? null}
/>
)}
<div className="flex items-center gap-2">
{DEBUG_UI && onToggleDebugOverlay && (
<Tooltip>
@@ -0,0 +1,100 @@
'use client';
import { memo, useMemo } from 'react';
import { cn } from '@kandev/ui/lib/utils';
type Step = {
id: string;
name: string;
color: string;
position: number;
};
type WorkflowStepperProps = {
steps: Step[];
currentStepId: string | null;
};
const WorkflowStepper = memo(function WorkflowStepper({
steps,
currentStepId,
}: WorkflowStepperProps) {
const sortedSteps = useMemo(
() => [...steps].sort((a, b) => a.position - b.position),
[steps]
);
const currentIndex = useMemo(
() => sortedSteps.findIndex((s) => s.id === currentStepId),
[sortedSteps, currentStepId]
);
if (sortedSteps.length === 0) return null;
return (
<div className="flex items-center gap-0 overflow-x-auto flex-shrink min-w-0">
{sortedSteps.map((step, index) => {
const isCompleted = currentIndex >= 0 && index < currentIndex;
const isCurrent = index === currentIndex;
return (
<div key={step.id} className="flex items-center">
{/* Connector line before (except first) */}
{index > 0 && (
<div
className={cn(
'h-px w-6 shrink-0',
isCompleted || isCurrent
? 'bg-muted-foreground/40'
: 'bg-border'
)}
/>
)}
{/* 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>
{/* 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>
</div>
);
})}
</div>
);
});
export { WorkflowStepper };
export type { Step as WorkflowStepperStep };
@@ -9,64 +9,64 @@ import {
} from '@kandev/ui/dropdown-menu';
import { cn } from '@/lib/utils';
type Board = {
type Workflow = {
id: string;
name: string;
};
type BoardSwitcherProps = {
boards: Board[];
activeBoardId: string | null;
onSelect: (boardId: string) => void;
type WorkflowSwitcherProps = {
workflows: Workflow[];
activeWorkflowId: string | null;
onSelect: (workflowId: string) => void;
};
export function BoardSwitcher({
boards,
activeBoardId,
export function WorkflowSwitcher({
workflows,
activeWorkflowId,
onSelect,
}: BoardSwitcherProps) {
const selectedBoard = boards.find((b) => b.id === activeBoardId);
}: WorkflowSwitcherProps) {
const selectedWorkflow = workflows.find((w) => w.id === activeWorkflowId);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
title="Switch Boards"
title="Switch Workflows"
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs cursor-pointer',
'text-foreground hover:bg-foreground/5 transition-colors duration-150',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
)}
>
{/* Board Avatar */}
{/* Workflow Avatar */}
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-foreground/10 text-xs font-medium">
{selectedBoard?.name?.charAt(0) || 'B'}
{selectedWorkflow?.name?.charAt(0) || 'W'}
</span>
{/* Board Name */}
{/* Workflow Name */}
<span className="flex-1 truncate text-left font-medium">
{selectedBoard?.name || 'Select board'}
{selectedWorkflow?.name || 'Select workflow'}
</span>
<IconChevronDown className="h-3.5 w-3.5 shrink-0 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{boards.map((board) => (
{workflows.map((workflow) => (
<DropdownMenuItem
key={board.id}
onClick={() => onSelect(board.id)}
key={workflow.id}
onClick={() => onSelect(workflow.id)}
className={cn(
'justify-between',
activeBoardId === board.id && 'bg-foreground/10'
activeWorkflowId === workflow.id && 'bg-foreground/10'
)}
>
<div className="flex items-center gap-2">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-foreground/10 text-xs font-medium">
{board.name.charAt(0)}
{workflow.name.charAt(0)}
</span>
<span className="truncate">{board.name}</span>
<span className="truncate">{workflow.name}</span>
</div>
{activeBoardId === board.id && (
{activeWorkflowId === workflow.id && (
<IconCheck className="h-4 w-4 shrink-0" />
)}
</DropdownMenuItem>
@@ -0,0 +1,100 @@
'use client';
import { Fragment, memo, useMemo, useState } from 'react';
import { IconCheck, IconChevronDown, IconLogicBuffer } from '@tabler/icons-react';
import { Popover, PopoverContent, PopoverTrigger } from '@kandev/ui/popover';
import { Button } from '@kandev/ui/button';
import type { WorkflowSnapshotData } from '@/lib/state/slices/kanban/types';
type StepItem = { id: string; title: string; color: string; position: number };
function InlineSteps({ steps }: { steps: StepItem[] }) {
if (steps.length === 0) return null;
return (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground whitespace-nowrap">
{steps.map((s, i) => (
<Fragment key={s.id}>
{i > 0 && <span className="text-muted-foreground/40">→</span>}
<span className="flex items-center gap-1">
<span
className="h-1.5 w-1.5 rounded-full shrink-0"
style={{ backgroundColor: s.color || 'hsl(var(--muted-foreground))' }}
/>
{s.title}
</span>
</Fragment>
))}
</div>
);
}
type WorkflowSelectorRowProps = {
workflows: Array<{ id: string; name: string; description?: string | null }>;
snapshots: Record<string, WorkflowSnapshotData>;
selectedWorkflowId: string | null;
onWorkflowChange: (workflowId: string) => void;
};
export const WorkflowSelectorRow = memo(function WorkflowSelectorRow({
workflows,
snapshots,
selectedWorkflowId,
onWorkflowChange,
}: WorkflowSelectorRowProps) {
const [open, setOpen] = useState(false);
const selectedWorkflow = useMemo(
() => workflows.find((w) => w.id === selectedWorkflowId),
[workflows, selectedWorkflowId]
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
className="w-auto justify-between cursor-pointer"
>
<IconLogicBuffer className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{selectedWorkflow?.name ?? 'Select workflow'}</span>
<IconChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto min-w-[300px] max-w-none p-1" align="start">
<div className="text-muted-foreground px-2 py-1.5 text-xs border-b">Workflow</div>
{workflows.map((wf) => {
const isSelected = wf.id === selectedWorkflowId;
const snapshot = snapshots[wf.id];
const steps = snapshot
? [...snapshot.steps].sort((a, b) => a.position - b.position)
: [];
return (
<button
key={wf.id}
type="button"
onClick={() => {
onWorkflowChange(wf.id);
setOpen(false);
}}
className="w-full flex flex-col gap-1 px-2 py-1.5 rounded-sm hover:bg-muted transition-colors cursor-pointer text-left relative pr-8"
>
<div className="flex items-center gap-2">
<IconLogicBuffer className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="text-sm">{wf.name}</span>
</div>
{steps.length > 0 && (
<div className="pl-[calc(0.875rem+0.5rem)]">
<InlineSteps steps={steps} />
</div>
)}
{isSelected && (
<IconCheck className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4" />
)}
</button>
);
})}
</PopoverContent>
</Popover>
);
});