"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { useParams } from "next/navigation"; import useSWR from "swr"; import AppShell from "@/components/AppShell"; import TaskLiveStatus from "@/components/TaskLiveStatus"; import { ConfirmDialog, Modal, PageHeader, StatusBadge } from "@/components/ui"; import { useToast } from "@/components/toast"; import { api, fetcher, wsUrl } from "@/lib/api"; import { useAuth, roleAtLeast } from "@/lib/auth"; import { TaskEvent } from "@/lib/task-types"; import { LlmUsage, RunHistory, TaskArtifactsPanel, TaskCostPanel, TaskReportPanel, TaskRunHistoryPanel, TaskScopeCard, TaskTimelinePanel, } from "@/components/task-detail-panels"; const MAX_EVENTS = 600; const ACTIVE_STATUSES = ["running", "queued", "waiting_approval"]; type Event = TaskEvent; type Approval = { id: number; tool_name: string; tool_args: any; risk: string | null; status: string; }; type Task = { id: number; title: string; issue: string; status: string; report: any; error: string | null; memory_id: string | null; obsidian_path: string | null; vessel_id: number | null; vessel_name?: string | null; events?: Event[]; }; export default function TaskDetailPage() { const params = useParams(); const id = params.id as string; const { user } = useAuth(); const canAct = roleAtLeast(user?.role, "support"); const toast = useToast(); // Poll only while the task is active; stop once it settles (WS drives live updates). const taskActive = (data?: Task) => !!data && ACTIVE_STATUSES.includes(data.status); const { data: task, error: taskError, isLoading: taskLoading, mutate } = useSWR( `/api/tasks/${id}`, fetcher, { refreshInterval: (d) => (taskActive(d) ? 8000 : 0) } ); const { data: approvals, mutate: mutateApprovals } = useSWR( `/api/tasks/${id}/approvals`, fetcher, { refreshInterval: (d) => ((d || []).some((a) => a.status === "pending") ? 8000 : 0) } ); const [events, setEvents] = useState([]); const [wsConnected, setWsConnected] = useState(false); const [continueOpen, setContinueOpen] = useState(false); const [retryOpen, setRetryOpen] = useState(false); const [cancelOpen, setCancelOpen] = useState(false); const [followUp, setFollowUp] = useState(""); const [actionError, setActionError] = useState(null); const [acting, setActing] = useState(false); const [decidingId, setDecidingId] = useState(null); const [pinnedToBottom, setPinnedToBottom] = useState(true); const endRef = useRef(null); const scrollBoxRef = useRef(null); const appendEvent = useCallback((ev: Event) => { setEvents((prev) => { const key = `${ev.id || ""}-${ev.ts || ev.created_at || ""}-${ev.kind}-${ev.message || ""}`; if (prev.some((item) => `${item.id || ""}-${item.ts || item.created_at || ""}-${item.kind}-${item.message || ""}` === key)) { return prev; } const next = [...prev, ev]; return next.length > MAX_EVENTS ? next.slice(next.length - MAX_EVENTS) : next; }); }, []); useEffect(() => { if (!task?.events?.length) return; setEvents((prev) => { if (prev.length > 0) return prev; return task.events!.slice(-MAX_EVENTS); }); }, [task?.events]); // Hydrate historical events once (so joining mid/post-run shows context), // then open a WebSocket with exponential-backoff reconnect. useEffect(() => { let ws: WebSocket | null = null; let closed = false; let retry = 0; let timer: ReturnType; setEvents([]); const connect = () => { ws = new WebSocket(wsUrl(`/api/tasks/${id}/stream`)); ws.onopen = () => { retry = 0; setWsConnected(true); }; ws.onmessage = (msg) => { try { const ev = JSON.parse(msg.data) as Event; appendEvent(ev); if (ev.kind === "approval") { mutateApprovals(); mutate(); } if (ev.kind === "report" || ev.kind === "error" || ev.kind === "cancelled") { mutate(); } } catch { // ignore malformed frames } }; ws.onclose = () => { setWsConnected(false); if (closed) return; retry += 1; const delay = Math.min(1000 * 2 ** retry, 15000); timer = setTimeout(connect, delay); }; ws.onerror = () => ws?.close(); }; connect(); return () => { closed = true; clearTimeout(timer); ws?.close(); }; }, [id, appendEvent, mutate, mutateApprovals]); useEffect(() => { if (pinnedToBottom) endRef.current?.scrollIntoView({ behavior: "smooth" }); }, [events, pinnedToBottom]); const onScroll = () => { const el = scrollBoxRef.current; if (!el) return; const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80; setPinnedToBottom(nearBottom); }; const decide = async (approvalId: number, approve: boolean) => { setDecidingId(approvalId); try { await api(`/api/tasks/${id}/approvals/${approvalId}`, { method: "POST", body: JSON.stringify({ approve }), }); toast.success(approve ? "Approved" : "Rejected"); mutateApprovals(); mutate(); } catch (err: any) { toast.error(err.message || "Action failed"); } finally { setDecidingId(null); } }; const continueTask = async (e: React.FormEvent) => { e.preventDefault(); setActionError(null); setActing(true); try { await api(`/api/tasks/${id}/continue`, { method: "POST", body: JSON.stringify({ follow_up: followUp }), }); setContinueOpen(false); setFollowUp(""); setEvents([]); toast.success("Follow-up run queued"); mutate(); } catch (err: any) { setActionError(err.message); toast.error(err.message || "Could not continue task"); } finally { setActing(false); } }; const retryTask = async () => { setActing(true); try { await api(`/api/tasks/${id}/retry`, { method: "POST" }); setEvents([]); setRetryOpen(false); toast.success("Task restarted"); mutate(); } catch (err: any) { setActionError(err.message); toast.error(err.message || "Could not restart task"); } finally { setActing(false); } }; const cancelTask = async () => { setActing(true); try { await api(`/api/tasks/${id}/cancel`, { method: "POST" }); setCancelOpen(false); toast.success("Task stopped"); mutate(); } catch (err: any) { setActionError(err.message); toast.error(err.message || "Could not stop task"); } finally { setActing(false); } }; const report = task?.report; const llmUsage: LlmUsage[] = report?.llm_usage || []; const runs: RunHistory[] = report?.runs || []; const pendingApprovals = (approvals || []).filter((a) => a.status === "pending"); const canContinue = canAct && task && !["running", "queued"].includes(task.status); const isRunning = task?.status === "running"; const canStop = canAct && task && ["running", "queued"].includes(task.status); const escalateLlm = async (tier: "economy" | "premium") => { setActionError(null); setActing(true); try { await api(`/api/tasks/${id}/escalate-llm`, { method: "POST", body: JSON.stringify({ tier }), }); mutate(); } catch (err: any) { setActionError(err.message); } finally { setActing(false); } }; return ( {report?.run_number > 1 && ( Run {report.run_number} )} {isRunning && canAct && ( <> )} {canStop && ( )} {canContinue && ( <> )} ) } /> {actionError &&
{actionError}
} {taskError && !task && (
Couldn’t load this task. Retrying…
)} {task && }
{ setPinnedToBottom(true); endRef.current?.scrollIntoView({ behavior: "smooth" }); }} />
{pendingApprovals.length > 0 && (

Approvals required

Config-changing actions proposed by the agent. Approve before they can be applied.

{pendingApprovals.map((a) => (
{a.tool_name}
{a.risk &&
{a.risk}
} {canAct && (
)}
))}
)} {report && ( <> )} {task?.error && (

Error

{task.error}

)}
setContinueOpen(false)} title="Continue investigation">

The agent will use prior findings and run additional checks. Describe what to investigate next or why the issue is still unresolved.