M13: Claude job queue — 'Voer uit'-knop + worker presence (ST-1111) (#18)

* feat(ST-1111.1): add ClaudeJob model and state-machine enum

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

* feat(ST-1111.2): add ClaudeJob status API mappers

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

* feat(ST-1111.3): add enqueue/cancel ClaudeJob server actions with idempotency + NOTIFY

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

* feat(ST-1111.4): forward ClaudeJob events on solo SSE stream + initial state

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

* feat(ST-1111.6): add 'Voer uit' + cancel buttons to task detail dialog

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

* feat(ST-1111.7): add job status pill with spinner on solo task cards

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

* test(ST-1111.8): cover job-status mappers and enqueue/cancel actions

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

* docs(ST-1111.9): document Claude job queue architecture and agent flow

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

* feat(ST-1111.10a): add ClaudeWorker presence model

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

* feat(ST-1111.10c): forward worker presence events on solo SSE + initial count

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

* feat(ST-1111.10d): show worker presence indicator and gate 'Voer uit' on connected workers

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-29 19:51:48 +02:00 committed by GitHub
parent 1cb5772edd
commit 73087e9705
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 921 additions and 27 deletions

View file

@ -92,6 +92,7 @@ export function SoloBoard({
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 [activeDragId, setActiveDragId] = useState<string | null>(null)
const [selectedTask, setSelectedTask] = useState<SoloTask | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
@ -192,6 +193,13 @@ export function SoloBoard({
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>
{sprintGoal && (
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">{sprintGoal}</p>

View file

@ -3,8 +3,11 @@
import type React from 'react'
import { useDraggable } from '@dnd-kit/core'
import { CSS } from '@dnd-kit/utilities'
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { CodeBadge } from '@/components/shared/code-badge'
import { JOB_STATUS_LABELS, JOB_STATUS_COLORS, JOB_STATUS_ACTIVE } from '@/components/shared/job-status'
import { useSoloStore } from '@/stores/solo-store'
import type { SoloTask } from './solo-board'
const PRIORITY_BORDER: Record<number, string> = {
@ -21,6 +24,7 @@ interface SoloTaskCardProps {
}
export function SoloTaskCard({ task, isDemo, onClick }: SoloTaskCardProps) {
const job = useSoloStore(s => s.claudeJobsByTaskId[task.id])
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: task.id,
disabled: isDemo,
@ -51,10 +55,26 @@ export function SoloTaskCard({ task, isDemo, onClick }: SoloTaskCardProps) {
<p className="text-sm text-foreground leading-snug flex-1">{task.title}</p>
{task.task_code && <CodeBadge code={task.task_code} className="shrink-0 mt-0.5" />}
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{task.story_code && <span className="font-mono mr-1">{task.story_code}</span>}
{task.story_title}
</p>
<div className="flex items-center justify-between gap-2 mt-0.5">
<p className="text-xs text-muted-foreground truncate">
{task.story_code && <span className="font-mono mr-1">{task.story_code}</span>}
{task.story_title}
</p>
{job && (
<span
className={cn(
'text-[10px] px-1.5 py-0 rounded border flex items-center gap-1 shrink-0',
JOB_STATUS_COLORS[job.status],
)}
onClick={(e) => { e.stopPropagation(); onClick() }}
role="button"
aria-label={`Agent-status: ${JOB_STATUS_LABELS[job.status]}`}
>
{JOB_STATUS_ACTIVE.has(job.status) && <Loader2 className="animate-spin" size={8} />}
{JOB_STATUS_LABELS[job.status]}
</span>
)}
</div>
</div>
)
}

View file

@ -5,9 +5,12 @@ import Link from 'next/link'
import { toast } from 'sonner'
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 type { SoloTask } from './solo-board'
@ -43,12 +46,34 @@ type SaveState = 'idle' | 'saving' | 'saved'
function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailContentProps) {
const { updatePlan } = useSoloStore()
const job = useSoloStore(s => s.claudeJobsByTaskId[task.id])
const connectedWorkers = useSoloStore(s => s.connectedWorkers)
const [localPlan, setLocalPlan] = useState(task.implementation_plan ?? '')
const [saveState, setSaveState] = useState<SaveState>('idle')
const [, startTransition] = useTransition()
const [jobPending, startJobTransition] = 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
@ -133,14 +158,61 @@ function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailConte
</div>
</div>
<div className="-mx-4 -mb-4 flex items-center border-t bg-muted/50 px-4 py-3 rounded-b-xl">
<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"
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">
Klaar{job.branch ? ` — branch ${job.branch}` : ''}
</span>
)}
{job?.status === 'failed' && (
<span className="text-xs text-error">Mislukt: {job.error}</span>
)}
</div>
</>
)