Files
Codewalkers/apps/web/src/components/execution/TaskSlideOver.tsx
Lukas May d81e0864f7 feat: Add retry mechanism for blocked tasks
Blocked tasks (from spawn failures) were a dead-end with no way to
recover. Add retryBlockedTask to DispatchManager that resets status
to pending and re-queues, a tRPC mutation that also kicks dispatchNext,
and a Retry button in the task slide-over when status is blocked.
2026-03-05 20:41:49 +01:00

352 lines
12 KiB
TypeScript

import { useCallback, useEffect, useRef, useMemo } from "react";
import { motion, AnimatePresence } from "motion/react";
import { X, Trash2, MessageCircle, RotateCw } from "lucide-react";
import type { ChatTarget } from "@/components/chat/ChatSlideOver";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/StatusBadge";
import { StatusDot } from "@/components/StatusDot";
import { TiptapEditor } from "@/components/editor/TiptapEditor";
import { getCategoryConfig } from "@/lib/category";
import { markdownToTiptapJson } from "@/lib/markdown-to-tiptap";
import { useExecutionContext } from "./ExecutionContext";
import { trpc } from "@/lib/trpc";
import { cn } from "@/lib/utils";
interface TaskSlideOverProps {
onOpenChat?: (target: ChatTarget) => void;
}
export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
const { selectedEntry, setSelectedTaskId } = useExecutionContext();
const queueTaskMutation = trpc.queueTask.useMutation();
const retryBlockedTaskMutation = trpc.retryBlockedTask.useMutation();
const deleteTaskMutation = trpc.deleteTask.useMutation();
const updateTaskMutation = trpc.updateTask.useMutation();
const close = useCallback(() => setSelectedTaskId(null), [setSelectedTaskId]);
// Escape key closes
useEffect(() => {
if (!selectedEntry) return;
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") close();
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [selectedEntry, close]);
// Debounced description save
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingRef = useRef<{ id: string; description: string } | null>(null);
const flushDescription = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (pendingRef.current) {
const data = pendingRef.current;
pendingRef.current = null;
updateTaskMutation.mutate(data);
}
}, [updateTaskMutation]);
const handleDescriptionUpdate = useCallback(
(json: string) => {
const task = selectedEntry?.task;
if (!task) return;
pendingRef.current = { id: task.id, description: json };
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(flushDescription, 1000);
},
[selectedEntry?.task, flushDescription],
);
// Flush on close / unmount
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
if (pendingRef.current) {
const data = pendingRef.current;
pendingRef.current = null;
updateTaskMutation.mutate(data);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const task = selectedEntry?.task ?? null;
const dependencies = selectedEntry?.blockedBy ?? [];
const dependents = selectedEntry?.dependents ?? [];
const allDepsComplete =
dependencies.length === 0 ||
dependencies.every((d) => d.status === "completed");
const canQueue = task !== null && task.status === "pending" && allDepsComplete;
// Convert existing markdown description to tiptap JSON for the editor.
// If it's already valid tiptap JSON, use as-is.
const editorContent = useMemo(() => {
if (!task?.description) return null;
try {
const parsed = JSON.parse(task.description);
if (parsed?.type === "doc") return task.description;
} catch {
// Not JSON — treat as markdown
}
try {
return JSON.stringify(markdownToTiptapJson(task.description));
} catch {
return null;
}
}, [task?.description]);
return (
<AnimatePresence>
{selectedEntry && task && (
<>
{/* Backdrop */}
<motion.div
className="fixed inset-0 z-40 bg-background/60 backdrop-blur-[2px]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={() => {
flushDescription();
close();
}}
/>
{/* Panel */}
<motion.div
className="fixed inset-y-0 right-0 z-50 flex w-full max-w-2xl flex-col border-l border-border bg-background shadow-xl"
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ duration: 0.25, ease: [0, 0, 0.2, 1] }}
>
{/* Header */}
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
<div className="min-w-0 flex-1">
<h3 className="text-base font-semibold leading-snug">
{task.name}
</h3>
<p className="mt-0.5 text-xs text-muted-foreground">
{selectedEntry.phaseName}
</p>
</div>
{updateTaskMutation.isPending && (
<span className="shrink-0 text-[10px] text-muted-foreground">
Saving...
</span>
)}
<button
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => {
flushDescription();
close();
}}
>
<X className="h-4 w-4" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
{/* Metadata grid */}
<div className="grid grid-cols-2 gap-3 text-sm">
<MetaField label="Status">
<StatusBadge status={task.status} />
</MetaField>
<MetaField label="Category">
<CategoryBadge category={task.category} />
</MetaField>
<MetaField label="Priority">
<PriorityText priority={task.priority} />
</MetaField>
<MetaField label="Type">
<span className="font-medium">{task.type}</span>
</MetaField>
<MetaField label="Agent" span={2}>
<span className="font-medium">
{selectedEntry.agentName ?? "Unassigned"}
</span>
</MetaField>
</div>
{/* Description — editable tiptap */}
<Section title="Description">
<TiptapEditor
entityId={task.id}
content={editorContent}
onUpdate={handleDescriptionUpdate}
enablePageLinks={false}
/>
</Section>
{/* Dependencies */}
<Section title="Blocked By">
{dependencies.length === 0 ? (
<p className="text-sm text-muted-foreground">None</p>
) : (
<ul className="space-y-1.5">
{dependencies.map((dep) => (
<li
key={dep.name}
className="flex items-center gap-2 text-sm"
>
<StatusDot status={dep.status} size="sm" />
<span className="min-w-0 flex-1 truncate">
{dep.name}
</span>
</li>
))}
</ul>
)}
</Section>
{/* Blocks */}
<Section title="Blocks">
{dependents.length === 0 ? (
<p className="text-sm text-muted-foreground">None</p>
) : (
<ul className="space-y-1.5">
{dependents.map((dep) => (
<li
key={dep.name}
className="flex items-center gap-2 text-sm"
>
<StatusDot status={dep.status} size="sm" />
<span className="min-w-0 flex-1 truncate">
{dep.name}
</span>
</li>
))}
</ul>
)}
</Section>
</div>
{/* Footer */}
<div className="flex items-center gap-2 border-t border-border px-5 py-3">
{task.status === "blocked" ? (
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => {
retryBlockedTaskMutation.mutate({ taskId: task.id });
close();
}}
>
<RotateCw className="h-3.5 w-3.5" />
Retry
</Button>
) : (
<Button
variant="outline"
size="sm"
disabled={!canQueue}
onClick={() => {
queueTaskMutation.mutate({ taskId: task.id });
close();
}}
>
Queue Task
</Button>
)}
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => {
onOpenChat?.({ type: 'task', id: task.id, name: task.name });
close();
}}
>
<MessageCircle className="h-3.5 w-3.5" />
Chat
</Button>
<Button
variant="destructive"
size="sm"
className="gap-1.5"
onClick={(e) => {
if (
e.shiftKey ||
window.confirm(`Delete "${task.name}"?`)
) {
deleteTaskMutation.mutate({ id: task.id });
close();
}
}}
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}
// ---------------------------------------------------------------------------
// Small helpers
// ---------------------------------------------------------------------------
function MetaField({
label,
span,
children,
}: {
label: string;
span?: number;
children: React.ReactNode;
}) {
return (
<div className={span === 2 ? "col-span-2" : undefined}>
<span className="text-xs text-muted-foreground">{label}</span>
<div className="mt-1">{children}</div>
</div>
);
}
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div>
<h4 className="mb-1.5 text-sm font-medium">{title}</h4>
{children}
</div>
);
}
function CategoryBadge({ category }: { category: string }) {
const config = getCategoryConfig(category);
return (
<Badge variant={config.variant} size="xs">
{config.label}
</Badge>
);
}
function PriorityText({ priority }: { priority: string }) {
const color =
priority === "high"
? "text-status-error-fg"
: priority === "medium"
? "text-status-warning-fg"
: "text-muted-foreground";
return (
<span className={cn("font-medium capitalize", color)}>{priority}</span>
);
}