922 lines
32 KiB
TypeScript
922 lines
32 KiB
TypeScript
"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> | 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: (
|
|
<svg
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2.5"
|
|
viewBox="0 0 24 24"
|
|
className="h-3 w-3"
|
|
>
|
|
<path d="M20 6 9 17l-5-5" />
|
|
</svg>
|
|
),
|
|
};
|
|
}
|
|
|
|
if (status === "killed" || status === "terminated") {
|
|
return {
|
|
label: status,
|
|
pillClass: "done-status-pill--killed",
|
|
icon: (
|
|
<svg
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2.5"
|
|
viewBox="0 0 24 24"
|
|
className="h-3 w-3"
|
|
>
|
|
<path d="M18 6 6 18M6 6l12 12" />
|
|
</svg>
|
|
),
|
|
};
|
|
}
|
|
|
|
// Default: exited / done / cleanup / closed PR
|
|
const label = activity === "exited" ? "exited" : status;
|
|
return {
|
|
label,
|
|
pillClass: "done-status-pill--exited",
|
|
icon: (
|
|
<svg
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
viewBox="0 0 24 24"
|
|
className="h-3 w-3"
|
|
>
|
|
<circle cx="12" cy="12" r="9" />
|
|
<path d="M9 12h6" />
|
|
</svg>
|
|
),
|
|
};
|
|
}
|
|
|
|
function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: SessionCardProps) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [sendingAction, setSendingAction] = useState<string | null>(null);
|
|
const [failedAction, setFailedAction] = useState<string | null>(null);
|
|
const [sendingQuickReply, setSendingQuickReply] = useState<string | null>(null);
|
|
const [sentQuickReply, setSentQuickReply] = useState<string | null>(null);
|
|
const [killConfirming, setKillConfirming] = useState(false);
|
|
const [replyText, setReplyText] = useState("");
|
|
const actionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const quickReplyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const level = getAttentionLevel(session);
|
|
const pr = session.pr;
|
|
|
|
const handleQuickReply = async (message: string): Promise<boolean> => {
|
|
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<HTMLTextAreaElement>) => {
|
|
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<HTMLButtonElement>) => {
|
|
e.stopPropagation();
|
|
if (!killConfirming) {
|
|
setKillConfirming(true);
|
|
return;
|
|
}
|
|
|
|
setKillConfirming(false);
|
|
onKill?.(session.id);
|
|
};
|
|
|
|
/* ── Done card variant ──────────────────────────────────────────── */
|
|
if (isDone) {
|
|
const statusInfo = getDoneStatusInfo(session);
|
|
|
|
return (
|
|
<div
|
|
className={cn("session-card-done", expanded && "done-expanded")}
|
|
onClick={(e) => {
|
|
if ((e.target as HTMLElement).closest("a, button, textarea")) return;
|
|
setExpanded(!expanded);
|
|
}}
|
|
>
|
|
{/* Row 1: Status pill + session id + restore */}
|
|
<div className="flex items-center gap-2 px-3.5 pt-3 pb-1.5">
|
|
<span className={cn("done-status-pill", statusInfo.pillClass)}>
|
|
{statusInfo.icon}
|
|
{statusInfo.label}
|
|
</span>
|
|
<span className="font-[var(--font-mono)] text-[10px] tracking-wide text-[var(--color-text-muted)]">
|
|
{session.id}
|
|
</span>
|
|
<div className="flex-1" />
|
|
{isRestorable && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onRestore?.(session.id);
|
|
}}
|
|
className="done-restore-btn"
|
|
>
|
|
<svg
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
viewBox="0 0 24 24"
|
|
className="h-3 w-3"
|
|
>
|
|
<polyline points="1 4 1 10 7 10" />
|
|
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
|
</svg>
|
|
restore
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Row 2: Title */}
|
|
<div className="px-3.5 pb-2">
|
|
<p className="session-card-done__title text-[13px] font-semibold leading-snug [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:2] overflow-hidden">
|
|
{title}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Row 3: Meta chips */}
|
|
<div className="flex flex-wrap items-center gap-1.5 px-3.5 pb-3">
|
|
{session.branch && (
|
|
<span className="done-meta-chip font-[var(--font-mono)]">
|
|
<svg
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
viewBox="0 0 24 24"
|
|
className="h-2.5 w-2.5 opacity-50"
|
|
>
|
|
<path d="M6 3v12M18 9a3 3 0 0 1-3 3H9a3 3 0 0 0-3 3" />
|
|
<circle cx="18" cy="6" r="3" />
|
|
</svg>
|
|
{session.branch}
|
|
</span>
|
|
)}
|
|
{pr && (
|
|
<a
|
|
href={pr.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={(e) => 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}
|
|
</a>
|
|
)}
|
|
{pr &&
|
|
!rateLimited &&
|
|
(prUnenriched ? (
|
|
<span className="inline-block h-[14px] w-16 animate-pulse rounded-full bg-[var(--color-bg-subtle)]" />
|
|
) : (
|
|
<span className="done-meta-chip font-[var(--font-mono)]">
|
|
<span className="text-[var(--color-status-ready)]">+{pr.additions}</span>{" "}
|
|
<span className="text-[var(--color-status-error)]">-{pr.deletions}</span>{" "}
|
|
{getSizeLabel(pr.additions, pr.deletions)}
|
|
<span className="sr-only">
|
|
{`+${pr.additions} -${pr.deletions} ${getSizeLabel(pr.additions, pr.deletions)}`}
|
|
</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
{/* Expandable detail panel */}
|
|
{expanded && (
|
|
<div className="done-expand-section px-3.5 py-3">
|
|
{session.summary && pr?.title && session.summary !== pr.title && (
|
|
<div className="mb-3">
|
|
<div className="done-detail-heading">
|
|
<svg fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path d="M4 6h16M4 12h16M4 18h10" />
|
|
</svg>
|
|
Summary
|
|
</div>
|
|
<p className="text-[12px] leading-relaxed text-[var(--color-text-secondary)]">
|
|
{session.summary}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{session.issueUrl && (
|
|
<div className="mb-3">
|
|
<div className="done-detail-heading">
|
|
<svg fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<circle cx="12" cy="12" r="10" />
|
|
<path d="M12 8v4M12 16h.01" />
|
|
</svg>
|
|
Issue
|
|
</div>
|
|
<a
|
|
href={session.issueUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="text-[12px] text-[var(--color-accent)] hover:underline"
|
|
>
|
|
{session.issueLabel || session.issueUrl}
|
|
{session.issueTitle && `: ${session.issueTitle}`}
|
|
</a>
|
|
</div>
|
|
)}
|
|
|
|
{pr && pr.ciChecks.length > 0 && (
|
|
<div className="mb-3">
|
|
<div className="done-detail-heading">
|
|
<svg fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path d="M9 12l2 2 4-4" />
|
|
<circle cx="12" cy="12" r="10" />
|
|
</svg>
|
|
CI Checks
|
|
</div>
|
|
<CICheckList checks={pr.ciChecks} />
|
|
</div>
|
|
)}
|
|
|
|
{pr && (
|
|
<div className="mb-3">
|
|
<div className="done-detail-heading">
|
|
<svg fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65S8.93 17.38 9 18v4" />
|
|
<path d="M9 18c-4.51 2-5-2-7-2" />
|
|
</svg>
|
|
PR
|
|
</div>
|
|
<p className="text-[12px] text-[var(--color-text-secondary)]">
|
|
<a
|
|
href={pr.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="hover:underline"
|
|
>
|
|
{pr.title}
|
|
</a>
|
|
{prUnenriched ? (
|
|
<>
|
|
<br />
|
|
<span className="mt-1 inline-flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
|
<span className="inline-block h-3 w-12 animate-pulse rounded bg-[var(--color-bg-subtle)]" />
|
|
<span>PR details loading...</span>
|
|
</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<br />
|
|
<span className="mt-1 inline-flex items-center gap-2">
|
|
<span className="done-meta-chip font-[var(--font-mono)]">
|
|
<span className="text-[var(--color-status-ready)]">+{pr.additions}</span>{" "}
|
|
<span className="text-[var(--color-status-error)]">-{pr.deletions}</span>
|
|
</span>
|
|
<span className="text-[var(--color-text-muted)]">·</span>
|
|
<span className="text-[10px] text-[var(--color-text-muted)]">
|
|
mergeable: {pr.mergeability.mergeable ? "yes" : "no"}
|
|
</span>
|
|
<span className="text-[var(--color-text-muted)]">·</span>
|
|
<span className="text-[10px] text-[var(--color-text-muted)]">
|
|
review: {pr.reviewDecision}
|
|
</span>
|
|
</span>
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{!pr && (
|
|
<p className="text-[12px] text-[var(--color-text-tertiary)]">
|
|
No PR associated with this session.
|
|
</p>
|
|
)}
|
|
|
|
{/* Action buttons — restore already shown in header row */}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
className={cn(
|
|
"session-card kanban-card-enter border",
|
|
cardFrameClass,
|
|
accentClass,
|
|
isReadyToMerge && "card-merge-ready",
|
|
)}
|
|
>
|
|
<div className="session-card__header">
|
|
<span
|
|
className={cn(
|
|
"card__adot",
|
|
cardDotTone === "working" && "card__adot--working",
|
|
cardDotTone === "ready" && "card__adot--ready",
|
|
cardDotTone === "idle" && "card__adot--idle",
|
|
cardDotTone === "waiting" && "card__adot--waiting",
|
|
cardDotTone === "exited" && "card__adot--exited",
|
|
)}
|
|
/>
|
|
<span className="card__id">
|
|
{session.id}
|
|
</span>
|
|
<div className="flex-1" />
|
|
{isRestorable && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onRestore?.(session.id);
|
|
}}
|
|
className="inline-flex items-center gap-1 border border-[color-mix(in_srgb,var(--color-accent)_35%,transparent)] px-2 py-0.5 text-[11px] text-[var(--color-accent)] transition-colors hover:bg-[var(--color-tint-blue)]"
|
|
>
|
|
<svg
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
viewBox="0 0 24 24"
|
|
className="h-3 w-3"
|
|
>
|
|
<polyline points="1 4 1 10 7 10" />
|
|
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
|
</svg>
|
|
restore
|
|
</button>
|
|
)}
|
|
{!isTerminal && (
|
|
<a
|
|
href={`/sessions/${encodeURIComponent(session.id)}#session-terminal-section`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="session-card__control session-card__terminal-link"
|
|
>
|
|
<svg
|
|
className="session-card__control-icon"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<rect x="2" y="4" width="20" height="16" rx="2" />
|
|
<path d="M6 10l4 2-4 2" />
|
|
<path d="M14 14h4" />
|
|
</svg>
|
|
terminal
|
|
</a>
|
|
)}
|
|
</div>
|
|
|
|
<div className="session-card__body flex min-h-0 flex-1 flex-col">
|
|
<div className="card__title-wrap">
|
|
<p className="card__title">
|
|
{title}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="card__meta">
|
|
{session.branch && (
|
|
<span className="card__branch">
|
|
{session.branch}
|
|
</span>
|
|
)}
|
|
{session.branch && pr ? (
|
|
<span className="card__meta-sep" aria-hidden="true">
|
|
·
|
|
</span>
|
|
) : null}
|
|
{pr && (
|
|
<a
|
|
href={pr.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="card__pr"
|
|
>
|
|
#{pr.number}
|
|
</a>
|
|
)}
|
|
{pr &&
|
|
!rateLimited &&
|
|
(prUnenriched ? (
|
|
<span className="inline-block h-[14px] w-16 animate-pulse rounded-full bg-[var(--color-bg-subtle)]" />
|
|
) : (
|
|
<span className="card__diff inline-flex items-center">
|
|
<span className="card__diff-add">+{pr.additions}</span>{" "}
|
|
<span className="card__diff-del">-{pr.deletions}</span>{" "}
|
|
<span className="card__diff-size">{getSizeLabel(pr.additions, pr.deletions)}</span>
|
|
<span className="sr-only">
|
|
{`+${pr.additions} -${pr.deletions} ${getSizeLabel(pr.additions, pr.deletions)}`}
|
|
</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
{secondaryText && (
|
|
<div className="px-[10px] pb-[5px]">
|
|
{level === "merge" || isReadyToMerge ? (
|
|
<p className="session-card__secondary session-card__secondary--merge">
|
|
<svg width="9" height="9" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M20 6 9 17l-5-5" />
|
|
</svg>
|
|
<span>{secondaryText}</span>
|
|
</p>
|
|
) : (
|
|
<p className="session-card__secondary">
|
|
{secondaryText}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{rateLimited && pr?.state === "open" && (
|
|
<div className="px-[10px] pb-[5px]">
|
|
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
|
|
<svg
|
|
className="h-3 w-3 text-[var(--color-text-tertiary)]"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<path d="M12 8v4M12 16h.01" />
|
|
</svg>
|
|
PR data rate limited
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{visiblePassingChecks.length > 0 && (
|
|
<div className="card__ci">
|
|
{visiblePassingChecks.map((check) => (
|
|
<span key={check.name} className="ci-chip ci-chip--pass">
|
|
<svg width="8" height="8" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M20 6 9 17l-5-5" />
|
|
</svg>
|
|
{check.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!rateLimited && alerts.length > 0 && (
|
|
<div className="card__alerts flex flex-col">
|
|
{alerts.slice(0, 3).map((alert) => (
|
|
<div
|
|
key={alert.key}
|
|
className={cn("alert-row", `alert-row--${alert.type}`)}
|
|
>
|
|
<span className="alert-row__icon">{alert.icon}</span>
|
|
<span className="alert-row__text">
|
|
<a
|
|
href={alert.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{alert.count !== undefined && (
|
|
<>
|
|
<span className="font-bold">{alert.count}</span>{" "}
|
|
</>
|
|
)}
|
|
{alert.label}
|
|
</a>
|
|
{alert.notified && (
|
|
<span className="alert-row__notified" title="Agent has been notified">
|
|
{" "}· notified
|
|
</span>
|
|
)}
|
|
</span>
|
|
{alert.actionLabel && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
void handleAction(alert.key, alert.actionMessage ?? "");
|
|
}}
|
|
disabled={sendingAction === alert.key}
|
|
className="alert-row__action"
|
|
>
|
|
{sendingAction === alert.key
|
|
? "sent!"
|
|
: failedAction === alert.key
|
|
? "failed"
|
|
: alert.actionLabel}
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{level === "respond" && (
|
|
<div className="quick-reply" onClick={(e) => e.stopPropagation()}>
|
|
{session.summary && !session.summaryIsFallback && (
|
|
<div className="card__agent-msg">
|
|
<svg className="card__agent-msg-icon" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
</svg>
|
|
<span>{session.summary}</span>
|
|
</div>
|
|
)}
|
|
<a
|
|
href={`/sessions/${encodeURIComponent(session.id)}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="card__view-context"
|
|
>
|
|
View current context →
|
|
</a>
|
|
<div className="card__presets">
|
|
<button
|
|
className="card__preset"
|
|
onClick={() => void handleQuickReply("continue")}
|
|
disabled={sendingQuickReply !== null}
|
|
>
|
|
{sendingQuickReply === "continue"
|
|
? "Sending..."
|
|
: sentQuickReply === "continue"
|
|
? "Sent"
|
|
: "Continue"}
|
|
</button>
|
|
<button
|
|
className="card__preset"
|
|
onClick={() => void handleQuickReply("abort")}
|
|
disabled={sendingQuickReply !== null}
|
|
>
|
|
{sendingQuickReply === "abort"
|
|
? "Sending..."
|
|
: sentQuickReply === "abort"
|
|
? "Sent"
|
|
: "Abort"}
|
|
</button>
|
|
<button
|
|
className="card__preset"
|
|
onClick={() => void handleQuickReply("skip")}
|
|
disabled={sendingQuickReply !== null}
|
|
>
|
|
{sendingQuickReply === "skip"
|
|
? "Sending..."
|
|
: sentQuickReply === "skip"
|
|
? "Sent"
|
|
: "Skip"}
|
|
</button>
|
|
</div>
|
|
<div className="card__reply-wrap">
|
|
<textarea
|
|
className="card__reply"
|
|
placeholder={sendingQuickReply !== null ? "Sending..." : "Type a reply... (Enter to send)"}
|
|
aria-label="Type a reply to the agent"
|
|
value={replyText}
|
|
onChange={(e) => setReplyText(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
void handleReplyKeyDown(e);
|
|
}}
|
|
rows={1}
|
|
disabled={sendingQuickReply !== null}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="session-card__footer">
|
|
<span className="card__status min-w-0 truncate" title={session.userPrompt ?? undefined}>
|
|
{!session.issueUrl && session.userPrompt
|
|
? session.userPrompt.length > 60
|
|
? session.userPrompt.slice(0, 60) + "…"
|
|
: session.userPrompt
|
|
: footerStatus}
|
|
</span>
|
|
|
|
{isReadyToMerge && pr ? (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onMerge?.(pr.number);
|
|
}}
|
|
className="session-card__control session-card__merge-control"
|
|
>
|
|
<svg
|
|
className="session-card__control-icon"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<circle cx="6" cy="6" r="2" />
|
|
<circle cx="18" cy="18" r="2" />
|
|
<circle cx="18" cy="6" r="2" />
|
|
<path d="M8 6h5a3 3 0 0 1 3 3v7" />
|
|
</svg>
|
|
Merge PR #{pr.number}
|
|
</button>
|
|
) : (
|
|
!isTerminal && (
|
|
<button
|
|
onClick={handleKillClick}
|
|
onMouseLeave={() => setKillConfirming(false)}
|
|
onBlur={() => setKillConfirming(false)}
|
|
aria-label={killConfirming ? "Confirm terminate session" : "Terminate session"}
|
|
className={cn(
|
|
"session-card__control session-card__terminate btn--danger",
|
|
killConfirming && "is-confirming",
|
|
)}
|
|
>
|
|
{killConfirming ? (
|
|
<span className="font-mono text-[10px] font-semibold tracking-[0.04em]">kill?</span>
|
|
) : (
|
|
<svg
|
|
className="session-card__control-icon"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path d="M3 6h18" />
|
|
<path d="M8 6V4h8v2" />
|
|
<path d="M19 6l-1 14H6L5 6" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function areSessionCardPropsEqual(prev: SessionCardProps, next: SessionCardProps): boolean {
|
|
return (
|
|
prev.session === next.session &&
|
|
prev.onSend === next.onSend &&
|
|
prev.onKill === next.onKill &&
|
|
prev.onMerge === next.onMerge &&
|
|
prev.onRestore === next.onRestore
|
|
);
|
|
}
|
|
|
|
export const SessionCard = memo(SessionCardView, areSessionCardPropsEqual);
|
|
|
|
function getFooterStatusLabel(
|
|
session: DashboardSession,
|
|
level: ReturnType<typeof getAttentionLevel>,
|
|
isReadyToMerge: boolean,
|
|
): string {
|
|
if (isReadyToMerge || level === "merge") return "mergeable";
|
|
if (level === "respond") return "waiting_input";
|
|
if (session.status === "ci_failed") return "ci_failed";
|
|
if (level === "review") return "review_pending";
|
|
if (level === "working") return "working";
|
|
return session.status;
|
|
}
|
|
|
|
interface Alert {
|
|
key: string;
|
|
type: "ci" | "changes" | "review" | "conflict" | "comment";
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
url: string;
|
|
count?: number;
|
|
notified?: boolean;
|
|
actionLabel?: string;
|
|
actionMessage?: string;
|
|
}
|
|
|
|
function getAlerts(session: DashboardSession): Alert[] {
|
|
const pr = session.pr;
|
|
if (!pr || pr.state !== "open") return [];
|
|
if (isPRRateLimited(pr)) return [];
|
|
if (isPRUnenriched(pr)) return [];
|
|
|
|
const meta = session.metadata;
|
|
const alerts: Alert[] = [];
|
|
|
|
// The lifecycle manager's status is the most up-to-date source of truth.
|
|
// PR enrichment data can be stale (5-min cache) or unavailable (rate limit/timeout).
|
|
// Use lifecycle status as fallback when PR data hasn't caught up yet.
|
|
const lifecycleStatus = meta["status"];
|
|
|
|
const ciIsFailing = pr.ciStatus === CI_STATUS.FAILING || lifecycleStatus === "ci_failed";
|
|
const hasChangesRequested =
|
|
pr.reviewDecision === "changes_requested" || lifecycleStatus === "changes_requested";
|
|
const hasConflicts = !pr.mergeability.noConflicts;
|
|
|
|
if (ciIsFailing) {
|
|
const failedCheck = pr.ciChecks.find((c) => c.status === "failed");
|
|
const failCount = pr.ciChecks.filter((c) => c.status === "failed").length;
|
|
if (failCount === 0 && pr.ciStatus !== CI_STATUS.FAILING) {
|
|
// Lifecycle says ci_failed but PR enrichment hasn't caught up — show generic alert
|
|
alerts.push({
|
|
key: "ci-fail",
|
|
type: "ci",
|
|
icon: "\u2717",
|
|
label: "CI failing",
|
|
url: pr.url + "/checks",
|
|
notified: Boolean(meta["lastCIFailureDispatchHash"]),
|
|
actionLabel: "Ask to fix",
|
|
actionMessage: `Please fix the failing CI checks on ${pr.url}`,
|
|
});
|
|
} else if (failCount === 0) {
|
|
alerts.push({
|
|
key: "ci-unknown",
|
|
type: "ci",
|
|
icon: "?",
|
|
label: "CI unknown",
|
|
url: pr.url + "/checks",
|
|
});
|
|
} else {
|
|
alerts.push({
|
|
key: "ci-fail",
|
|
type: "ci",
|
|
icon: "\u2717",
|
|
count: failCount,
|
|
label: `check${failCount > 1 ? "s" : ""} failing`,
|
|
url: failedCheck?.url ?? pr.url + "/checks",
|
|
notified: Boolean(meta["lastCIFailureDispatchHash"]),
|
|
actionLabel: "Ask to fix",
|
|
actionMessage: `Please fix the failing CI checks on ${pr.url}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (hasChangesRequested) {
|
|
alerts.push({
|
|
key: "changes",
|
|
type: "changes",
|
|
icon: "\u21BB",
|
|
label: "changes requested",
|
|
url: pr.url,
|
|
notified: Boolean(meta["lastPendingReviewDispatchHash"]),
|
|
actionLabel: "Ask to address",
|
|
actionMessage: `Please address the requested changes on ${pr.url}`,
|
|
});
|
|
} else if (!pr.isDraft && (pr.reviewDecision === "pending" || pr.reviewDecision === "none")) {
|
|
alerts.push({
|
|
key: "review",
|
|
type: "review",
|
|
icon: (
|
|
<svg width="9" height="9" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
|
<circle cx="9" cy="7" r="4" />
|
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
|
|
</svg>
|
|
),
|
|
label: "needs review",
|
|
url: pr.url,
|
|
actionLabel: "Ask to post",
|
|
actionMessage: `Post ${pr.url} on slack asking for a review.`,
|
|
});
|
|
}
|
|
|
|
if (hasConflicts) {
|
|
alerts.push({
|
|
key: "conflict",
|
|
type: "conflict",
|
|
icon: "\u26A0",
|
|
label: "merge conflict",
|
|
url: pr.url,
|
|
notified: meta["lastMergeConflictDispatched"] === "true",
|
|
actionLabel: "Ask to fix",
|
|
actionMessage: `Please resolve the merge conflicts on ${pr.url} by rebasing on the base branch`,
|
|
});
|
|
}
|
|
|
|
if (pr.unresolvedThreads > 0) {
|
|
const firstUrl = pr.unresolvedComments[0]?.url ?? pr.url + "/files";
|
|
alerts.push({
|
|
key: "comments",
|
|
type: "comment",
|
|
icon: "\uD83D\uDCAC",
|
|
label: "unresolved comments",
|
|
count: pr.unresolvedThreads,
|
|
url: firstUrl,
|
|
actionLabel: "Ask to resolve",
|
|
actionMessage: `Please address all unresolved review comments on ${pr.url}`,
|
|
});
|
|
}
|
|
|
|
return alerts;
|
|
}
|