import { useState, useRef, useCallback, useEffect } from "preact/hooks"; import type { ThreadData } from "../../lib/types"; import { fmtDur, durClass, fmtTime, statusOf } from "../../lib/format"; import StatusBadge from "./StatusBadge"; import { Undo2 } from "lucide-preact"; function fmtK(n: number): string { return n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n); } interface Props { thread: ThreadData; selected: boolean; onClick: () => void; onChangeStatus?: (newStatus: string) => void; onDelete?: () => void; onArchive?: () => void; onDebug?: () => Promise; } const SWIPE_THRESHOLD = 80; const UNDO_TIMEOUT = 4000; type SwipeAction = null | "deleted" | "archived"; export default function ThreadCard({ thread, selected, onClick, onChangeStatus, onDelete, onArchive, onDebug }: Props) { const isActive = thread.status === "processing"; const st = statusOf(thread); const dur = (thread.endedAt || Date.now()) - thread.startedAt; const [debugging, setDebugging] = useState(false); const [swipeX, setSwipeX] = useState(0); const [swipeAction, setSwipeAction] = useState(null); const [pendingAction, setPendingAction] = useState(null); const dragStart = useRef({ x: 0, y: 0, dragging: false, decided: false }); const undoTimer = useRef | null>(null); const canSwipe = !!onDelete || !!onArchive; const onDeleteRef = useRef(onDelete); onDeleteRef.current = onDelete; const onArchiveRef = useRef(onArchive); onArchiveRef.current = onArchive; const onPointerDown = useCallback((e: PointerEvent) => { if (!canSwipe) return; dragStart.current = { x: e.clientX, y: e.clientY, dragging: false, decided: false }; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); }, [canSwipe]); const onPointerMove = useCallback((e: PointerEvent) => { if (!canSwipe) return; if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return; const dx = e.clientX - dragStart.current.x; const dy = e.clientY - dragStart.current.y; if (!dragStart.current.decided) { if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return; dragStart.current.decided = true; if (Math.abs(dy) > Math.abs(dx)) { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); return; } dragStart.current.dragging = true; } if (dragStart.current.dragging) { const clamped = onDelete && dx < 0 ? dx : onArchive && dx > 0 ? dx : 0; setSwipeX(clamped); } }, [canSwipe, onDelete, onArchive]); const onPointerUp = useCallback((e: PointerEvent) => { if (!canSwipe) return; if (dragStart.current.dragging) { if (swipeX < -SWIPE_THRESHOLD && onDelete) { setSwipeAction("deleted"); setPendingAction("deleted"); } else if (swipeX > SWIPE_THRESHOLD && onArchive) { setSwipeAction("archived"); setPendingAction("archived"); } } setSwipeX(0); dragStart.current = { x: 0, y: 0, dragging: false, decided: false }; }, [canSwipe, swipeX, onDelete, onArchive]); const handleDebug = useCallback(async (e: Event) => { e.stopPropagation(); if (debugging || !onDebug) return; setDebugging(true); try { await onDebug(); } catch {} setDebugging(false); }, [debugging, onDebug]); const handleClick = useCallback((e: MouseEvent) => { if (dragStart.current.dragging) { e.preventDefault(); e.stopPropagation(); return; } onClick(); }, [onClick]); useEffect(() => { if (!pendingAction) return; undoTimer.current = setTimeout(() => { if (pendingAction === "deleted") onDeleteRef.current?.(); else if (pendingAction === "archived") onArchiveRef.current?.(); }, UNDO_TIMEOUT); return () => { if (undoTimer.current) clearTimeout(undoTimer.current); }; }, [pendingAction]); const handleUndo = useCallback((e: Event) => { e.stopPropagation(); if (undoTimer.current) clearTimeout(undoTimer.current); setPendingAction(null); setSwipeAction(null); }, []); const title = thread.linearIssue ? (thread.linearTitle || thread.subject) : thread.subject; if (swipeAction) { const label = swipeAction === "deleted" ? "Deleted" : "Archived"; return (
{label}: {title}
); } const style = swipeX !== 0 ? { transform: `translateX(${swipeX}px)`, transition: "none" } : { transform: "translateX(0)", transition: "transform 0.2s" }; return (
{swipeX < 0 &&
Delete
} {swipeX > 0 &&
Archive
}
{thread.linearIssue && ( e.stopPropagation()} > {thread.linearIssue} )} {title} {fmtDur(dur)}
{(thread.status === "stalled" || thread.status === "failed") && onDebug && ( )} via {thread.channel} {fmtTime(thread.startedAt)} {thread.tokIn != null && thread.tokOut != null && thread.cost != null && ( {fmtK(thread.tokIn)}in/{fmtK(thread.tokOut)}out ยท ${thread.cost.toFixed(4)} )}
); }