From 94b99198ed3e06cf90767a01eec9dfb811f21493 Mon Sep 17 00:00:00 2001
From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com>
Date: Sun, 3 May 2026 13:44:13 +0200
Subject: [PATCH] feat(solo): preview-then-confirm flow in SoloBoard
Voer-alle-uit
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Vervang directe enqueueAllTodoJobsAction door previewEnqueueAllAction + BatchEnqueueBlockerDialog. Geen blocker → enqueueClaudeJobsBatchAction direct. Wel blocker → dialog met prefix-enqueue of annuleer. Loading-state op knop tijdens preview en confirm. 5 integratie-tests.
---
.../solo/solo-board-batch-enqueue.test.tsx | 204 ++++++++++++++++++
components/solo/solo-board.tsx | 65 +++++-
2 files changed, 265 insertions(+), 4 deletions(-)
create mode 100644 __tests__/components/solo/solo-board-batch-enqueue.test.tsx
diff --git a/__tests__/components/solo/solo-board-batch-enqueue.test.tsx b/__tests__/components/solo/solo-board-batch-enqueue.test.tsx
new file mode 100644
index 0000000..392bf6e
--- /dev/null
+++ b/__tests__/components/solo/solo-board-batch-enqueue.test.tsx
@@ -0,0 +1,204 @@
+// @vitest-environment jsdom
+import '@testing-library/jest-dom'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+
+const { mockPreviewEnqueueAllAction, mockEnqueueClaudeJobsBatchAction } = vi.hoisted(() => ({
+ mockPreviewEnqueueAllAction: vi.fn(),
+ mockEnqueueClaudeJobsBatchAction: vi.fn(),
+}))
+
+vi.mock('@/actions/claude-jobs', () => ({
+ previewEnqueueAllAction: mockPreviewEnqueueAllAction,
+ enqueueClaudeJobsBatchAction: mockEnqueueClaudeJobsBatchAction,
+ cancelClaudeJobAction: vi.fn(),
+ enqueueClaudeJobAction: vi.fn(),
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } }))
+vi.mock('@dnd-kit/core', () => ({
+ DndContext: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ DragOverlay: () => null,
+ PointerSensor: class {},
+ useSensor: vi.fn(() => ({})),
+ useSensors: vi.fn(() => []),
+ closestCorners: vi.fn(),
+}))
+vi.mock('@/components/ui/button', () => ({
+ Button: ({
+ children,
+ onClick,
+ disabled,
+ }: {
+ children?: React.ReactNode
+ onClick?: () => void
+ disabled?: boolean
+ }) => (
+
+ ),
+}))
+vi.mock('@/components/ui/dialog', () => ({
+ Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
+ open ?
{children}
: null,
+ DialogContent: ({ children }: { children: React.ReactNode }) => {children}
,
+ DialogHeader: ({ children }: { children: React.ReactNode }) => {children}
,
+ DialogTitle: ({ children }: { children: React.ReactNode }) => {children}
,
+}))
+vi.mock('@/components/ui/tooltip', () => ({
+ TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ TooltipTrigger: ({ render: r, children }: { render?: React.ReactElement; children?: React.ReactNode }) =>
+ r ? <>{r}> : <>{children}>,
+ TooltipContent: () => null,
+}))
+vi.mock('@/components/shared/demo-tooltip', () => ({
+ DemoTooltip: ({ children }: { children: React.ReactNode }) => <>{children}>,
+}))
+vi.mock('@/components/split-pane/split-pane', () => ({
+ SplitPane: ({ panes }: { panes: React.ReactNode[] }) => <>{panes}>,
+}))
+vi.mock('@/components/solo/solo-column', () => ({
+ SoloColumn: () => ,
+}))
+vi.mock('@/components/solo/solo-task-card', () => ({
+ SoloTaskCardOverlay: () => null,
+}))
+vi.mock('@/components/solo/task-detail-dialog', () => ({
+ TaskDetailDialog: () => null,
+}))
+vi.mock('@/components/solo/unassigned-stories-sheet', () => ({
+ UnassignedStoriesSheet: () => null,
+}))
+vi.mock('@/lib/task-status', () => ({
+ taskStatusToApi: (s: string) => s.toLowerCase(),
+}))
+
+import { useSoloStore } from '@/stores/solo-store'
+import { SoloBoard } from '@/components/solo/solo-board'
+import { toast } from 'sonner'
+
+const PRODUCT_ID = 'prod-1'
+const TODO_TASK = {
+ id: 't1',
+ title: 'Task 1',
+ description: null,
+ implementation_plan: null,
+ priority: 1,
+ sort_order: 1,
+ status: 'TO_DO' as const,
+ verify_only: false,
+ verify_required: 'ALIGNED_OR_PARTIAL' as const,
+ story_id: 'story-1',
+ story_code: 'ST-1',
+ story_title: 'Story 1',
+ task_code: 'ST-1.1',
+}
+
+const DEFAULT_PROPS = {
+ productId: PRODUCT_ID,
+ sprintGoal: 'Sprint goal',
+ tasks: [TODO_TASK],
+ unassignedStories: [],
+ isDemo: false,
+ currentUserId: 'user-1',
+}
+
+const PREVIEW_NO_BLOCKER = {
+ tasks: [{ id: 't1', title: 'Task 1', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' }],
+ blockerIndex: null,
+ blockerReason: null,
+}
+
+const PREVIEW_WITH_BLOCKER = {
+ tasks: [
+ { id: 't1', title: 'Task 1', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' },
+ { id: 't2', title: 'Task 2', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' },
+ { id: 't3', title: 'Task Review', status: 'REVIEW', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' },
+ ],
+ blockerIndex: 2,
+ blockerReason: 'task-review' as const,
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ useSoloStore.setState({ tasks: {}, claudeJobsByTaskId: {}, connectedWorkers: 1 })
+})
+
+describe('SoloBoard — batch-enqueue flow', () => {
+ it('no blocker: calls enqueueClaudeJobsBatchAction with TO_DO task IDs directly', async () => {
+ mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_NO_BLOCKER)
+ mockEnqueueClaudeJobsBatchAction.mockResolvedValue({ success: true, count: 1 })
+
+ render()
+
+ fireEvent.click(screen.getByText(/Start agents/))
+
+ await waitFor(() => {
+ expect(mockPreviewEnqueueAllAction).toHaveBeenCalledWith(PRODUCT_ID)
+ expect(mockEnqueueClaudeJobsBatchAction).toHaveBeenCalledWith(PRODUCT_ID, ['t1'])
+ expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('1 agent'))
+ })
+ })
+
+ it('blocker: shows dialog when preview returns blockerIndex', async () => {
+ mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER)
+
+ render()
+
+ fireEvent.click(screen.getByText(/Start agents/))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('dialog')).toBeInTheDocument()
+ expect(screen.getByText(/Blokkade gedetecteerd/)).toBeInTheDocument()
+ })
+ expect(mockEnqueueClaudeJobsBatchAction).not.toHaveBeenCalled()
+ })
+
+ it('blocker dialog confirm: enqueues prefix tasks and closes', async () => {
+ mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER)
+ mockEnqueueClaudeJobsBatchAction.mockResolvedValue({ success: true, count: 2 })
+
+ render()
+ fireEvent.click(screen.getByText(/Start agents/))
+
+ await waitFor(() => screen.getByTestId('dialog'))
+
+ fireEvent.click(screen.getByText(/Stuur 2 taken tot aan blokkade/))
+
+ await waitFor(() => {
+ expect(mockEnqueueClaudeJobsBatchAction).toHaveBeenCalledWith(PRODUCT_ID, ['t1', 't2'])
+ expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('2 agents'))
+ expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
+ })
+ })
+
+ it('blocker dialog cancel: closes dialog without enqueuing', async () => {
+ mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER)
+
+ render()
+ fireEvent.click(screen.getByText(/Start agents/))
+
+ await waitFor(() => screen.getByTestId('dialog'))
+
+ fireEvent.click(screen.getByText('Annuleer'))
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
+ })
+ expect(mockEnqueueClaudeJobsBatchAction).not.toHaveBeenCalled()
+ })
+
+ it('preview error: shows toast without opening dialog', async () => {
+ mockPreviewEnqueueAllAction.mockResolvedValue({ error: 'Geen toegang' })
+
+ render()
+ fireEvent.click(screen.getByText(/Start agents/))
+
+ await waitFor(() => {
+ expect(toast.error).toHaveBeenCalledWith('Geen toegang')
+ })
+ expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
+ })
+})
diff --git a/components/solo/solo-board.tsx b/components/solo/solo-board.tsx
index 54ee48e..0dd6fa2 100644
--- a/components/solo/solo-board.tsx
+++ b/components/solo/solo-board.tsx
@@ -8,7 +8,8 @@ import {
import { toast } from 'sonner'
import { useSoloStore } from '@/stores/solo-store'
import { taskStatusToApi } from '@/lib/task-status'
-import { enqueueAllTodoJobsAction } from '@/actions/claude-jobs'
+import { previewEnqueueAllAction, enqueueClaudeJobsBatchAction } from '@/actions/claude-jobs'
+import { BatchEnqueueBlockerDialog } from './batch-enqueue-blocker-dialog'
import { Button } from '@/components/ui/button'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { SplitPane } from '@/components/split-pane/split-pane'
@@ -61,6 +62,15 @@ export function SoloBoard({
const [unassignedStories, setUnassignedStories] = useState(initialUnassigned)
const [, startTransition] = useTransition()
const [batchPending, startBatchTransition] = useTransition()
+ const [confirmPending, startConfirmTransition] = useTransition()
+
+ type BlockerDialogState = {
+ prefixCount: number
+ blockerReason: 'task-review' | 'pbi-blocked'
+ blockerLabel: string
+ prefixIds: string[]
+ }
+ const [blockerDialog, setBlockerDialog] = useState(null)
const taskKey = initialTasks.map(t => t.id).join(',')
useEffect(() => {
@@ -140,7 +150,42 @@ export function SoloBoard({
function handleStartAll() {
if (queueableCount === 0) return
startBatchTransition(async () => {
- const result = await enqueueAllTodoJobsAction(productId)
+ const preview = await previewEnqueueAllAction(productId)
+ if ('error' in preview) {
+ toast.error(preview.error)
+ return
+ }
+ if (preview.blockerIndex === null) {
+ const todoIds = preview.tasks.filter(t => t.status === 'TO_DO').map(t => t.id)
+ const result = await enqueueClaudeJobsBatchAction(productId, todoIds)
+ if ('error' in result) {
+ toast.error(result.error)
+ } else if (result.count === 0) {
+ toast.info('Geen taken om te starten')
+ } else {
+ toast.success(`${result.count} ${result.count === 1 ? 'agent' : 'agents'} ingeschakeld`)
+ }
+ } else {
+ const blockerTask = preview.tasks[preview.blockerIndex]
+ const blockerLabel = preview.blockerReason === 'task-review'
+ ? `${blockerTask.story_title} — ${blockerTask.title}`
+ : blockerTask.story_title
+ setBlockerDialog({
+ prefixCount: preview.blockerIndex,
+ blockerReason: preview.blockerReason!,
+ blockerLabel,
+ prefixIds: preview.tasks.slice(0, preview.blockerIndex).map(t => t.id),
+ })
+ }
+ })
+ }
+
+ function handleBlockerConfirm() {
+ if (!blockerDialog) return
+ const { prefixIds } = blockerDialog
+ setBlockerDialog(null)
+ startConfirmTransition(async () => {
+ const result = await enqueueClaudeJobsBatchAction(productId, prefixIds)
if ('error' in result) {
toast.error(result.error)
} else if (result.count === 0) {
@@ -159,9 +204,9 @@ export function SoloBoard({
{sprintGoal && (
@@ -234,6 +279,18 @@ export function SoloBoard({
onOpenChange={setSheetOpen}
onClaim={(id) => setUnassignedStories(prev => prev.filter(s => s.id !== id))}
/>
+
+ {blockerDialog && (
+ { if (!v) setBlockerDialog(null) }}
+ prefixCount={blockerDialog.prefixCount}
+ blockerReason={blockerDialog.blockerReason}
+ blockerLabel={blockerDialog.blockerLabel}
+ onConfirm={handleBlockerConfirm}
+ onCancel={() => setBlockerDialog(null)}
+ />
+ )}
)
}