From 3bb87f17ba581117a376a1fb7f1c90e327fa5628 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Fri, 1 May 2026 11:41:35 +0200 Subject: [PATCH] Solo Paneel header refactor + agent-workflow hardening (#24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: SoloBoard layout naar SplitPane met cookie-persistentie en tab-collapse Co-Authored-By: Claude Sonnet 4.6 * feat: verplaats Live + agent-status indicators naar NavBar Live-dot (SSE-status) en "Agent verbonden / Geen agent" indicator zijn verhuisd van de SoloBoard-header naar de NavBar (rechts, voor de notifications-bell). Data blijft uit useSoloStore komen, gevoed door SoloRealtimeBridge in de (app)-layout. Indicators tonen alleen op /products/[id]/solo — buiten die route is de SSE-stream inactief. Co-Authored-By: Claude Opus 4.7 (1M context) * feat: open SoloRealtimeBridge globaal voor active product SoloRealtimeBridge gated nu op active-product i.p.v. /solo-pad. Live-dot en worker-presence werken daardoor op alle (app)-pagina's (Producten/PB/Sprint/Solo/Todo's). Buiten /solo is de solo-store leeg en zijn task-events no-ops, dus de stream gedraagt zich automatisch als lichte presence-stream tot SoloBoard mount. - realtime-bridge: productId-prop i.p.v. usePathname - (app)/layout: activeProduct?.id doorgegeven aan bridge - nav-status-indicators: pathname-check vervangen door hasActiveProduct prop - nav-bar: hasActiveProduct={!!activeProduct} doorgegeven - architecture-doc: realtime connection lifecycle bijgewerkt Co-Authored-By: Claude Opus 4.7 (1M context) * feat: enqueueAllTodoJobsAction voor batch-queueing van TO_DO-taken Nieuwe Server Action die alle TO_DO-taken van een product zonder actieve ClaudeJob in één $transaction als QUEUED jobs aanmaakt en voor elk een pg_notify('claude_job_enqueued') stuurt zodat de SSE- stream de UI live bijwerkt. - Auth + demo-blokkade + product-access via productAccessFilter - Idempotent: tasks met status QUEUED/CLAIMED/RUNNING worden overgeslagen - 4 nieuwe tests (happy path, count=0, demo-blokkade, geen toegang) Co-Authored-By: Claude Opus 4.7 (1M context) * feat: 'Start agents (n)'-knop in Solo header, productname weg SoloBoard-header toont nu een primary button die het aantal queueable TO_DO-taken telt (TO_DO zonder actieve ClaudeJob via claudeJobsByTaskId-store) en bij klik de nieuwe enqueueAllTodoJobsAction aanroept. Toast geeft het aantal gestarte agents terug. - productname-h1 verwijderd (staat al in NavBar-dropdown, dubbel) - sprintdoel blijft naast de knop - 'Toon openstaande stories'-link blijft rechts - demo-modus disabled met DemoTooltip - batch-pending state voorkomt dubbele klikken - productName-prop weg uit SoloBoard + page.tsx (was alleen voor h1) Co-Authored-By: Claude Opus 4.7 (1M context) * fix: scope enqueueAllTodoJobsAction op actieve sprint + assignee De action queue'de eerder ALLE TO_DO-taken van een product, ongeacht sprint of assignee — terwijl de 'Start agents (n)'-knop in de UI alleen de taken telt die de gebruiker ziet (actieve sprint, eigen stories). Daardoor kreeg een klik op de knop veel meer jobs aangemaakt dan de count suggereerde (62 i.p.v. de getoonde n). Server-filter komt nu overeen met page.tsx solo-query: story: { sprint_id: , assignee_id: userId } Edge case: geen actieve sprint → success met count=0 (geen error). Tests aangepast + nieuwe test voor 'geen actieve sprint'-pad. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(db): trigger sync_task_status_from_claude_job promote task naar DONE Postgres AFTER-trigger op claude_jobs.status zet de bijbehorende task automatisch op DONE zodra de job DONE wordt — werkt ongeacht welke client de update doet (MCP-server, Server Action, raw SQL). Idempotent: WHERE status <> 'DONE' voorkomt no-op updates die de bestaande notify_task_change-trigger zouden doen vuren. Die laatste verzorgt de pg_notify naar /api/realtime/solo zodat de UI synct. - migration: prisma/migrations/20260501110000_sync_task_status_from_claude_job - doc: nieuwe sectie 'Auto-promote task naar DONE' in architecture.md Co-Authored-By: Claude Opus 4.7 (1M context) * fix(ui): vul SoloColumn-kolommen volledige paneelhoogte Buitenste flex-container van SoloColumn miste h-full, waardoor het kader op content-hoogte bleef hangen i.p.v. de hele pane (binnen SplitPane) te vullen. Drop-target was daardoor ook beperkt tot het kleine kader bovenin een lege kolom. Auto-toegepast door een ClaudeJob-agent op task cmomoayt10002bortgp27jwma; co-auteurschap hieronder. Co-Authored-By: Claude Opus 4.7 (1M context) * docs: agent-batch-loop verplichte flow in CLAUDE.md Na een 'pak de volgende job'-instructie liep de agent één job en sloot de turn af, waardoor de gebruiker handmatig opnieuw 'wait_for_job' moest aanroepen voor elke volgende job in de queue. Voeg een expliciete loop-instructie toe onder de MCP-tools-sectie: na elke update_job_status moet de agent opnieuw wait_for_job aanroepen, totdat die na de full block-time terugkomt zonder claim. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Sonnet 4.6 --- CLAUDE.md | 18 ++- __tests__/actions/claude-jobs.test.ts | 89 ++++++++++- actions/claude-jobs.ts | 66 ++++++++ app/(app)/layout.tsx | 2 +- app/(app)/products/[id]/solo/page.tsx | 1 - components/shared/nav-bar.tsx | 4 +- components/solo/nav-status-indicators.tsx | 65 ++++++++ components/solo/realtime-bridge.tsx | 18 +-- components/solo/solo-board.tsx | 143 ++++++++---------- components/solo/solo-column.tsx | 2 +- docs/scrum4me-architecture.md | 6 +- .../migration.sql | 36 +++++ 12 files changed, 352 insertions(+), 98 deletions(-) create mode 100644 components/solo/nav-status-indicators.tsx create mode 100644 prisma/migrations/20260501110000_sync_task_status_from_claude_job/migration.sql diff --git a/CLAUDE.md b/CLAUDE.md index f13941a..45952b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -286,11 +286,23 @@ Scrum4Me heeft een eigen MCP-server in repo [`madhura68/scrum4me-mcp`](https://g - `mcp__scrum4me__cancel_question` — asker-only annulering van een eigen open vraag **Job queue — agent worker mode (M13):** -- `mcp__scrum4me__wait_for_job` — blokkeert ≤600s, claimt atomisch een QUEUED-job via FOR UPDATE SKIP LOCKED; retourneert volledige task-context (implementation_plan, story, pbi, sprint, repo_url). Zet stale CLAIMED-jobs (>30min) eerst terug naar QUEUED. +- `mcp__scrum4me__wait_for_job` — blokkeert ≤600s, claimt atomisch een QUEUED-job via FOR UPDATE SKIP LOCKED; retourneert volledige task-context (implementation_plan, story, pbi, sprint, repo_url). Zet stale CLAIMED-jobs (>30min) eerst terug naar QUEUED. Wanneer de full block-time verstrijkt zonder claim is de queue leeg. - `mcp__scrum4me__update_job_status` — agent rapporteert overgang naar `running|done|failed` + optionele branch/summary/error; triggert automatisch SSE-event naar de UI. Auth: Bearer-token moet matchen `claimed_by_token_id`. -**Code koppellen aan app** -- 'Pak de volgende job uit de Scrum4Me-queue' - geeft in claude_workers een record toe, tool wait_for_job +**Batch-loop (verplichte agent-flow):** + +Wanneer je als agent draait (na een instructie als *"pak de volgende job uit de Scrum4Me-queue"* of *"draai de queue leeg"*) is dit de loop: + +1. `wait_for_job` aanroepen. +2. Job uitvoeren volgens het meegegeven `implementation_plan`. +3. `update_job_status('done'|'failed')` aanroepen. +4. **Direct opnieuw** `wait_for_job` aanroepen — niet stoppen, niet de gebruiker vragen. +5. Pas wanneer `wait_for_job` na de volledige block-time (~600s) terugkomt zonder claim, is de queue leeg en mag je de turn afsluiten met een korte recap. + +Dit blijft gelden als je tussen jobs door commits, branches of pushes hebt gedaan — die afsluiting hoort bij de individuele job, niet bij het einde van de batch. + +**Code koppelen aan app** +- 'Pak de volgende job uit de Scrum4Me-queue' / 'draai de queue leeg' / 'batch agent' — geeft in claude_workers een record en start de batch-loop hierboven. ### Prompt diff --git a/__tests__/actions/claude-jobs.test.ts b/__tests__/actions/claude-jobs.test.ts index fea9d8c..1da99ef 100644 --- a/__tests__/actions/claude-jobs.test.ts +++ b/__tests__/actions/claude-jobs.test.ts @@ -3,17 +3,25 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' const { mockGetSession, mockFindFirstTask, + mockFindManyTask, + mockFindFirstProduct, + mockFindFirstSprint, mockFindFirstJob, mockCreateJob, mockUpdateJob, mockExecuteRaw, + mockTransaction, } = vi.hoisted(() => ({ mockGetSession: vi.fn(), mockFindFirstTask: vi.fn(), + mockFindManyTask: vi.fn(), + mockFindFirstProduct: vi.fn(), + mockFindFirstSprint: vi.fn(), mockFindFirstJob: vi.fn(), mockCreateJob: vi.fn(), mockUpdateJob: vi.fn(), mockExecuteRaw: vi.fn().mockResolvedValue(undefined), + mockTransaction: vi.fn(), })) vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) @@ -24,17 +32,24 @@ vi.mock('@/lib/auth', () => ({ vi.mock('@/lib/prisma', () => ({ prisma: { - task: { findFirst: mockFindFirstTask }, + task: { findFirst: mockFindFirstTask, findMany: mockFindManyTask }, + product: { findFirst: mockFindFirstProduct }, + sprint: { findFirst: mockFindFirstSprint }, claudeJob: { findFirst: mockFindFirstJob, create: mockCreateJob, update: mockUpdateJob, }, $executeRaw: mockExecuteRaw, + $transaction: mockTransaction, }, })) -import { enqueueClaudeJobAction, cancelClaudeJobAction } from '@/actions/claude-jobs' +import { + enqueueClaudeJobAction, + enqueueAllTodoJobsAction, + cancelClaudeJobAction, +} from '@/actions/claude-jobs' const SESSION_USER = { userId: 'user-1', isDemo: false } @@ -108,6 +123,76 @@ describe('enqueueClaudeJobAction', () => { }) }) +describe('enqueueAllTodoJobsAction', () => { + it('happy path: scopes to active sprint + assignee, queues all queueable tasks', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) + mockFindManyTask.mockResolvedValue([{ id: 'task-a' }, { id: 'task-b' }]) + mockTransaction.mockResolvedValue([ + { id: 'job-a', task_id: 'task-a' }, + { id: 'job-b', task_id: 'task-b' }, + ]) + + const result = await enqueueAllTodoJobsAction(PRODUCT_ID) + + expect(result).toEqual({ success: true, count: 2 }) + expect(mockFindManyTask).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: 'TO_DO', + story: { sprint_id: 'sprint-1', assignee_id: SESSION_USER.userId }, + }), + }) + ) + expect(mockExecuteRaw).toHaveBeenCalledTimes(2) + }) + + it('returns count=0 when product has no active sprint', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + mockFindFirstSprint.mockResolvedValue(null) + + const result = await enqueueAllTodoJobsAction(PRODUCT_ID) + + expect(result).toEqual({ success: true, count: 0 }) + expect(mockFindManyTask).not.toHaveBeenCalled() + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('returns count=0 when no queueable tasks in sprint+assignee scope', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) + mockFindManyTask.mockResolvedValue([]) + + const result = await enqueueAllTodoJobsAction(PRODUCT_ID) + + expect(result).toEqual({ success: true, count: 0 }) + expect(mockTransaction).not.toHaveBeenCalled() + expect(mockExecuteRaw).not.toHaveBeenCalled() + }) + + it('blocks demo user', async () => { + mockGetSession.mockResolvedValue(SESSION_DEMO) + + const result = await enqueueAllTodoJobsAction(PRODUCT_ID) + + expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' }) + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('returns error when product not accessible', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue(null) + + const result = await enqueueAllTodoJobsAction(PRODUCT_ID) + + expect(result).toMatchObject({ error: 'Geen toegang tot dit product' }) + expect(mockTransaction).not.toHaveBeenCalled() + }) +}) + describe('cancelClaudeJobAction', () => { it('happy path: cancels QUEUED job', async () => { mockGetSession.mockResolvedValue(SESSION_USER) diff --git a/actions/claude-jobs.ts b/actions/claude-jobs.ts index fa9a1e8..17c55d3 100644 --- a/actions/claude-jobs.ts +++ b/actions/claude-jobs.ts @@ -10,6 +10,10 @@ type EnqueueResult = | { success: true; jobId: string } | { error: string; jobId?: string } +type EnqueueAllResult = + | { success: true; count: number } + | { error: string } + type CancelResult = { success: true } | { error: string } export async function enqueueClaudeJobAction(taskId: string): Promise { @@ -59,6 +63,68 @@ export async function enqueueClaudeJobAction(taskId: string): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + if (!productId) return { error: 'product_id is verplicht' } + + const product = await prisma.product.findFirst({ + where: { id: productId, ...productAccessFilter(session.userId) }, + select: { id: true }, + }) + if (!product) return { error: 'Geen toegang tot dit product' } + + const userId = session.userId + + // Match het scope dat de gebruiker op het Solo Paneel ziet: + // alleen TO_DO-taken in de actieve sprint, in stories die aan deze + // gebruiker zijn toegewezen. Anders queue je per ongeluk taken die + // niet in de huidige sprint zitten of aan iemand anders toebehoren. + const sprint = await prisma.sprint.findFirst({ + where: { product_id: productId, status: 'ACTIVE' }, + select: { id: true }, + }) + if (!sprint) return { success: true, count: 0 } + + const tasks = await prisma.task.findMany({ + where: { + status: 'TO_DO', + story: { sprint_id: sprint.id, assignee_id: userId }, + claude_jobs: { none: { status: { in: ACTIVE_JOB_STATUSES } } }, + }, + select: { id: true }, + }) + + if (tasks.length === 0) return { success: true, count: 0 } + + const created = await prisma.$transaction( + tasks.map(t => + prisma.claudeJob.create({ + data: { user_id: userId, product_id: productId, task_id: t.id, status: 'QUEUED' }, + select: { id: true, task_id: true }, + }) + ) + ) + + for (const job of created) { + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'claude_job_enqueued', + job_id: job.id, + task_id: job.task_id, + user_id: userId, + product_id: productId, + status: 'queued', + })}::text) + ` + } + + revalidatePath(`/products/${productId}/solo`) + return { success: true, count: created.length } +} + export async function cancelClaudeJobAction(jobId: string): Promise { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index fa41d4a..384828b 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -92,7 +92,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod {children} - + diff --git a/app/(app)/products/[id]/solo/page.tsx b/app/(app)/products/[id]/solo/page.tsx index 995aee2..980bcc0 100644 --- a/app/(app)/products/[id]/solo/page.tsx +++ b/app/(app)/products/[id]/solo/page.tsx @@ -105,7 +105,6 @@ export default async function SoloProductPage({ params }: Props) { return ( - {/* Rechts: notifications + account-menu */} + {/* Rechts: solo-status + notifications + account-menu */}
+
diff --git a/components/solo/nav-status-indicators.tsx b/components/solo/nav-status-indicators.tsx new file mode 100644 index 0000000..e370540 --- /dev/null +++ b/components/solo/nav-status-indicators.tsx @@ -0,0 +1,65 @@ +'use client' + +import { useSoloStore } from '@/stores/solo-store' +import type { RealtimeStatus } from '@/stores/solo-store' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' + +function RealtimeIndicator({ + status, + showConnectingIndicator, +}: { + status: RealtimeStatus + showConnectingIndicator: boolean +}) { + let color = 'bg-status-done' + let label = 'Live' + if (showConnectingIndicator) { + if (status === 'disconnected') { + color = 'bg-priority-critical' + label = 'Verbroken — opnieuw proberen…' + } else { + color = 'bg-muted-foreground' + label = 'Verbinden…' + } + } + return ( + + + + } + /> + {label} + + + ) +} + +export function SoloNavStatusIndicators({ hasActiveProduct }: { hasActiveProduct: boolean }) { + const realtimeStatus = useSoloStore((s) => s.realtimeStatus) + const showConnectingIndicator = useSoloStore((s) => s.showConnectingIndicator) + const connectedWorkers = useSoloStore((s) => s.connectedWorkers) + + if (!hasActiveProduct) return null + + return ( +
+ +
+ 0 ? 'bg-status-done' : 'bg-muted-foreground/40' + )} /> + {connectedWorkers > 0 ? 'Agent verbonden' : 'Geen agent'} +
+
+ ) +} diff --git a/components/solo/realtime-bridge.tsx b/components/solo/realtime-bridge.tsx index bd04f69..37cc14b 100644 --- a/components/solo/realtime-bridge.tsx +++ b/components/solo/realtime-bridge.tsx @@ -1,21 +1,17 @@ // SoloRealtimeBridge — mount in de (app)-layout zodat de SSE-verbinding -// blijft staan over Server Action-refreshes van de Solo-page heen. +// blijft staan over Server Action-refreshes heen. // -// Leest het huidige product-id uit de URL (`/products/[id]/solo`). -// Wanneer de gebruiker niet op het Solo Paneel zit, wordt de stream -// gesloten — geen onnodige verbinding open houden. +// Stream opent zodra er een actief product is (ongeacht het pad), zodat +// de Live-status-dot en worker-presence-indicator in de NavBar overal +// werken. Buiten /solo is de solo-store leeg en zijn task-events no-ops +// (zie stores/solo-store.ts handleRealtimeEvent), dus de stream gedraagt +// zich automatisch als lichte presence-stream tot SoloBoard mount. 'use client' -import { usePathname } from 'next/navigation' import { useSoloRealtime } from '@/lib/realtime/use-solo-realtime' -const SOLO_PATH_RE = /^\/products\/([^/]+)\/solo$/ - -export function SoloRealtimeBridge() { - const pathname = usePathname() - const match = pathname?.match(SOLO_PATH_RE) - const productId = match?.[1] ?? null +export function SoloRealtimeBridge({ productId }: { productId: string | null }) { useSoloRealtime(productId) return null } diff --git a/components/solo/solo-board.tsx b/components/solo/solo-board.tsx index fb50be8..83d220e 100644 --- a/components/solo/solo-board.tsx +++ b/components/solo/solo-board.tsx @@ -7,54 +7,16 @@ import { } from '@dnd-kit/core' import { toast } from 'sonner' import { useSoloStore } from '@/stores/solo-store' -import type { RealtimeStatus } from '@/stores/solo-store' import { taskStatusToApi } from '@/lib/task-status' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' +import { enqueueAllTodoJobsAction } from '@/actions/claude-jobs' +import { Button } from '@/components/ui/button' +import { DemoTooltip } from '@/components/shared/demo-tooltip' +import { SplitPane } from '@/components/split-pane/split-pane' import { SoloColumn, type ColumnStatus } from './solo-column' import { SoloTaskCardOverlay } from './solo-task-card' import { TaskDetailDialog } from './task-detail-dialog' import { UnassignedStoriesSheet, type UnassignedStory } from './unassigned-stories-sheet' -// ST-805: kleine status-dot in de header — groen wanneer SSE-stream open -// is, grijs/rood pas zichtbaar als de connectie >4s niet open is (animatie B -// zit in useSoloRealtime). Default groen tijdens de eerste 4s zodat micro- -// disconnects geen flikker geven. -function RealtimeIndicator({ - status, - showConnectingIndicator, -}: { - status: RealtimeStatus - showConnectingIndicator: boolean -}) { - let color = 'bg-status-done' - let label = 'Live' - if (showConnectingIndicator) { - if (status === 'disconnected') { - color = 'bg-priority-critical' - label = 'Verbroken — opnieuw proberen…' - } else { - color = 'bg-muted-foreground' - label = 'Verbinden…' - } - } - return ( - - - - } - /> - {label} - - - ) -} - export interface SoloTask { id: string title: string @@ -71,7 +33,6 @@ export interface SoloTask { export interface SoloBoardProps { productId: string - productName: string sprintGoal: string tasks: SoloTask[] unassignedStories: UnassignedStory[] @@ -87,17 +48,16 @@ function getColumnStatus(status: SoloTask['status']): ColumnStatus { } export function SoloBoard({ - productId, productName, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, + productId, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, }: SoloBoardProps) { const { tasks, initTasks, optimisticMove, rollback, markPending, clearPending } = useSoloStore() - const realtimeStatus = useSoloStore((s) => s.realtimeStatus) - const showConnectingIndicator = useSoloStore((s) => s.showConnectingIndicator) - const connectedWorkers = useSoloStore((s) => s.connectedWorkers) + const claudeJobsByTaskId = useSoloStore((s) => s.claudeJobsByTaskId) const [activeDragId, setActiveDragId] = useState(null) const [selectedTask, setSelectedTask] = useState(null) const [sheetOpen, setSheetOpen] = useState(false) const [unassignedStories, setUnassignedStories] = useState(initialUnassigned) const [, startTransition] = useTransition() + const [batchPending, startBatchTransition] = useTransition() const taskKey = initialTasks.map(t => t.id).join(',') useEffect(() => { @@ -169,40 +129,40 @@ export function SoloBoard({ const activeTask = activeDragId ? tasks[activeDragId] : null - const columns = ( -
- {COLUMN_STATUSES.map(status => ( - setSelectedTask(t)} - /> - ))} -
- ) + const queueableCount = columnTasks.TO_DO.filter(t => { + const job = claudeJobsByTaskId[t.id] + return !job || (job.status !== 'queued' && job.status !== 'claimed' && job.status !== 'running') + }).length + + function handleStartAll() { + if (queueableCount === 0) return + startBatchTransition(async () => { + const result = await enqueueAllTodoJobsAction(productId) + 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`) + } + }) + } return (
-
-
-

{productName}

- -
- 0 ? 'bg-status-done' : 'bg-muted-foreground/40' - )} /> - {connectedWorkers > 0 ? 'Agent verbonden' : 'Geen agent'} -
-
+
+ + + {sprintGoal && ( -

{sprintGoal}

+

{sprintGoal}

)}