Solo Paneel header refactor + agent-workflow hardening (#24)

* feat: SoloBoard layout naar SplitPane met cookie-persistentie en tab-collapse

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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: <activeSprint>, 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-01 11:41:35 +02:00 committed by GitHub
parent 794f7afd2e
commit 3bb87f17ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 352 additions and 98 deletions

View file

@ -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

View file

@ -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)

View file

@ -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<EnqueueResult> {
@ -59,6 +63,68 @@ export async function enqueueClaudeJobAction(taskId: string): Promise<EnqueueRes
return { success: true, jobId: job.id }
}
export async function enqueueAllTodoJobsAction(productId: string): Promise<EnqueueAllResult> {
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<CancelResult> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }

View file

@ -92,7 +92,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
{children}
</main>
<StatusBar />
<SoloRealtimeBridge />
<SoloRealtimeBridge productId={activeProduct?.id ?? null} />
<NotificationsBridge userId={session.userId} />
<Suspense>
<AlertToast />

View file

@ -105,7 +105,6 @@ export default async function SoloProductPage({ params }: Props) {
return (
<SoloBoard
productId={id}
productName={product.name}
sprintGoal={sprint.sprint_goal}
tasks={tasks}
unassignedStories={unassignedStories}

View file

@ -17,6 +17,7 @@ import {
import { AppIcon } from '@/components/shared/app-icon'
import { UserMenu } from '@/components/shared/user-menu'
import { NotificationsBell } from '@/components/shared/notifications-bell'
import { SoloNavStatusIndicators } from '@/components/solo/nav-status-indicators'
import { cn } from '@/lib/utils'
import { setActiveProductAction } from '@/actions/active-product'
@ -180,8 +181,9 @@ export function NavBar({
)}
</div>
{/* Rechts: notifications + account-menu */}
{/* Rechts: solo-status + notifications + account-menu */}
<div className="flex items-center gap-2 flex-1 justify-end">
<SoloNavStatusIndicators hasActiveProduct={!!activeProduct} />
<NotificationsBell currentUserId={userId} isDemo={isDemo} />
<UserMenu userId={userId} username={username} email={email} roles={roles} />
</div>

View file

@ -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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<span
aria-label={label}
className={cn('inline-block h-2 w-2 rounded-full shrink-0 transition-colors', color)}
/>
}
/>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
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 (
<div className="flex items-center gap-3 px-2">
<RealtimeIndicator
status={realtimeStatus}
showConnectingIndicator={showConnectingIndicator}
/>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span className={cn(
'size-2 rounded-full',
connectedWorkers > 0 ? 'bg-status-done' : 'bg-muted-foreground/40'
)} />
{connectedWorkers > 0 ? 'Agent verbonden' : 'Geen agent'}
</div>
</div>
)
}

View file

@ -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
}

View file

@ -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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<span
aria-label={label}
className={cn('inline-block h-2 w-2 rounded-full shrink-0 transition-colors', color)}
/>
}
/>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
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<string | null>(null)
const [selectedTask, setSelectedTask] = useState<SoloTask | null>(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 = (
<div className="grid grid-cols-3 gap-4 flex-1 min-h-0">
{COLUMN_STATUSES.map(status => (
<SoloColumn
key={status}
status={status}
tasks={columnTasks[status]}
isDemo={isDemo}
onTaskClick={(t) => setSelectedTask(t)}
/>
))}
</div>
)
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 (
<div className="flex flex-col h-full p-4 gap-4 min-h-0">
<div className="flex items-start justify-between gap-4 shrink-0">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-base font-semibold text-foreground truncate">{productName}</h1>
<RealtimeIndicator
status={realtimeStatus}
showConnectingIndicator={showConnectingIndicator}
/>
<div className="flex items-center gap-1 text-xs text-muted-foreground ml-1">
<span className={cn(
'size-2 rounded-full',
connectedWorkers > 0 ? 'bg-status-done' : 'bg-muted-foreground/40'
)} />
{connectedWorkers > 0 ? 'Agent verbonden' : 'Geen agent'}
</div>
</div>
<div className="min-w-0 flex items-center gap-3">
<DemoTooltip show={isDemo}>
<Button
size="sm"
onClick={handleStartAll}
disabled={isDemo || batchPending || queueableCount === 0}
>
{batchPending ? 'Starten…' : `Start agents (${queueableCount})`}
</Button>
</DemoTooltip>
{sprintGoal && (
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">{sprintGoal}</p>
<p className="text-sm text-muted-foreground line-clamp-2 min-w-0">{sprintGoal}</p>
)}
</div>
<button
@ -220,7 +180,36 @@ export function SoloBoard({
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{columns}
<div className="flex-1 min-h-0">
<SplitPane
cookieKey={`solo-${productId}`}
defaultSplit={[33, 33, 34]}
tabLabels={['Te doen', 'Bezig', 'Klaar']}
panes={[
<SoloColumn
key="TO_DO"
status="TO_DO"
tasks={columnTasks.TO_DO}
isDemo={isDemo}
onTaskClick={(t) => setSelectedTask(t)}
/>,
<SoloColumn
key="IN_PROGRESS"
status="IN_PROGRESS"
tasks={columnTasks.IN_PROGRESS}
isDemo={isDemo}
onTaskClick={(t) => setSelectedTask(t)}
/>,
<SoloColumn
key="DONE"
status="DONE"
tasks={columnTasks.DONE}
isDemo={isDemo}
onTaskClick={(t) => setSelectedTask(t)}
/>,
]}
/>
</div>
<DragOverlay>
{activeTask && <SoloTaskCardOverlay task={activeTask} />}
</DragOverlay>

View file

@ -37,7 +37,7 @@ export function SoloColumn({ status, tasks, isDemo, onTaskClick }: SoloColumnPro
<div
ref={setNodeRef}
className={cn(
'flex flex-col rounded-lg border border-border overflow-hidden',
'flex flex-col h-full rounded-lg border border-border overflow-hidden',
isOver && 'ring-2 ring-primary ring-inset',
)}
>

View file

@ -986,7 +986,7 @@ Niet-matchende events worden server-side gedropt zodat de browser geen irrelevan
### Connection lifecycle
- **Open**: `EventSource('/api/realtime/solo?product_id=...')` zodra de gebruiker op `/solo` is.
- **Open**: `EventSource('/api/realtime/solo?product_id=...')` zodra de gebruiker een actief product heeft. `SoloRealtimeBridge` mount in `(app)/layout` en krijgt het `productId` via prop, zodat de stream over de hele app open staat — niet alleen op `/solo`. Zo kunnen de Live-status-dot en worker-presence-indicator in de NavBar overal werken. Buiten `/solo` is de solo-store leeg en zijn binnenkomende task-events no-ops (`stores/solo-store.ts handleRealtimeEvent` skipt onbekende ids), dus de stream gedraagt zich automatisch als lichte presence-stream tot `SoloBoard` mount.
- **Reconnect**: exponential backoff bij `onerror` (1s → 30s, reset bij `ready` event).
- **Pause op tab-hidden**: `document.visibilityState === 'hidden'` sluit de stream actief. Bij `visible` wordt opnieuw verbonden. Dit voorkomt dat inactieve tabs DB-connecties open houden.
- **Hard close**: server sluit zelf na 240s (Vercel `maxDuration` is 300s); client herconnect transparant.
@ -1161,6 +1161,10 @@ UI klikt 'Voer uit'
`enqueueClaudeJobAction` weigert een tweede enqueue als er al een job bestaat met `status IN (QUEUED, CLAIMED, RUNNING)`. Teruggestuurde fout bevat het bestaande `jobId` zodat de UI ernaar kan linken.
### Auto-promote task naar DONE
Wanneer een `claude_job` op `DONE` komt, vuurt de Postgres-trigger `claude_job_status_to_task` (zie `prisma/migrations/20260501110000_sync_task_status_from_claude_job`) en zet de bijbehorende task ook op `DONE`. Werkt voor INSERT (direct als DONE aangemaakt) en UPDATE (transitie naar DONE). Idempotent: skip wanneer de task al DONE is. De bestaande `notify_task_change`-trigger op `tasks` vuurt dan automatisch de pg_notify zodat de Solo-paneel-UI synct — geen extra plumbing in de SSE-handler nodig.
### Hybride-ready
De huidige implementatie verwacht een lokale Claude Code-sessie die `wait_for_job` aanroept vanuit `madhura68/scrum4me-mcp`. Toekomstige uitbreiding naar Vercel Sandbox (serverless agent) vereist alleen een nieuw claim-endpoint — het datamodel en SSE-flow zijn ongewijzigd.

View file

@ -0,0 +1,36 @@
-- Sync task.status to DONE wanneer een ClaudeJob afgerond wordt.
--
-- Wanneer de agent (via MCP `update_job_status('done')` of een Server
-- Action) een ClaudeJob op DONE zet, willen we dat de bijbehorende
-- Task automatisch óók op DONE komt. Anders blijft de kaart in de
-- "Te doen"-kolom staan terwijl de job-badge "Klaar" toont.
--
-- We doen dit op DB-niveau zodat het werkt ongeacht welke client de
-- update doet (MCP-server, Server Action, raw SQL). De bestaande
-- notify_task_change-trigger vuurt automatisch op de task-UPDATE en
-- stuurt de pg_notify naar /api/realtime/solo, dus de UI synct
-- zonder extra plumbing.
--
-- Idempotent: WHERE-clause voorkomt no-op updates die de task-trigger
-- onnodig doen vuren.
CREATE OR REPLACE FUNCTION sync_task_status_from_claude_job() RETURNS trigger AS $$
BEGIN
IF NEW.status = 'DONE' THEN
IF (TG_OP = 'INSERT')
OR (TG_OP = 'UPDATE' AND OLD.status IS DISTINCT FROM 'DONE')
THEN
UPDATE tasks
SET status = 'DONE', updated_at = now()
WHERE id = NEW.task_id AND status <> 'DONE';
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS claude_job_status_to_task ON claude_jobs;
CREATE TRIGGER claude_job_status_to_task
AFTER INSERT OR UPDATE OF status ON claude_jobs
FOR EACH ROW
EXECUTE FUNCTION sync_task_status_from_claude_job();