Scrum4Me/components/solo/task-detail-dialog.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

340 lines
12 KiB
TypeScript

'use client'
import { useRef, useState, useTransition } from 'react'
import Link from 'next/link'
import { toast } from 'sonner'
import { Markdown } from '@/components/markdown'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useSoloStore } from '@/stores/solo-store'
import { enqueueClaudeJobAction, cancelClaudeJobAction } from '@/actions/claude-jobs'
import { cn } from '@/lib/utils'
import { getBranchUrl } from '@/lib/job-status-url'
import type { SoloTask } from './solo-board'
const STATUS_COLORS: Record<string, string> = {
TO_DO: 'bg-status-todo/15 text-status-todo border-status-todo/30',
IN_PROGRESS: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
REVIEW: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
DONE: 'bg-status-done/15 text-status-done border-status-done/30',
}
const STATUS_LABELS: Record<string, string> = {
TO_DO: 'To Do',
IN_PROGRESS: 'Bezig',
REVIEW: 'Review',
DONE: 'Klaar',
}
interface TaskDetailDialogProps {
task: SoloTask | null
productId: string
isDemo: boolean
repoUrl?: string | null
onClose: () => void
}
interface TaskDetailContentProps {
task: SoloTask
productId: string
isDemo: boolean
repoUrl?: string | null
onClose: () => void
}
const VERIFY_RESULT_CONFIG: Record<string, { label: string; className: string }> = {
aligned: { label: 'Aligned', className: 'text-status-done' },
partial: { label: 'Gedeeltelijk', className: 'text-warning' },
divergent: { label: 'Divergent', className: 'text-error' },
empty: { label: 'Geen wijzigingen', className: 'text-muted-foreground' },
}
type SaveState = 'idle' | 'saving' | 'saved'
function TaskDetailContent({ task, productId, isDemo, repoUrl, onClose }: TaskDetailContentProps) {
const { updatePlan, updateVerifyOnly } = useSoloStore()
const job = useSoloStore(s => s.claudeJobsByTaskId[task.id])
const connectedWorkers = useSoloStore(s => s.connectedWorkers)
const [localPlan, setLocalPlan] = useState(task.implementation_plan ?? '')
const [localVerifyOnly, setLocalVerifyOnly] = useState(task.verify_only)
const [saveState, setSaveState] = useState<SaveState>('idle')
const [, startTransition] = useTransition()
const [jobPending, startJobTransition] = useTransition()
const [verifyOnlyPending, startVerifyOnlyTransition] = useTransition()
const fadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const savedPlanRef = useRef(task.implementation_plan ?? '')
function handleEnqueue() {
startJobTransition(async () => {
const result = await enqueueClaudeJobAction(task.id)
if ('error' in result) {
toast.error(result.error)
} else {
toast.success('Agent ingeschakeld')
}
})
}
function handleCancel() {
if (!job) return
startJobTransition(async () => {
const result = await cancelClaudeJobAction(job.job_id)
if ('error' in result) toast.error(result.error)
})
}
function handleBlur() {
if (isDemo || localPlan === savedPlanRef.current) return
setSaveState('saving')
if (fadeTimer.current) clearTimeout(fadeTimer.current)
// fetch naar Route Handler i.p.v. Server Action — Server Actions
// kappen anders de open SSE-stream van het Solo Paneel af. Zie
// notitie in solo-board.tsx handleDragEnd.
startTransition(async () => {
try {
const res = await fetch(`/api/tasks/${task.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ implementation_plan: localPlan }),
})
if (!res.ok) {
setSaveState('idle')
toast.error('Implementatieplan opslaan mislukt')
return
}
savedPlanRef.current = localPlan
updatePlan(task.id, localPlan || null)
setSaveState('saved')
fadeTimer.current = setTimeout(() => setSaveState('idle'), 2000)
} catch {
setSaveState('idle')
toast.error('Implementatieplan opslaan mislukt')
}
})
}
function handleVerifyOnlyToggle() {
if (isDemo) return
const newValue = !localVerifyOnly
setLocalVerifyOnly(newValue)
startVerifyOnlyTransition(async () => {
try {
const res = await fetch(`/api/tasks/${task.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ verify_only: newValue }),
})
if (!res.ok) {
setLocalVerifyOnly(!newValue)
toast.error('Verify-only bijwerken mislukt')
return
}
updateVerifyOnly(task.id, newValue)
} catch {
setLocalVerifyOnly(!newValue)
toast.error('Verify-only bijwerken mislukt')
}
})
}
return (
<>
<DialogHeader>
<div className="flex items-start gap-3 pr-8">
<DialogTitle className="text-sm font-medium leading-snug flex-1">
{task.title}
</DialogTitle>
{task.task_code && (
<span className="font-mono text-[11px] text-muted-foreground border border-border rounded-md bg-surface-container px-1.5 py-0.5 shrink-0">
{task.task_code}
</span>
)}
<Badge className={cn('text-xs border shrink-0', STATUS_COLORS[task.status])}>
{STATUS_LABELS[task.status]}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{task.story_code && <span className="font-mono mr-1">{task.story_code}</span>}
{task.story_title}
</p>
</DialogHeader>
{task.description && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5">Beschrijving</p>
<Markdown className="text-foreground">{task.description}</Markdown>
</div>
)}
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5">Implementatieplan</p>
<DemoTooltip show={isDemo}>
<Textarea
value={localPlan}
onChange={(e) => setLocalPlan(e.target.value)}
onBlur={handleBlur}
placeholder="Voeg een implementatieplan toe…"
className="resize-none text-sm min-h-[120px]"
readOnly={isDemo}
/>
</DemoTooltip>
<div className="flex justify-end mt-1 h-4">
{saveState === 'saving' && (
<span className="text-xs text-muted-foreground">Bezig met opslaan</span>
)}
{saveState === 'saved' && (
<span className="text-xs text-status-done">Opgeslagen</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<DemoTooltip show={isDemo}>
<button
type="button"
role="checkbox"
aria-checked={localVerifyOnly}
onClick={handleVerifyOnlyToggle}
disabled={isDemo || verifyOnlyPending}
className={cn(
'h-4 w-4 rounded border border-border flex items-center justify-center shrink-0',
'disabled:cursor-not-allowed disabled:opacity-50',
localVerifyOnly && 'bg-primary border-primary',
)}
>
{localVerifyOnly && (
<svg className="h-3 w-3 text-primary-foreground" viewBox="0 0 12 12" fill="none">
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</button>
</DemoTooltip>
<span className="text-xs text-muted-foreground">Alleen verifiëren (niet implementeren)</span>
</div>
<div className="-mx-4 -mb-4 flex flex-wrap items-center gap-2 border-t bg-muted/50 px-4 py-3 rounded-b-xl">
<Link
href={`/products/${productId}/sprint/planning`}
className="text-xs text-primary hover:underline mr-auto"
onClick={onClose}
>
Open in Sprint Board
</Link>
{!isDemo && !job && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
size="sm"
className="h-7 text-xs"
onClick={handleEnqueue}
disabled={jobPending || connectedWorkers === 0}
>
Voer uit
</Button>
}
/>
{connectedWorkers === 0 && (
<TooltipContent side="top" className="max-w-xs text-xs">
Geen Claude Code-sessie verbonden. Start claude lokaal en zeg &apos;wacht op jobs&apos;.
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)}
{job?.status === 'queued' && (
<span className="text-xs text-muted-foreground">Wacht op agent</span>
)}
{(job?.status === 'claimed' || job?.status === 'running') && (
<>
<span className="text-xs text-muted-foreground">Bezig: {job.summary ?? '…'}</span>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={handleCancel} disabled={jobPending}>
Annuleer
</Button>
</>
)}
{job?.status === 'done' && (
<span className="text-xs text-status-done flex items-center gap-2 flex-wrap">
Klaar{job.branch && !job.pushed_at ? ` — branch ${job.branch}` : ''}
{job.pr_url && (
<a
href={job.pr_url}
target="_blank"
rel="noopener noreferrer"
className="underline text-primary hover:text-primary/80"
>
Open PR
</a>
)}
{!job.pr_url && job.pushed_at && job.branch && repoUrl && (
<a
href={getBranchUrl(repoUrl, job.branch)}
target="_blank"
rel="noopener noreferrer"
className="underline text-primary hover:text-primary/80"
>
Open op GitHub
</a>
)}
{job.verify_result && (() => {
const cfg = VERIFY_RESULT_CONFIG[job.verify_result]
return cfg ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<span className={cn('font-medium cursor-default', cfg.className)}>
{cfg.label}
</span>
} />
<TooltipContent side="top" className="max-w-xs text-xs">
{job.verify_result === 'aligned' && 'De implementatie komt overeen met het plan.'}
{job.verify_result === 'partial' && 'De implementatie wijkt gedeeltelijk af van het plan.'}
{job.verify_result === 'divergent' && 'De implementatie wijkt significant af van het plan.'}
{job.verify_result === 'empty' && 'Er zijn geen codewijzigingen gedetecteerd.'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null
})()}
</span>
)}
{job?.status === 'failed' && (
<span className="text-xs text-error">Mislukt: {job.error}</span>
)}
</div>
</>
)
}
export function TaskDetailDialog({ task, productId, isDemo, repoUrl, onClose }: TaskDetailDialogProps) {
return (
<Dialog open={!!task} onOpenChange={(open) => { if (!open) onClose() }}>
<DialogContent className="sm:max-w-lg">
{task && (
<TaskDetailContent
key={task.id}
task={task}
productId={productId}
isDemo={isDemo}
repoUrl={repoUrl}
onClose={onClose}
/>
)}
</DialogContent>
</Dialog>
)
}