* 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>
235 lines
8.1 KiB
TypeScript
235 lines
8.1 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState, useTransition } from 'react'
|
|
import {
|
|
DndContext, DragEndEvent, DragOverlay, DragStartEvent,
|
|
PointerSensor, useSensor, useSensors, closestCorners,
|
|
} from '@dnd-kit/core'
|
|
import { toast } from 'sonner'
|
|
import { useSoloStore } from '@/stores/solo-store'
|
|
import { taskStatusToApi } from '@/lib/task-status'
|
|
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'
|
|
|
|
export interface SoloTask {
|
|
id: string
|
|
title: string
|
|
description: string | null
|
|
implementation_plan: string | null
|
|
priority: number
|
|
sort_order: number
|
|
status: 'TO_DO' | 'IN_PROGRESS' | 'REVIEW' | 'DONE'
|
|
story_id: string
|
|
story_code: string | null
|
|
story_title: string
|
|
task_code: string | null
|
|
}
|
|
|
|
export interface SoloBoardProps {
|
|
productId: string
|
|
sprintGoal: string
|
|
tasks: SoloTask[]
|
|
unassignedStories: UnassignedStory[]
|
|
isDemo: boolean
|
|
currentUserId: string
|
|
}
|
|
|
|
const COLUMN_STATUSES: ColumnStatus[] = ['TO_DO', 'IN_PROGRESS', 'DONE']
|
|
|
|
function getColumnStatus(status: SoloTask['status']): ColumnStatus {
|
|
if (status === 'REVIEW') return 'IN_PROGRESS'
|
|
return status
|
|
}
|
|
|
|
export function SoloBoard({
|
|
productId, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo,
|
|
}: SoloBoardProps) {
|
|
const { tasks, initTasks, optimisticMove, rollback, markPending, clearPending } = useSoloStore()
|
|
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(() => {
|
|
initTasks(initialTasks)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [taskKey])
|
|
|
|
const pointerSensor = useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
|
|
const sensors = useSensors(...(isDemo ? [] : [pointerSensor]))
|
|
|
|
const taskList = Object.values(tasks)
|
|
const columnTasks: Record<ColumnStatus, SoloTask[]> = {
|
|
TO_DO: taskList.filter(t => getColumnStatus(t.status) === 'TO_DO'),
|
|
IN_PROGRESS: taskList.filter(t => getColumnStatus(t.status) === 'IN_PROGRESS'),
|
|
DONE: taskList.filter(t => getColumnStatus(t.status) === 'DONE'),
|
|
}
|
|
|
|
function handleDragStart(event: DragStartEvent) {
|
|
setActiveDragId(event.active.id as string)
|
|
}
|
|
|
|
function handleDragEnd(event: DragEndEvent) {
|
|
setActiveDragId(null)
|
|
const { active, over } = event
|
|
if (!over) return
|
|
|
|
const toStatus = over.id as ColumnStatus
|
|
if (!COLUMN_STATUSES.includes(toStatus)) return
|
|
|
|
const taskId = active.id as string
|
|
const task = tasks[taskId]
|
|
if (!task) return
|
|
if (getColumnStatus(task.status) === toStatus) return
|
|
|
|
const prevStatus = optimisticMove(taskId, toStatus)
|
|
if (!prevStatus) return
|
|
|
|
// Onderdruk realtime-echo van onze eigen write — de Postgres-trigger
|
|
// vuurt en die NOTIFY komt zo terug via SSE; zonder pending-marker
|
|
// zou de store nogmaals een set() doen of de optimistic state
|
|
// overschrijven. clearPending na de fetch (succes of fail).
|
|
//
|
|
// We gebruiken bewust een fetch-based Route Handler in plaats van
|
|
// de updateTaskStatusAction Server Action — Server Actions
|
|
// triggeren een full route-tree refresh die de open SSE-stream van
|
|
// /api/realtime/solo zou afkappen, waardoor we elke 5s reconnecten
|
|
// en realtime-events missen.
|
|
markPending(taskId)
|
|
startTransition(async () => {
|
|
try {
|
|
const res = await fetch(`/api/tasks/${taskId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ status: taskStatusToApi(toStatus) }),
|
|
})
|
|
if (!res.ok) {
|
|
rollback(taskId, prevStatus)
|
|
toast.error('Status bijwerken mislukt — taak teruggeplaatst')
|
|
}
|
|
} catch {
|
|
rollback(taskId, prevStatus)
|
|
toast.error('Status bijwerken mislukt — taak teruggeplaatst')
|
|
} finally {
|
|
clearPending(taskId)
|
|
}
|
|
})
|
|
}
|
|
|
|
const activeTask = activeDragId ? tasks[activeDragId] : null
|
|
|
|
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 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 line-clamp-2 min-w-0">{sprintGoal}</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
className="text-sm text-primary hover:underline whitespace-nowrap shrink-0 disabled:text-muted-foreground disabled:cursor-default"
|
|
disabled={unassignedStories.length === 0}
|
|
onClick={() => setSheetOpen(true)}
|
|
>
|
|
Toon openstaande stories ({unassignedStories.length})
|
|
</button>
|
|
</div>
|
|
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCorners}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<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>
|
|
</DndContext>
|
|
|
|
<TaskDetailDialog
|
|
task={selectedTask}
|
|
productId={productId}
|
|
isDemo={isDemo}
|
|
onClose={() => setSelectedTask(null)}
|
|
/>
|
|
|
|
<UnassignedStoriesSheet
|
|
stories={unassignedStories}
|
|
productId={productId}
|
|
isDemo={isDemo}
|
|
open={sheetOpen}
|
|
onOpenChange={setSheetOpen}
|
|
onClaim={(id) => setUnassignedStories(prev => prev.filter(s => s.id !== id))}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|