398 lines
15 KiB
TypeScript
398 lines
15 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, DialogTitle } from '@/components/ui/dialog'
|
|
import {
|
|
entityDialogBodyClasses,
|
|
entityDialogContentClasses,
|
|
entityDialogFooterClasses,
|
|
} from '@/components/shared/entity-dialog-layout'
|
|
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 { debugProps } from '@/lib/debug'
|
|
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'
|
|
|
|
const VERIFY_REQUIRED_LABELS: Record<string, string> = {
|
|
ALIGNED: 'Strikt — alleen ALIGNED',
|
|
ALIGNED_OR_PARTIAL: 'Standaard — ALIGNED of PARTIAL met uitleg',
|
|
ANY: 'Vrij — geen verify-eis',
|
|
}
|
|
|
|
function TaskDetailContent({ task, productId, isDemo, repoUrl, onClose }: TaskDetailContentProps) {
|
|
const { updatePlan, updateVerifyOnly, updateVerifyRequired } = 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 [localVerifyRequired, setLocalVerifyRequired] = useState(task.verify_required)
|
|
const [saveState, setSaveState] = useState<SaveState>('idle')
|
|
const [, startTransition] = useTransition()
|
|
const [jobPending, startJobTransition] = useTransition()
|
|
const [verifyOnlyPending, startVerifyOnlyTransition] = useTransition()
|
|
const [verifyRequiredPending, startVerifyRequiredTransition] = 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')
|
|
}
|
|
})
|
|
}
|
|
|
|
function handleVerifyRequiredChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
|
if (isDemo) return
|
|
const newValue = e.target.value as typeof localVerifyRequired
|
|
const prevValue = localVerifyRequired
|
|
setLocalVerifyRequired(newValue)
|
|
startVerifyRequiredTransition(async () => {
|
|
try {
|
|
const res = await fetch(`/api/tasks/${task.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ verify_required: newValue }),
|
|
})
|
|
if (!res.ok) {
|
|
setLocalVerifyRequired(prevValue)
|
|
toast.error('Verify-required bijwerken mislukt')
|
|
return
|
|
}
|
|
updateVerifyRequired(task.id, newValue)
|
|
} catch {
|
|
setLocalVerifyRequired(prevValue)
|
|
toast.error('Verify-required bijwerken mislukt')
|
|
}
|
|
})
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="flex flex-col gap-1 px-6 pt-5 pb-4 border-b border-outline-variant shrink-0">
|
|
<div className="flex items-start gap-3">
|
|
<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>
|
|
</div>
|
|
|
|
<div className={entityDialogBodyClasses}>
|
|
{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] max-h-[40vh]"
|
|
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="flex items-center gap-2">
|
|
<span className="text-xs text-muted-foreground shrink-0">Verify-gate:</span>
|
|
<DemoTooltip show={isDemo}>
|
|
<select
|
|
value={localVerifyRequired}
|
|
onChange={handleVerifyRequiredChange}
|
|
disabled={isDemo || verifyRequiredPending}
|
|
className="text-xs rounded-md border border-border bg-surface-container px-2 py-1 text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{(['ALIGNED', 'ALIGNED_OR_PARTIAL', 'ANY'] as const).map(v => (
|
|
<option key={v} value={v}>{VERIFY_REQUIRED_LABELS[v]}</option>
|
|
))}
|
|
</select>
|
|
</DemoTooltip>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={cn(entityDialogFooterClasses, 'flex flex-wrap items-center gap-2')}>
|
|
<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 'wacht op jobs'.
|
|
</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 showCloseButton={false} className={entityDialogContentClasses} {...debugProps('task-detail-dialog', 'TaskDetailDialog', 'components/solo/task-detail-dialog.tsx')}>
|
|
{task && (
|
|
<TaskDetailContent
|
|
key={task.id}
|
|
task={task}
|
|
productId={productId}
|
|
isDemo={isDemo}
|
|
repoUrl={repoUrl}
|
|
onClose={onClose}
|
|
/>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|