Scrum4Me/components/solo/solo-board.tsx
Janpeter Visser 9794a9baef
M13: Veilige Claude-agent-workflow (Scrum4Me-side) (#26)
* feat: add pushed_at field to ClaudeJob schema

Nullable DateTime column to record when the agent's feature branch was
pushed to origin. Enables the UI to show a 'pushed' state independently
of DONE status.

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

* feat: GitHub-link op DONE-card + pushed_at doorvoer

- lib/job-status-url.ts: getBranchUrl(repoUrl, branch) → GitHub tree URL
- JobState + ClaudeJobEvent: pushed_at? veld toegevoegd
- realtime/solo/route.ts: pushed_at in Prisma-select, JobPayload en mapping
- SoloBoardProps + TaskDetailDialog: repoUrl prop doorgevoerd
- task-detail-dialog: "Open op GitHub"-link als done + pushed_at + branch + repoUrl
- 3 unit-tests voor getBranchUrl; totaal 261 tests groen

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

* feat: add VerifyResult enum, verify_only on Task, verify_result on ClaudeJob

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

* feat: add verify_result+pushed_at to JobState, VerifyResultApi type, SSE payload

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

* feat: verify_only field on SoloTask, PATCH route saves verify_only

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

* feat: TaskDetailDialog — verify_result display + verify_only checkbox

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

* test: verify_only PATCH + verify_result dialog render + store fix

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

* docs: document VerifyResult enum, verify_only task field, pushed_at in architecture

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

* feat(M13): cron /api/cron/cleanup-agent-artifacts — hard-delete FAILED/CANCELLED jobs >7 days

* feat(M13): add auto_pr field to Product schema + migration

* feat(M13): auto_pr toggle in product settings — server action + UI component + tests

* feat(M13): add pr_url to ClaudeJob schema + migration

* feat(M13): UI — 'Open PR' link on DONE-card; pr_url in JobState + SSE + task-dialog

* feat(M13): add retry_count migration + regen erd

- Migration ALTER TABLE claude_jobs ADD COLUMN retry_count INT DEFAULT 0
  (schema.prisma was reeds bijgewerkt in eerdere commits)
- docs/erd.svg geregenereerd voor de complete M13-schema-wijzigingen
  (verify_result, verify_only, pushed_at, pr_url, auto_pr, retry_count)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 13:42:18 +02:00

238 lines
8.2 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'
verify_only: boolean
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
repoUrl?: string | null
}
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, repoUrl,
}: 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}
repoUrl={repoUrl}
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>
)
}