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:
parent
1cb5772edd
commit
73087e9705
18 changed files with 921 additions and 27 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 '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">
|
||||
Klaar{job.branch ? ` — branch ${job.branch}` : ''}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{job?.status === 'failed' && (
|
||||
<span className="text-xs text-error">Mislukt: {job.error}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue