feat: improve workflows (#228)
This commit is contained in:
@@ -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,
|
||||
() => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
+22
-22
@@ -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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user