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.
352 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|