-
-
Notifications
You must be signed in to change notification settings - Fork 1k
feat: Queue System v2 with Auto-Promotion and Smart Task Management #405
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
fac8424
bd38e0a
e1a7f91
4ae6a01
d85c4e2
16074cb
2e14f70
7ba1286
3939d79
9bbb73b
021db54
b579fbf
287d7c7
383f51b
676417d
507c66d
604a954
cbaed27
2a39ff9
e0e54df
25412c9
a4f4008
9637ead
e42aecc
1c7e356
de2f4e5
2bbba8f
8b34f5f
322d38c
037cc2f
aa41944
d3b0ccf
0b931fa
55b9019
5359795
afa28c8
418e0c4
f7cb1a6
02c1d0b
38c8cf5
9a91411
9c56f4b
b8b1ad6
6415aa3
4b00aa5
7174747
0f11979
06cade3
e44733f
5ddc2fb
57ecb55
dc28a6c
047366b
13f8043
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,16 +18,19 @@ import { | |
| sortableKeyboardCoordinates, | ||
| verticalListSortingStrategy | ||
| } from '@dnd-kit/sortable'; | ||
| import { Plus, Inbox, Loader2, Eye, CheckCircle2, Archive } from 'lucide-react'; | ||
| import { Plus, Inbox, Loader2, Eye, CheckCircle2, Archive, Settings, ListPlus } from 'lucide-react'; | ||
| import { ScrollArea } from './ui/scroll-area'; | ||
| import { Button } from './ui/button'; | ||
| import { Checkbox } from './ui/checkbox'; | ||
| import { Label } from './ui/label'; | ||
| import { TaskCard } from './TaskCard'; | ||
| import { SortableTaskCard } from './SortableTaskCard'; | ||
| import { QueueSettingsModal } from './QueueSettingsModal'; | ||
| import { TASK_STATUS_COLUMNS, TASK_STATUS_LABELS } from '../../shared/constants'; | ||
| import { cn } from '../lib/utils'; | ||
| import { persistTaskStatus, archiveTasks } from '../stores/task-store'; | ||
| import { persistTaskStatus, archiveTasks, useTaskStore } from '../stores/task-store'; | ||
| import { updateProjectSettings } from '../stores/project-store'; | ||
| import { useProjectStore } from '../stores/project-store'; | ||
| import type { Task, TaskStatus } from '../../shared/types'; | ||
|
|
||
| interface KanbanBoardProps { | ||
|
|
@@ -43,6 +46,9 @@ interface DroppableColumnProps { | |
| isOver: boolean; | ||
| onAddClick?: () => void; | ||
| onArchiveAll?: () => void; | ||
| onQueueSettings?: () => void; | ||
| onQueueAll?: () => void; | ||
| maxParallelTasks?: number; | ||
| } | ||
|
|
||
| // Empty state content for each column | ||
|
|
@@ -54,6 +60,12 @@ const getEmptyStateContent = (status: TaskStatus, t: (key: string) => string): { | |
| message: t('kanban.emptyBacklog'), | ||
| subtext: t('kanban.emptyBacklogHint') | ||
| }; | ||
| case 'queue': | ||
| return { | ||
| icon: <Loader2 className="h-6 w-6 text-muted-foreground/50" />, | ||
| message: t('kanban.emptyQueue'), | ||
| subtext: t('kanban.emptyQueueHint') | ||
| }; | ||
| case 'in_progress': | ||
| return { | ||
| icon: <Loader2 className="h-6 w-6 text-muted-foreground/50" />, | ||
|
|
@@ -86,18 +98,23 @@ const getEmptyStateContent = (status: TaskStatus, t: (key: string) => string): { | |
| } | ||
| }; | ||
|
|
||
| function DroppableColumn({ status, tasks, onTaskClick, isOver, onAddClick, onArchiveAll }: DroppableColumnProps) { | ||
| function DroppableColumn({ status, tasks, onTaskClick, isOver, onAddClick, onArchiveAll, onQueueSettings, onQueueAll, maxParallelTasks }: DroppableColumnProps) { | ||
| const { t } = useTranslation('tasks'); | ||
| const { setNodeRef } = useDroppable({ | ||
| id: status | ||
| }); | ||
|
|
||
| const taskIds = tasks.map((t) => t.id); | ||
|
|
||
| // Check if In Progress column is at capacity | ||
| const isInProgressFull = status === 'in_progress' && maxParallelTasks && tasks.length >= maxParallelTasks; | ||
|
|
||
| const getColumnBorderColor = (): string => { | ||
| switch (status) { | ||
| case 'backlog': | ||
| return 'column-backlog'; | ||
| case 'queue': | ||
| return 'column-queue'; | ||
| case 'in_progress': | ||
| return 'column-in-progress'; | ||
| case 'ai_review': | ||
|
|
@@ -129,19 +146,55 @@ function DroppableColumn({ status, tasks, onTaskClick, isOver, onAddClick, onArc | |
| <h2 className="font-semibold text-sm text-foreground"> | ||
| {TASK_STATUS_LABELS[status]} | ||
| </h2> | ||
| <span className="column-count-badge"> | ||
| {tasks.length} | ||
| </span> | ||
| {status === 'in_progress' && maxParallelTasks ? ( | ||
| <span className={cn( | ||
| "column-count-badge", | ||
| isInProgressFull && "bg-warning/20 text-warning border-warning/30" | ||
| )}> | ||
| {tasks.length}/{maxParallelTasks} | ||
| </span> | ||
| ) : ( | ||
| <span className="column-count-badge"> | ||
| {tasks.length} | ||
| </span> | ||
| )} | ||
| </div> | ||
| <div className="flex items-center gap-1"> | ||
| {status === 'backlog' && onAddClick && ( | ||
| {status === 'backlog' && ( | ||
| <> | ||
| {onQueueAll && tasks.length > 0 && ( | ||
| <Button | ||
| variant="ghost" | ||
| size="icon" | ||
| className="h-7 w-7 hover:bg-cyan-500/10 hover:text-cyan-400 transition-colors" | ||
| onClick={onQueueAll} | ||
| title={t('tooltips.queueAll')} | ||
| > | ||
| <ListPlus className="h-4 w-4" /> | ||
| </Button> | ||
| )} | ||
| {onAddClick && ( | ||
| <Button | ||
| variant="ghost" | ||
| size="icon" | ||
| className="h-7 w-7 hover:bg-primary/10 hover:text-primary transition-colors" | ||
| onClick={onAddClick} | ||
| title={t('tooltips.addTask')} | ||
| > | ||
| <Plus className="h-4 w-4" /> | ||
| </Button> | ||
| )} | ||
| </> | ||
| )} | ||
| {status === 'queue' && onQueueSettings && ( | ||
| <Button | ||
| variant="ghost" | ||
| size="icon" | ||
| className="h-7 w-7 hover:bg-primary/10 hover:text-primary transition-colors" | ||
| onClick={onAddClick} | ||
| className="h-7 w-7 hover:bg-cyan-500/10 hover:text-cyan-400 transition-colors" | ||
| onClick={onQueueSettings} | ||
| title="Queue Settings" | ||
| > | ||
| <Plus className="h-4 w-4" /> | ||
| <Settings className="h-4 w-4" /> | ||
| </Button> | ||
| )} | ||
| {status === 'done' && onArchiveAll && tasks.length > 0 && ( | ||
|
|
@@ -216,6 +269,13 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick }: KanbanBoardP | |
| const [activeTask, setActiveTask] = useState<Task | null>(null); | ||
| const [overColumnId, setOverColumnId] = useState<string | null>(null); | ||
| const [showArchived, setShowArchived] = useState(false); | ||
| const [showQueueSettings, setShowQueueSettings] = useState(false); | ||
|
|
||
| // Get active project for queue settings | ||
| const { getActiveProject } = useProjectStore(); | ||
| const activeProject = getActiveProject(); | ||
| const projectId = tasks.length > 0 ? tasks[0].projectId : activeProject?.id || ''; | ||
| const maxParallelTasks = activeProject?.settings.maxParallelTasks ?? 3; | ||
|
|
||
| // Count archived tasks for display | ||
| const archivedCount = useMemo(() => { | ||
|
|
@@ -244,6 +304,7 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick }: KanbanBoardP | |
| const tasksByStatus = useMemo(() => { | ||
| const grouped: Record<TaskStatus, Task[]> = { | ||
| backlog: [], | ||
| queue: [], | ||
| in_progress: [], | ||
| ai_review: [], | ||
| human_review: [], | ||
|
|
@@ -285,6 +346,36 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick }: KanbanBoardP | |
| } | ||
| }; | ||
|
|
||
| const handleSaveQueueSettings = async (newMaxParallel: number) => { | ||
| if (!projectId) { | ||
| console.error('[KanbanBoard] No projectId found'); | ||
| return; | ||
| } | ||
|
|
||
| const success = await updateProjectSettings(projectId, { | ||
| maxParallelTasks: newMaxParallel | ||
| }); | ||
|
|
||
| if (!success) { | ||
| console.error('[KanbanBoard] Failed to save queue settings'); | ||
| } | ||
| }; | ||
|
|
||
| const handleQueueAll = async () => { | ||
| const backlogTasks = tasksByStatus.backlog; | ||
| if (backlogTasks.length === 0) return; | ||
|
|
||
| console.log(`[Queue] Moving ${backlogTasks.length} tasks from Planning to Queue`); | ||
|
|
||
| // Move all backlog tasks to queue | ||
| for (const task of backlogTasks) { | ||
| await persistTaskStatus(task.id, 'queue'); | ||
| } | ||
|
|
||
| // After all tasks are queued, process the queue to start filling In Progress | ||
| await processQueue(); | ||
| }; | ||
|
Comment on lines
+364
to
+377
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ๐งฉ Analysis chain๐ Script executed: # First, let's check if the file exists and understand its structure
ls -la apps/frontend/src/renderer/components/KanbanBoard.tsxRepository: AndyMik90/Auto-Claude Length of output: 168 ๐ Script executed: # Get line count to understand the file size
wc -l apps/frontend/src/renderer/components/KanbanBoard.tsxRepository: AndyMik90/Auto-Claude Length of output: 122 ๐ Script executed: # Now let's examine the specific lines mentioned in the review (364-377)
sed -n '364,377p' apps/frontend/src/renderer/components/KanbanBoard.tsxRepository: AndyMik90/Auto-Claude Length of output: 530 ๐ Script executed: # Also check the lines mentioned in "Also applies to: 441-500"
sed -n '441,500p' apps/frontend/src/renderer/components/KanbanBoard.tsxRepository: AndyMik90/Auto-Claude Length of output: 2601 ๐ Script executed: # Let's search for the processQueue function definition
rg "const processQueue" apps/frontend/src/renderer/components/KanbanBoard.tsx -A 30Repository: AndyMik90/Auto-Claude Length of output: 1246 ๐ Script executed: # Search for handleDragEnd function
rg "const handleDragEnd" apps/frontend/src/renderer/components/KanbanBoard.tsx -A 40Repository: AndyMik90/Auto-Claude Length of output: 1415 ๐ Script executed: # Look for hardcoded strings, especially "Queue Settings"
rg "Queue Settings" apps/frontend/src/renderer/components/KanbanBoard.tsxRepository: AndyMik90/Auto-Claude Length of output: 136 ๐ Script executed: # Check if projectId is used in the component
rg "projectId" apps/frontend/src/renderer/components/KanbanBoard.tsx -B 2 -A 2Repository: AndyMik90/Auto-Claude Length of output: 1281 ๐ Script executed: # Search for hardcoded strings in JSX (looking for common patterns like title, placeholder, label, aria-label, etc.)
rg '(title|placeholder|label|aria-label|aria-placeholder)=["'"'"']' apps/frontend/src/renderer/components/KanbanBoard.tsxRepository: AndyMik90/Auto-Claude Length of output: 101 ๐ Script executed: # Also check for hardcoded text in JSX elements (words directly in JSX without variables)
rg '<(button|div|span|p|h[1-6]|label).*>' apps/frontend/src/renderer/components/KanbanBoard.tsx -A 1 | grep -E '(Queue|Settings|Planning|Progress|Done|Backlog|Archive|Clear)' | head -20Repository: AndyMik90/Auto-Claude Length of output: 47 ๐ Script executed: # Look at the entire handleDragEnd to verify the exact projectId issue
sed -n '441,478p' apps/frontend/src/renderer/components/KanbanBoard.tsxRepository: AndyMik90/Auto-Claude Length of output: 1703 Scope queue capacity and auto-promotion per project, not globally. Both the drag-end capacity check in Also, the hardcoded string Example refactor to make
|
||
|
|
||
| const handleDragStart = (event: DragStartEvent) => { | ||
| const { active } = event; | ||
| const task = tasks.find((t) => t.id === active.id); | ||
|
|
@@ -316,7 +407,7 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick }: KanbanBoardP | |
| } | ||
| }; | ||
|
|
||
| const handleDragEnd = (event: DragEndEvent) => { | ||
| const handleDragEnd = async (event: DragEndEvent) => { | ||
| const { active, over } = event; | ||
| setActiveTask(null); | ||
| setOverColumnId(null); | ||
|
|
@@ -326,26 +417,86 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick }: KanbanBoardP | |
| const activeTaskId = active.id as string; | ||
| const overId = over.id as string; | ||
|
|
||
| // Determine target status | ||
| let newStatus: TaskStatus | null = null; | ||
|
|
||
| // Check if dropped on a column | ||
| if (TASK_STATUS_COLUMNS.includes(overId as TaskStatus)) { | ||
| const newStatus = overId as TaskStatus; | ||
| const task = tasks.find((t) => t.id === activeTaskId); | ||
| newStatus = overId as TaskStatus; | ||
| } else { | ||
| // Check if dropped on another task - move to that task's column | ||
| const overTask = tasks.find((t) => t.id === overId); | ||
| if (overTask) { | ||
| newStatus = overTask.status; | ||
| } | ||
| } | ||
|
|
||
| if (!newStatus) return; | ||
|
|
||
| const task = tasks.find((t) => t.id === activeTaskId); | ||
| if (!task || task.status === newStatus) return; | ||
|
|
||
| if (task && task.status !== newStatus) { | ||
| // Persist status change to file and update local state | ||
| persistTaskStatus(activeTaskId, newStatus); | ||
| const oldStatus = task.status; | ||
|
|
||
| // ============================================ | ||
| // QUEUE SYSTEM: Enforce parallel task limit | ||
| // ============================================ | ||
| if (newStatus === 'in_progress') { | ||
| // Get CURRENT state from store directly to avoid stale prop/memo issues during rapid dragging | ||
| const currentTasks = useTaskStore.getState().tasks; | ||
| const inProgressCount = currentTasks.filter((t) => | ||
| t.status === 'in_progress' && !t.metadata?.archivedAt | ||
| ).length; | ||
|
|
||
| // If limit reached, move to queue instead (unless already coming from queue) | ||
| if (inProgressCount >= maxParallelTasks && oldStatus !== 'queue') { | ||
| console.log(`[Queue] In Progress full (${inProgressCount}/${maxParallelTasks}), moving task to Queue`); | ||
| newStatus = 'queue'; | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| // Check if dropped on another task - move to that task's column | ||
| const overTask = tasks.find((t) => t.id === overId); | ||
| if (overTask) { | ||
| const task = tasks.find((t) => t.id === activeTaskId); | ||
| if (task && task.status !== overTask.status) { | ||
| // Persist status change to file and update local state | ||
| persistTaskStatus(activeTaskId, overTask.status); | ||
| // Persist status change to file and update local state | ||
| await persistTaskStatus(activeTaskId, newStatus); | ||
|
|
||
| // ============================================ | ||
| // QUEUE SYSTEM: Auto-process queue when slot opens | ||
| // ============================================ | ||
| if (oldStatus === 'in_progress' && newStatus !== 'in_progress') { | ||
| // A task left In Progress - check if we can promote from queue | ||
| await processQueue(); | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * Automatically move tasks from Queue to In Progress to fill available capacity | ||
| * Promotes multiple tasks if needed (e.g., after bulk queue) | ||
| */ | ||
| const processQueue = async () => { | ||
| // Loop until capacity is full or queue is empty | ||
| while (true) { | ||
| // Get CURRENT state from store to ensure accuracy | ||
| const currentTasks = useTaskStore.getState().tasks; | ||
| const inProgressCount = currentTasks.filter((t) => | ||
| t.status === 'in_progress' && !t.metadata?.archivedAt | ||
| ).length; | ||
| const queuedTasks = currentTasks.filter((t) => | ||
| t.status === 'queue' && !t.metadata?.archivedAt | ||
| ); | ||
|
|
||
| // Stop if no capacity or no queued tasks | ||
| if (inProgressCount >= maxParallelTasks || queuedTasks.length === 0) { | ||
| break; | ||
| } | ||
|
|
||
| // Get the oldest task in queue (FIFO ordering) | ||
| const nextTask = queuedTasks.sort((a, b) => { | ||
| const dateA = new Date(a.createdAt).getTime(); | ||
| const dateB = new Date(b.createdAt).getTime(); | ||
| return dateA - dateB; // Ascending order (oldest first) | ||
| })[0]; | ||
|
|
||
| console.log(`[Queue] Auto-promoting task ${nextTask.id} from Queue to In Progress (${inProgressCount + 1}/${maxParallelTasks})`); | ||
| await persistTaskStatus(nextTask.id, 'in_progress'); | ||
| } | ||
| }; | ||
|
|
||
|
|
@@ -391,7 +542,10 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick }: KanbanBoardP | |
| onTaskClick={onTaskClick} | ||
| isOver={overColumnId === status} | ||
| onAddClick={status === 'backlog' ? onNewTaskClick : undefined} | ||
| onQueueAll={status === 'backlog' ? handleQueueAll : undefined} | ||
| onQueueSettings={status === 'queue' ? () => setShowQueueSettings(true) : undefined} | ||
| onArchiveAll={status === 'done' ? handleArchiveAll : undefined} | ||
| maxParallelTasks={status === 'in_progress' ? maxParallelTasks : undefined} | ||
| /> | ||
| ))} | ||
| </div> | ||
|
|
@@ -405,6 +559,15 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick }: KanbanBoardP | |
| ) : null} | ||
| </DragOverlay> | ||
| </DndContext> | ||
|
|
||
| {/* Queue Settings Modal */} | ||
| <QueueSettingsModal | ||
| open={showQueueSettings} | ||
| onOpenChange={setShowQueueSettings} | ||
| projectId={projectId} | ||
| currentMaxParallel={maxParallelTasks} | ||
| onSave={handleSaveQueueSettings} | ||
| /> | ||
| </div> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Localize the โQueue Settingsโ tooltip text.
The
titleattribute for the queue settings button is hard-coded:Per the frontend i18n guidelines, this should use a translation key via
t(...)(e.g.,t('tooltips.queueSettings')) and be backed by entries in the EN/FR locale files.๐ค Prompt for AI Agents