import { useCallback, useEffect, useRef, useMemo, useState } 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 { AgentOutputViewer } from "@/components/AgentOutputViewer"; 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"; type SlideOverTab = "details" | "logs"; 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 [tab, setTab] = useState("details"); const close = useCallback(() => setSelectedTaskId(null), [setSelectedTaskId]); // Reset tab when task changes useEffect(() => { setTab("details"); }, [selectedEntry?.task?.id]); // 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 | 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 ( {selectedEntry && task && ( <> {/* Backdrop */} { flushDescription(); close(); }} /> {/* Panel */} {/* Header */}

{task.name}

{selectedEntry.phaseName}

{updateTaskMutation.isPending && ( Saving... )}
{/* Tab bar */}
{(["details", "logs"] as const).map((t) => ( ))}
{/* Content */}
{tab === "details" ? (
{/* Metadata grid */}
{task.type} {selectedEntry.agentName ?? "Unassigned"}
{/* Description — editable tiptap */}
{/* Dependencies */}
{dependencies.length === 0 ? (

None

) : (
    {dependencies.map((dep) => (
  • {dep.name}
  • ))}
)}
{/* Blocks */}
{dependents.length === 0 ? (

None

) : (
    {dependents.map((dep) => (
  • {dep.name}
  • ))}
)}
) : ( )}
{/* Footer */}
{task.status === "blocked" ? ( ) : ( )}
)}
); } // --------------------------------------------------------------------------- // Agent Logs Tab // --------------------------------------------------------------------------- function AgentLogsTab({ taskId }: { taskId: string }) { const { data: agent, isLoading } = trpc.getTaskAgent.useQuery( { taskId }, { refetchOnWindowFocus: false }, ); if (isLoading) { return (
Loading...
); } if (!agent) { return (
No agent has been assigned to this task yet.
); } return (
); } // --------------------------------------------------------------------------- // Small helpers // --------------------------------------------------------------------------- function MetaField({ label, span, children, }: { label: string; span?: number; children: React.ReactNode; }) { return (
{label}
{children}
); } function Section({ title, children, }: { title: string; children: React.ReactNode; }) { return (

{title}

{children}
); } function CategoryBadge({ category }: { category: string }) { const config = getCategoryConfig(category); return ( {config.label} ); } function PriorityText({ priority }: { priority: string }) { const color = priority === "high" ? "text-status-error-fg" : priority === "medium" ? "text-status-warning-fg" : "text-muted-foreground"; return ( {priority} ); }