import { PrOpenIcon, PrStatusIcon, PrDraftIcon, PrMergedIcon, PrClosedIcon } from "./PrGlyph";
import { Anchor, Box, Popover, Stack, Text, VisuallyHidden } from "@mantine/core";
import { IconAlertTriangle, IconHourglass, IconCircleCheck, IconCircleX, IconLoader, IconMinus, IconClock, IconChartBar } from "@tabler/icons-react";
import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { formatWorkedDuration } from "../time.js";
import { turnUsageLabel, useAnimatedUsage } from "./SavedUsageFormat";
import type { SavedUsage, PullRequestStatus } from "../types";
export function nextRunLabel(value?: string | null, now = new Date()) {
if (!value || !Number.isFinite(new Date(value).getTime())) return "Next run time unavailable";
const date = new Date(value);
const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate() + 1);
const day = date.toDateString() === now.toDateString() ? "Today" : date.toDateString() === tomorrow.toDateString() ? "Tomorrow"
: date.toLocaleDateString(undefined, { month: "short", day: "numeric", ...(date.getFullYear() !== now.getFullYear() ? { year: "numeric" } : {}) });
return `Next run: ${day} at ${date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`;
}
export function prPresentation(pr: PullRequestStatus) {
if (pr.state === "merged") return { Icon: PrMergedIcon, color: "#b28bdf", dot: null, label: "Merged" };
if (pr.state === "closed") return { Icon: PrClosedIcon, color: "#f0787e", dot: null, label: "Closed, not merged" };
if (pr.state === "draft") return { Icon: PrDraftIcon, color: "var(--taskchef-muted)", dot: null, label: "Draft" };
if (pr.accessIssue && pr.state === "unknown") return { Icon: IconAlertTriangle, color: "#eac54f", dot: null, label: "Repository access needed" };
if (pr.state === "unknown") return { Icon: PrOpenIcon, color: "var(--taskchef-muted)", dot: null, label: "PR status not checked or unavailable" };
if (pr.hasMergeConflicts || pr.checks === "failed") return { Icon: PrStatusIcon, color: "var(--taskchef-muted)", dot: "#e56b6f", label: pr.hasMergeConflicts ? "Merge conflicts" : "Checks failed" };
if (pr.canMerge || pr.checks === "passed") return { Icon: PrStatusIcon, color: "var(--taskchef-muted)", dot: "#39c681", label: pr.canMerge ? "Ready to merge" : "Checks passed" };
return { Icon: PrStatusIcon, color: "var(--taskchef-muted)", dot: "#eac54f", label: pr.checks === "none" ? "No checks" : pr.checks === "pending" ? "Checks pending" : "Open PR" };
}
export function PullRequestIcons({ pullRequests = [] }: { pullRequests?: PullRequestStatus[] }) {
return {pullRequests.map(pr => {
const { Icon, color, dot, label } = prPresentation(pr);
return
{pr.accessIssue && pr.state !== "unknown" && }
;
})};
}
export function CardStatusLine({ children, pullRequests = [], earlierPullRequest, scheduled, nextRunAt, durationMs, startedAt, subagentCount, turnUsage, turnId, animateUsage }: {
subagentCount?: number;
turnUsage?: SavedUsage;
turnId?: string | null;
animateUsage?: boolean;
children: ReactNode;
pullRequests?: PullRequestStatus[];
earlierPullRequest?: PullRequestStatus;
scheduled?: boolean;
nextRunAt?: string | null;
durationMs?: number | null;
startedAt?: string | null;
}) {
const showEarlierPr = !pullRequests.length && !!earlierPullRequest;
const displayedPrs = showEarlierPr ? [earlierPullRequest!] : pullRequests;
const descriptionId = useId();
const [opened, setOpened] = useState(false);
const [now, setNow] = useState(Date.now);
const start = startedAt ? Date.parse(startedAt) : NaN;
const running = Number.isFinite(start);
const animatedUsage = useAnimatedUsage(turnUsage, animateUsage ?? running, turnId ?? startedAt);
useEffect(() => {
if (!running) return;
setNow(Date.now());
const tick = window.setInterval(() => setNow(Date.now()), 60000);
return () => window.clearInterval(tick);
}, [running, startedAt]);
const duration = formatWorkedDuration(running ? Math.max(0, now - start) : durationMs);
const hasNextRun = scheduled && !!nextRunAt && Number.isFinite(Date.parse(nextRunAt));
const hasUsage = !!turnUsage && (!!turnUsage.samples || turnUsage.partial || turnUsage.costPartial);
const hasInfo = hasUsage || duration !== "—" || displayedPrs.length > 0 || hasNextRun;
const timer = useRef | null>(null);
const target = useRef(null);
const popup = useRef(null);
function show() { if (timer.current) clearTimeout(timer.current); if (hasInfo) { setNow(Date.now()); setOpened(true); } }
function leave() {
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => {
if (!target.current?.contains(document.activeElement) && !popup.current?.contains(document.activeElement)) setOpened(false);
}, 180);
}
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
useEffect(() => { if (!hasInfo) setOpened(false); }, [hasInfo]);
return
{
if (event.key === "Escape") { setOpened(false); event.stopPropagation(); }
if (event.key === "Tab" && !event.shiftKey && opened && popup.current?.querySelector("a")
&& (event.target !== target.current || !target.current?.querySelector('button:not(:disabled), [tabindex="0"]'))) {
event.preventDefault(); popup.current.querySelector("a")?.focus();
}
}}>
{children}
{hasUsage && {turnUsageLabel(animatedUsage!)}}
{scheduled && }
{displayedPrs.length > 0 && {showEarlierPr ? "Earlier turn PR. " : ""}{displayedPrs.map(pr => `${prTitle(pr)}: ${pr.state === "merged" ? "Merged" : pr.state === "closed" ? "Closed, not merged" : pr.state === "draft" ? "Draft" : pr.state === "unknown" ? "PR status not checked or unavailable" : pr.hasMergeConflicts ? "Open, merge conflicts" : "Open"}`).join(". ")}}
{ if (event.key === "Escape") { setOpened(false); target.current?.focus(); setOpened(false); event.stopPropagation(); } }}>
{duration !== "—" && }>Worked for {duration}}
{hasUsage && }>Turn usage: {turnUsageLabel(animatedUsage!)}}
{displayedPrs.map(pr => )}
{hasNextRun && }>{nextRunLabel(nextRunAt)}}
;
}
function prTitle(pr: PullRequestStatus) {
const number = pr.url.match(/\/pull\/(\d+)/)?.[1];
const title = pr.title || pr.url.match(/github\.com\/(.+)\/pull\/\d+/)?.[1] || "Pull request";
return `${number ? `#${number} ` : ""}${title}`;
}
function StatusRow({ icon, children }: { icon: ReactNode; children: ReactNode }) {
return {icon}{children};
}
export function PullRequestInfo({ pr, showAccess = true, showChecks = true, earlierTurn = false }: { pr: PullRequestStatus; showAccess?: boolean; showChecks?: boolean; earlierTurn?: boolean }) {
const title = prTitle(pr);
const safeLink = /^https:\/\/github\.com\/[A-Za-z0-9-]+\/[A-Za-z0-9_.-]+\/pull\/[1-9]\d*\/?$/.test(pr.url);
const { Icon, color, dot } = prPresentation(pr);
const CiIcon = pr.checks === "passed" ? IconCircleCheck : pr.checks === "failed" ? IconCircleX : pr.checks === "pending" ? IconLoader : IconMinus;
const repo = safeLink ? pr.url.match(/github\.com\/(.+)\/pull\//)?.[1] : null;
return
}>
{safeLink ? {title} : {title}}
{earlierTurn && (Earlier turn PR)}
{showChecks && pr.checks !== "unknown" && }>{pr.checks === "passed" ? "CI passed" : pr.checks === "failed" ? "CI failed" : pr.checks === "pending" ? "CI pending" : "No CI checks"}}
{showAccess && pr.accessIssue && safeLink &&
{pr.accessIssue === "denied" ? `Access denied to ${repo}.` : `PR #${pr.url.match(/\/pull\/(\d+)/)?.[1]} is unavailable. It may be private or removed.`} {pr.accessIssue === "denied" ? "Grant access" : "Check access"}
}
;
}