"use client"; import { memo, useState, useEffect, useRef } from "react"; import { type DashboardSession, getAttentionLevel, isPRRateLimited, isPRUnenriched, TERMINAL_STATUSES, TERMINAL_ACTIVITIES, CI_STATUS, } from "@/lib/types"; import { cn } from "@/lib/cn"; import { getSessionTitle } from "@/lib/format"; import { CICheckList } from "./CIBadge"; import { getSizeLabel } from "./PRStatus"; interface SessionCardProps { session: DashboardSession; onSend?: (sessionId: string, message: string) => Promise | void; onKill?: (sessionId: string) => void; onMerge?: (prNumber: number) => void; onRestore?: (sessionId: string) => void; } /** * Determine the status display info for done cards. */ function getDoneStatusInfo(session: DashboardSession): { label: string; pillClass: string; icon: React.ReactNode; } { const activity = session.activity; const status = session.status; const prState = session.pr?.state; if (prState === "merged" || status === "merged") { return { label: "merged", pillClass: "done-status-pill--merged", icon: ( ), }; } if (status === "killed" || status === "terminated") { return { label: status, pillClass: "done-status-pill--killed", icon: ( ), }; } // Default: exited / done / cleanup / closed PR const label = activity === "exited" ? "exited" : status; return { label, pillClass: "done-status-pill--exited", icon: ( ), }; } function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: SessionCardProps) { const [expanded, setExpanded] = useState(false); const [sendingAction, setSendingAction] = useState(null); const [failedAction, setFailedAction] = useState(null); const [sendingQuickReply, setSendingQuickReply] = useState(null); const [sentQuickReply, setSentQuickReply] = useState(null); const [killConfirming, setKillConfirming] = useState(false); const [replyText, setReplyText] = useState(""); const actionTimerRef = useRef | null>(null); const quickReplyTimerRef = useRef | null>(null); const level = getAttentionLevel(session); const pr = session.pr; const handleQuickReply = async (message: string): Promise => { const trimmedMessage = message.trim(); if (!trimmedMessage || sendingQuickReply !== null) return false; setSendingQuickReply(trimmedMessage); setSentQuickReply(null); try { await Promise.resolve(onSend?.(session.id, trimmedMessage)); setSentQuickReply(trimmedMessage); if (quickReplyTimerRef.current) clearTimeout(quickReplyTimerRef.current); quickReplyTimerRef.current = setTimeout(() => setSentQuickReply(null), 2000); return true; } catch { return false; } finally { setSendingQuickReply(null); } }; const handleReplyKeyDown = async (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); const sent = await handleQuickReply(replyText); if (sent) setReplyText(""); } }; useEffect(() => { return () => { if (actionTimerRef.current) clearTimeout(actionTimerRef.current); if (quickReplyTimerRef.current) clearTimeout(quickReplyTimerRef.current); }; }, []); const handleAction = async (action: string, message: string) => { if (sendingAction !== null) return; setSendingAction(action); setFailedAction(null); try { await Promise.resolve(onSend?.(session.id, message)); if (actionTimerRef.current) clearTimeout(actionTimerRef.current); actionTimerRef.current = setTimeout(() => setSendingAction(null), 2000); } catch { setSendingAction(null); setFailedAction(action); if (actionTimerRef.current) clearTimeout(actionTimerRef.current); actionTimerRef.current = setTimeout(() => setFailedAction(null), 2000); } }; const rateLimited = pr ? isPRRateLimited(pr) : false; const prUnenriched = pr ? isPRUnenriched(pr) : false; const alerts = getAlerts(session); const isReadyToMerge = !rateLimited && pr?.mergeability.mergeable && pr.state === "open"; const isTerminal = TERMINAL_STATUSES.has(session.status) || (session.activity !== null && TERMINAL_ACTIVITIES.has(session.activity)); const isRestorable = isTerminal && session.status !== "merged"; const title = getSessionTitle(session); const footerStatus = getFooterStatusLabel(session, level, Boolean(isReadyToMerge)); const visiblePassingChecks = !rateLimited && pr && !prUnenriched ? pr.ciChecks.filter((check) => check.status === "passed").slice(0, 3) : []; const isDone = level === "done"; const secondaryText = session.issueLabel ? `${session.issueLabel}${session.issueTitle ? ` · ${session.issueTitle}` : ""}` : (session.issueTitle ?? (session.summary && session.summary !== title ? session.summary : null)); const cardFrameClass = isReadyToMerge ? "session-card--merge-frame" : alerts.length > 0 ? "session-card--alert-frame" : "session-card--fixed"; const accentClass = isReadyToMerge ? "session-card--accent-merge" : level === "working" ? "session-card--accent-working" : level === "respond" ? "session-card--accent-respond" : level === "review" || level === "pending" ? "session-card--accent-attention" : "session-card--accent-default"; const handleKillClick = (e: React.MouseEvent) => { e.stopPropagation(); if (!killConfirming) { setKillConfirming(true); return; } setKillConfirming(false); onKill?.(session.id); }; /* ── Done card variant ──────────────────────────────────────────── */ if (isDone) { const statusInfo = getDoneStatusInfo(session); return (
{ if ((e.target as HTMLElement).closest("a, button, textarea")) return; setExpanded(!expanded); }} > {/* Row 1: Status pill + session id + restore */}
{statusInfo.icon} {statusInfo.label} {session.id}
{isRestorable && ( )}
{/* Row 2: Title */}

{title}

{/* Row 3: Meta chips */}
{session.branch && ( {session.branch} )} {pr && ( e.stopPropagation()} className="done-meta-chip font-[var(--font-mono)] font-bold text-[var(--color-text-primary)] no-underline underline-offset-2 hover:underline" > #{pr.number} )} {pr && !rateLimited && (prUnenriched ? ( ) : ( +{pr.additions}{" "} -{pr.deletions}{" "} {getSizeLabel(pr.additions, pr.deletions)} {`+${pr.additions} -${pr.deletions} ${getSizeLabel(pr.additions, pr.deletions)}`} ))}
{/* Expandable detail panel */} {expanded && (
{session.summary && pr?.title && session.summary !== pr.title && (
Summary

{session.summary}

)} {session.issueUrl && ( )} {pr && pr.ciChecks.length > 0 && (
CI Checks
)} {pr && (
PR

e.stopPropagation()} className="hover:underline" > {pr.title} {prUnenriched ? ( <>
PR details loading... ) : ( <>
+{pr.additions}{" "} -{pr.deletions} · mergeable: {pr.mergeability.mergeable ? "yes" : "no"} · review: {pr.reviewDecision} )}

)} {!pr && (

No PR associated with this session.

)} {/* Action buttons — restore already shown in header row */}
)}
); } const cardDotTone = (() => { if (isTerminal) return "exited"; if (isReadyToMerge || level === "merge" || level === "review") return "ready"; if (level === "respond") return "waiting"; if (level === "pending") return "idle"; if (level === "working") return "working"; return "idle"; })(); /* ── Standard card (non-done) ────────────────────────────────────── */ return (
{session.id}
{isRestorable && ( )} {!isTerminal && ( e.stopPropagation()} className="session-card__control session-card__terminal-link" > terminal )}

{title}

{session.branch && ( {session.branch} )} {session.branch && pr ? ( ) : null} {pr && ( e.stopPropagation()} className="card__pr" > #{pr.number} )} {pr && !rateLimited && (prUnenriched ? ( ) : ( +{pr.additions}{" "} -{pr.deletions}{" "} {getSizeLabel(pr.additions, pr.deletions)} {`+${pr.additions} -${pr.deletions} ${getSizeLabel(pr.additions, pr.deletions)}`} ))}
{secondaryText && (
{level === "merge" || isReadyToMerge ? (

{secondaryText}

) : (

{secondaryText}

)}
)} {rateLimited && pr?.state === "open" && (
PR data rate limited
)} {visiblePassingChecks.length > 0 && (
{visiblePassingChecks.map((check) => ( {check.name} ))}
)} {!rateLimited && alerts.length > 0 && (
{alerts.slice(0, 3).map((alert) => (
{alert.icon} e.stopPropagation()} > {alert.count !== undefined && ( <> {alert.count}{" "} )} {alert.label} {alert.notified && ( {" "}· notified )} {alert.actionLabel && ( )}
))}
)} {level === "respond" && (
e.stopPropagation()}> {session.summary && !session.summaryIsFallback && (
{session.summary}
)} e.stopPropagation()} className="card__view-context" > View current context →