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"} } ; }