1340 lines
48 KiB
TypeScript
1340 lines
48 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode } from "react";
|
|
import { useSearchParams } from "next/navigation";
|
|
import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery";
|
|
import {
|
|
type DashboardAgentReportAuditEntry,
|
|
type DashboardSession,
|
|
type DashboardPR,
|
|
NON_RESTORABLE_STATUSES,
|
|
isPRMergeReady,
|
|
isPRRateLimited,
|
|
isPRUnenriched,
|
|
getSessionTruthLabel,
|
|
getSessionTruthReasonLabel,
|
|
getPRTruthLabel,
|
|
getPRTruthReasonLabel,
|
|
getRuntimeTruthLabel,
|
|
getRuntimeTruthReasonLabel,
|
|
getLifecycleGuidance,
|
|
getLifecycleEvidence,
|
|
getActivitySignalLabel,
|
|
getActivitySignalReasonLabel,
|
|
isDashboardRuntimeEnded,
|
|
isDashboardSessionRestorable,
|
|
} from "@/lib/types";
|
|
import { CI_STATUS } from "@aoagents/ao-core/types";
|
|
import { cn } from "@/lib/cn";
|
|
import dynamic from "next/dynamic";
|
|
import { getSessionTitle } from "@/lib/format";
|
|
import type { ProjectInfo } from "@/lib/project-name";
|
|
|
|
import { MobileBottomNav } from "./MobileBottomNav";
|
|
import { ProjectSidebar } from "./ProjectSidebar";
|
|
|
|
const DirectTerminal = dynamic(
|
|
() => import("./DirectTerminal").then((m) => ({ default: m.DirectTerminal })),
|
|
{
|
|
ssr: false,
|
|
loading: () => (
|
|
<div className="h-[440px] animate-pulse rounded bg-[var(--color-bg-primary)]" />
|
|
),
|
|
},
|
|
);
|
|
|
|
interface OrchestratorZones {
|
|
merge: number;
|
|
respond: number;
|
|
review: number;
|
|
pending: number;
|
|
working: number;
|
|
done: number;
|
|
}
|
|
|
|
interface SessionDetailProps {
|
|
session: DashboardSession;
|
|
isOrchestrator?: boolean;
|
|
orchestratorZones?: OrchestratorZones;
|
|
projectOrchestratorId?: string | null;
|
|
projects?: ProjectInfo[];
|
|
sidebarSessions?: DashboardSession[] | null;
|
|
sidebarLoading?: boolean;
|
|
sidebarError?: boolean;
|
|
onRetrySidebar?: () => void;
|
|
}
|
|
|
|
function truthBadgeTone(label: string): string {
|
|
const normalized = label.toLowerCase();
|
|
if (normalized.includes("detect")) return "var(--color-status-attention)";
|
|
if (normalized.includes("terminated") || normalized.includes("missing")) {
|
|
return "var(--color-status-error)";
|
|
}
|
|
if (normalized.includes("merged") || normalized.includes("alive")) {
|
|
return "var(--color-status-ready)";
|
|
}
|
|
return "var(--color-accent)";
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
function formatTimeCompact(isoDate: string | null): string {
|
|
if (!isoDate) return "just now";
|
|
const ts = new Date(isoDate).getTime();
|
|
if (!Number.isFinite(ts)) return "just now";
|
|
const diffMs = Date.now() - ts;
|
|
if (diffMs <= 0) return "just now";
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
const diffHours = Math.floor(diffMins / 60);
|
|
if (diffMins < 1) return "just now";
|
|
if (diffMins < 60) return `${diffMins}m ago`;
|
|
if (diffHours < 24) return `${diffHours}h ago`;
|
|
return `${Math.floor(diffHours / 24)}d ago`;
|
|
}
|
|
|
|
function getCiDotBg(pr: DashboardPR): string {
|
|
if (isPRRateLimited(pr) || isPRUnenriched(pr)) return "var(--color-text-tertiary)";
|
|
if (pr.ciStatus === "passing") return "var(--color-accent-green)";
|
|
if (pr.ciStatus === "failing") return "var(--color-accent-red)";
|
|
return "var(--color-status-attention)";
|
|
}
|
|
|
|
function getCiShortLabel(pr: DashboardPR): string {
|
|
if (isPRRateLimited(pr) || isPRUnenriched(pr)) return "CI";
|
|
if (pr.ciStatus === "passing") return "CI passing";
|
|
if (pr.ciStatus === "failing") return "CI failed";
|
|
return "CI pending";
|
|
}
|
|
|
|
function getReviewShortLabel(pr: DashboardPR): string {
|
|
if (isPRRateLimited(pr) || isPRUnenriched(pr)) return "";
|
|
if (pr.reviewDecision === "approved") return "approved";
|
|
if (pr.reviewDecision === "changes_requested") return "changes";
|
|
return "review";
|
|
}
|
|
|
|
const activityMeta: Record<string, { label: string; color: string }> = {
|
|
active: { label: "Active", color: "var(--color-status-working)" },
|
|
ready: { label: "Ready", color: "var(--color-status-ready)" },
|
|
idle: { label: "Idle", color: "var(--color-status-idle)" },
|
|
waiting_input: { label: "Waiting for input", color: "var(--color-status-attention)" },
|
|
blocked: { label: "Blocked", color: "var(--color-status-error)" },
|
|
exited: { label: "Exited", color: "var(--color-status-error)" },
|
|
};
|
|
|
|
function cleanBugbotComment(body: string): { title: string; description: string } {
|
|
const isBugbot = body.includes("<!-- DESCRIPTION START -->") || body.includes("### ");
|
|
if (isBugbot) {
|
|
const titleMatch = body.match(/###\s+(.+?)(?:\n|$)/);
|
|
const title = titleMatch ? titleMatch[1].replace(/\*\*/g, "").trim() : "Comment";
|
|
const descMatch = body.match(
|
|
/<!-- DESCRIPTION START -->\s*([\s\S]*?)\s*<!-- DESCRIPTION END -->/,
|
|
);
|
|
const description = descMatch ? descMatch[1].trim() : body.split("\n")[0] || "No description";
|
|
return { title, description };
|
|
}
|
|
return { title: "Comment", description: body.trim() };
|
|
}
|
|
|
|
function buildGitHubBranchUrl(pr: DashboardPR): string {
|
|
return `https://github.com/${pr.owner}/${pr.repo}/tree/${pr.branch}`;
|
|
}
|
|
|
|
function activityStateClass(activityLabel: string): string {
|
|
const normalized = activityLabel.toLowerCase();
|
|
if (normalized === "active") return "session-detail-status-pill--active";
|
|
if (normalized === "ready") return "session-detail-status-pill--ready";
|
|
if (normalized === "idle") return "session-detail-status-pill--idle";
|
|
if (normalized === "waiting for input") return "session-detail-status-pill--waiting";
|
|
if (normalized === "blocked" || normalized === "exited") {
|
|
return "session-detail-status-pill--error";
|
|
}
|
|
return "session-detail-status-pill--neutral";
|
|
}
|
|
|
|
function SessionTruthPanel({ session }: { session: DashboardSession }) {
|
|
if (!session.lifecycle) return null;
|
|
|
|
const facts = [
|
|
{
|
|
heading: "Activity",
|
|
label: getActivitySignalLabel(session),
|
|
reason: getActivitySignalReasonLabel(session),
|
|
},
|
|
{
|
|
heading: "Session",
|
|
label: getSessionTruthLabel(session),
|
|
reason: getSessionTruthReasonLabel(session),
|
|
},
|
|
{
|
|
heading: "PR",
|
|
label: getPRTruthLabel(session),
|
|
reason: getPRTruthReasonLabel(session),
|
|
},
|
|
{
|
|
heading: "Runtime",
|
|
label: getRuntimeTruthLabel(session),
|
|
reason: getRuntimeTruthReasonLabel(session),
|
|
},
|
|
];
|
|
const guidance = getLifecycleGuidance(session);
|
|
const evidence = getLifecycleEvidence(session);
|
|
|
|
return (
|
|
<section className="mb-4 rounded-[20px] border border-[var(--color-border-muted)] bg-[var(--color-bg-panel)] px-4 py-3">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--color-text-muted)]">
|
|
Lifecycle Truth
|
|
</p>
|
|
<p className="mt-1 text-[13px] text-[var(--color-text-secondary)]">
|
|
{session.lifecycle.summary}
|
|
</p>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{facts.map((fact) => (
|
|
<div
|
|
key={fact.heading}
|
|
className="rounded-full border border-[var(--color-border-muted)] bg-[var(--color-bg-base)] px-3 py-1.5"
|
|
>
|
|
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
|
{fact.heading}
|
|
</div>
|
|
<div
|
|
className="mt-0.5 text-[12px] font-medium"
|
|
style={{ color: truthBadgeTone(fact.label) }}
|
|
>
|
|
{fact.label}
|
|
</div>
|
|
{fact.reason ? (
|
|
<div className="mt-0.5 text-[11px] text-[var(--color-text-tertiary)]">
|
|
{fact.reason}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
{guidance ? (
|
|
<p className="mt-3 text-[12px] text-[var(--color-status-attention)]">
|
|
{guidance}
|
|
</p>
|
|
) : null}
|
|
{evidence ? (
|
|
<p className="mt-2 text-[11px] text-[var(--color-text-tertiary)]">
|
|
Evidence: {evidence}
|
|
</p>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function formatAuditTimestamp(isoDate: string): string {
|
|
const parsed = Date.parse(isoDate);
|
|
if (Number.isNaN(parsed)) return isoDate;
|
|
return new Date(parsed).toLocaleString();
|
|
}
|
|
|
|
function getAuditCommandLabel(entry: DashboardAgentReportAuditEntry): string {
|
|
return entry.source === "acknowledge" ? "ao acknowledge" : `ao report ${entry.reportState}`;
|
|
}
|
|
|
|
function SessionReportAuditPanel({
|
|
sessionId,
|
|
entries,
|
|
}: {
|
|
sessionId: string;
|
|
entries: DashboardAgentReportAuditEntry[];
|
|
}) {
|
|
const [isExpanded, setIsExpanded] = useState(true);
|
|
|
|
if (entries.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<section className="mb-4 rounded-[20px] border border-[var(--color-border-muted)] bg-[var(--color-bg-panel)] px-4 py-3">
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center justify-between gap-3 text-left"
|
|
aria-expanded={isExpanded}
|
|
aria-controls="session-report-audit-panel"
|
|
onClick={() => setIsExpanded((current) => !current)}
|
|
>
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--color-text-muted)]">
|
|
Agent Reports
|
|
</p>
|
|
<p className="mt-1 text-[12px] text-[var(--color-text-secondary)]">
|
|
{entries.length} audit {entries.length === 1 ? "entry" : "entries"}
|
|
</p>
|
|
</div>
|
|
<span
|
|
className="flex h-7 w-7 items-center justify-center rounded-full border border-[var(--color-border-muted)] bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]"
|
|
aria-hidden="true"
|
|
>
|
|
<svg
|
|
className={cn(
|
|
"h-4 w-4 transition-transform duration-200",
|
|
isExpanded ? "rotate-180" : "rotate-0",
|
|
)}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path d="m6 9 6 6 6-6" />
|
|
</svg>
|
|
</span>
|
|
</button>
|
|
{isExpanded ? (
|
|
<div id="session-report-audit-panel" className="mt-3 space-y-3">
|
|
{entries.map((entry, index) => (
|
|
<div
|
|
key={`${entry.timestamp}-${entry.reportState}-${entry.actor}-${entry.source}-${String(entry.accepted)}-${index}`}
|
|
className="rounded-[16px] border border-[var(--color-border-muted)] bg-[var(--color-bg-base)] px-3 py-3"
|
|
>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span
|
|
className="rounded-full px-2 py-0.5 text-[11px] font-medium"
|
|
style={{
|
|
background: entry.accepted
|
|
? "color-mix(in srgb, var(--color-status-ready) 14%, transparent)"
|
|
: "color-mix(in srgb, var(--color-status-error) 14%, transparent)",
|
|
color: entry.accepted
|
|
? "var(--color-status-ready)"
|
|
: "var(--color-status-error)",
|
|
}}
|
|
>
|
|
{entry.accepted ? "Accepted" : "Rejected"}
|
|
</span>
|
|
<span className="text-[11px] text-[var(--color-text-tertiary)]">
|
|
{formatAuditTimestamp(entry.timestamp)}
|
|
</span>
|
|
</div>
|
|
<div className="mt-3 grid gap-2 text-[12px] text-[var(--color-text-secondary)] sm:grid-cols-3">
|
|
<div className="rounded-[12px] border border-[var(--color-border-muted)] px-2.5 py-2">
|
|
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
|
Session
|
|
</div>
|
|
<div className="mt-1 font-[var(--font-mono)] text-[var(--color-text-primary)]">
|
|
{sessionId}
|
|
</div>
|
|
</div>
|
|
<div className="rounded-[12px] border border-[var(--color-border-muted)] px-2.5 py-2">
|
|
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
|
Actor
|
|
</div>
|
|
<div className="mt-1 text-[var(--color-text-primary)]">{entry.actor}</div>
|
|
</div>
|
|
<div className="rounded-[12px] border border-[var(--color-border-muted)] px-2.5 py-2">
|
|
<div className="text-[10px] uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
|
Source
|
|
</div>
|
|
<div className="mt-1 font-[var(--font-mono)] text-[var(--color-text-primary)]">
|
|
{getAuditCommandLabel(entry)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 text-[12px] text-[var(--color-text-secondary)]">
|
|
{entry.before.legacyStatus} / {entry.before.sessionState}
|
|
{" -> "}
|
|
{entry.after.legacyStatus} / {entry.after.sessionState}
|
|
</div>
|
|
{entry.note ? (
|
|
<p className="mt-2 text-[12px] text-[var(--color-text-secondary)]">
|
|
Note: {entry.note}
|
|
</p>
|
|
) : null}
|
|
{entry.rejectionReason ? (
|
|
<p className="mt-2 text-[12px] text-[var(--color-status-error)]">
|
|
Rejection: {entry.rejectionReason}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function SessionTopStrip({
|
|
headline,
|
|
crumbId,
|
|
activityLabel,
|
|
activityColor,
|
|
branch,
|
|
pr,
|
|
isOrchestrator = false,
|
|
crumbHref,
|
|
crumbLabel,
|
|
rightSlot,
|
|
onKill,
|
|
onRestore,
|
|
}: {
|
|
headline: string;
|
|
crumbId: string;
|
|
activityLabel: string;
|
|
activityColor: string;
|
|
branch: string | null;
|
|
pr: DashboardPR | null;
|
|
isOrchestrator?: boolean;
|
|
crumbHref: string;
|
|
crumbLabel: string;
|
|
rightSlot?: ReactNode;
|
|
onKill?: () => void;
|
|
onRestore?: () => void;
|
|
}) {
|
|
return (
|
|
<div className="session-detail-top-strip">
|
|
{/* Breadcrumbs */}
|
|
<div className="session-detail-crumbs">
|
|
<a
|
|
href={crumbHref}
|
|
className="session-detail-crumb-back"
|
|
>
|
|
<svg
|
|
className="h-3 w-3 opacity-60"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2.5"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path d="M15 18l-6-6 6-6" />
|
|
</svg>
|
|
{crumbLabel}
|
|
</a>
|
|
<span className="session-detail-crumb-sep">/</span>
|
|
<span className="session-detail-crumb-id">{crumbId}</span>
|
|
{isOrchestrator ? (
|
|
<span className="session-detail-mode-badge">orchestrator</span>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Identity strip */}
|
|
<div className="session-detail-identity">
|
|
<div className="session-detail-identity__info">
|
|
<h1 className="session-detail-identity__title">
|
|
{headline}
|
|
</h1>
|
|
<div className="session-detail-identity__pills">
|
|
<div
|
|
className={cn(
|
|
"session-detail-status-pill",
|
|
activityStateClass(activityLabel),
|
|
)}
|
|
>
|
|
<span
|
|
className="session-detail-status-pill__dot"
|
|
style={{ background: activityColor }}
|
|
/>
|
|
<span className="session-detail-status-pill__label">
|
|
{activityLabel}
|
|
</span>
|
|
</div>
|
|
{branch ? (
|
|
pr ? (
|
|
<a
|
|
href={buildGitHubBranchUrl(pr)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="session-detail-link-pill session-detail-link-pill--branch session-detail-link-pill--branch-link hover:no-underline"
|
|
>
|
|
{branch}
|
|
</a>
|
|
) : (
|
|
<span className="session-detail-link-pill session-detail-link-pill--branch">
|
|
{branch}
|
|
</span>
|
|
)
|
|
) : null}
|
|
{pr ? (
|
|
<a
|
|
href={pr.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="session-detail-link-pill session-detail-link-pill--pr hover:no-underline"
|
|
>
|
|
PR #{pr.number}
|
|
</a>
|
|
) : null}
|
|
{pr && (pr.additions > 0 || pr.deletions > 0) ? (
|
|
<span className="session-detail-link-pill session-detail-link-pill--diff">
|
|
<span className="session-detail-diff--add">+{pr.additions}</span>
|
|
{" "}
|
|
<span className="session-detail-diff--del">-{pr.deletions}</span>
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{rightSlot ? (
|
|
<div className="session-detail-identity__actions session-detail-identity__actions--custom">
|
|
{rightSlot}
|
|
</div>
|
|
) : (
|
|
<div className="session-detail-identity__actions">
|
|
{onRestore ? (
|
|
<button
|
|
type="button"
|
|
className="done-restore-btn"
|
|
onClick={onRestore}
|
|
>
|
|
<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>
|
|
) : onKill ? (
|
|
<button
|
|
type="button"
|
|
className="session-detail-action-btn session-detail-action-btn--danger"
|
|
onClick={onKill}
|
|
>
|
|
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
</svg>
|
|
Kill
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
async function askAgentToFix(
|
|
sessionId: string,
|
|
comment: { url: string; path: string; body: string },
|
|
onSuccess: () => void,
|
|
onError: () => void,
|
|
) {
|
|
try {
|
|
const { title, description } = cleanBugbotComment(comment.body);
|
|
const message = `Please address this review comment:\n\nFile: ${comment.path}\nComment: ${title}\nDescription: ${description}\n\nComment URL: ${comment.url}\n\nAfter fixing, mark the comment as resolved at ${comment.url}`;
|
|
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/message`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ message }),
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
onSuccess();
|
|
} catch (err) {
|
|
console.error("Failed to send message to agent:", err);
|
|
onError();
|
|
}
|
|
}
|
|
|
|
// ── Orchestrator status strip ─────────────────────────────────────────
|
|
|
|
function _OrchestratorStatusStrip({
|
|
zones,
|
|
createdAt,
|
|
headline,
|
|
activityLabel,
|
|
activityColor,
|
|
branch,
|
|
pr,
|
|
crumbHref,
|
|
crumbLabel,
|
|
}: {
|
|
zones: OrchestratorZones;
|
|
createdAt: string;
|
|
headline: string;
|
|
activityLabel: string;
|
|
activityColor: string;
|
|
branch: string | null;
|
|
pr: DashboardPR | null;
|
|
crumbHref: string;
|
|
crumbLabel: string;
|
|
}) {
|
|
const [uptime, setUptime] = useState<string>("");
|
|
|
|
useEffect(() => {
|
|
const compute = () => {
|
|
const diff = Date.now() - new Date(createdAt).getTime();
|
|
const h = Math.floor(diff / 3_600_000);
|
|
const m = Math.floor((diff % 3_600_000) / 60_000);
|
|
setUptime(h > 0 ? `${h}h ${m}m` : `${m}m`);
|
|
};
|
|
compute();
|
|
const id = setInterval(compute, 30_000);
|
|
return () => clearInterval(id);
|
|
}, [createdAt]);
|
|
|
|
const stats: Array<{ value: number; label: string; color: string; bg: string }> = [
|
|
{
|
|
value: zones.merge,
|
|
label: "merge-ready",
|
|
color: "var(--color-status-ready)",
|
|
bg: "color-mix(in srgb, var(--color-status-ready) 10%, transparent)",
|
|
},
|
|
{
|
|
value: zones.respond,
|
|
label: "responding",
|
|
color: "var(--color-status-error)",
|
|
bg: "color-mix(in srgb, var(--color-status-error) 10%, transparent)",
|
|
},
|
|
{
|
|
value: zones.review,
|
|
label: "review",
|
|
color: "var(--color-accent-orange)",
|
|
bg: "color-mix(in srgb, var(--color-accent-orange) 10%, transparent)",
|
|
},
|
|
{
|
|
value: zones.working,
|
|
label: "working",
|
|
color: "var(--color-accent-blue)",
|
|
bg: "color-mix(in srgb, var(--color-accent-blue) 10%, transparent)",
|
|
},
|
|
{
|
|
value: zones.pending,
|
|
label: "pending",
|
|
color: "var(--color-status-attention)",
|
|
bg: "color-mix(in srgb, var(--color-status-attention) 10%, transparent)",
|
|
},
|
|
{
|
|
value: zones.done,
|
|
label: "done",
|
|
color: "var(--color-text-tertiary)",
|
|
bg: "color-mix(in srgb, var(--color-text-tertiary) 14%, transparent)",
|
|
},
|
|
].filter((s) => s.value > 0);
|
|
|
|
const total =
|
|
zones.merge + zones.respond + zones.review + zones.working + zones.pending + zones.done;
|
|
|
|
return (
|
|
<div className="mx-auto max-w-[1180px] px-5 pt-5 lg:px-8">
|
|
<SessionTopStrip
|
|
headline={headline}
|
|
crumbId={headline}
|
|
activityLabel={activityLabel}
|
|
activityColor={activityColor}
|
|
branch={branch}
|
|
pr={pr}
|
|
isOrchestrator
|
|
crumbHref={crumbHref}
|
|
crumbLabel={crumbLabel}
|
|
rightSlot={
|
|
<div className="flex flex-wrap items-center gap-3 lg:justify-end">
|
|
<div className="flex items-baseline gap-1.5 mr-2">
|
|
<span className="text-[22px] font-bold leading-none tabular-nums text-[var(--color-text-primary)]">
|
|
{total}
|
|
</span>
|
|
<span className="text-[11px] text-[var(--color-text-tertiary)]">agents</span>
|
|
</div>
|
|
|
|
<div className="h-5 w-px bg-[var(--color-border-subtle)] mr-1" />
|
|
|
|
{/* Per-zone pills */}
|
|
{stats.length > 0 ? (
|
|
stats.map((s) => (
|
|
<div
|
|
key={s.label}
|
|
className="flex items-center gap-1.5 px-2.5 py-1"
|
|
style={{ background: s.bg }}
|
|
>
|
|
<span
|
|
className="text-[15px] font-bold leading-none tabular-nums"
|
|
style={{ color: s.color }}
|
|
>
|
|
{s.value}
|
|
</span>
|
|
<span
|
|
className="text-[10px] font-medium"
|
|
style={{ color: s.color, opacity: 0.8 }}
|
|
>
|
|
{s.label}
|
|
</span>
|
|
</div>
|
|
))
|
|
) : (
|
|
<span className="text-[12px] text-[var(--color-text-tertiary)]">
|
|
no active agents
|
|
</span>
|
|
)}
|
|
|
|
{uptime && (
|
|
<span className="ml-auto font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
|
|
up {uptime}
|
|
</span>
|
|
)}
|
|
</div>
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Main component ────────────────────────────────────────────────────
|
|
|
|
export function SessionDetail({
|
|
session,
|
|
isOrchestrator = false,
|
|
orchestratorZones,
|
|
projectOrchestratorId = null,
|
|
projects = [],
|
|
sidebarSessions = [],
|
|
sidebarLoading = false,
|
|
sidebarError = false,
|
|
onRetrySidebar,
|
|
}: SessionDetailProps) {
|
|
const searchParams = useSearchParams();
|
|
const isMobile = useMediaQuery(MOBILE_BREAKPOINT);
|
|
const startFullscreen = searchParams.get("fullscreen") === "true";
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
const [showTerminal, setShowTerminal] = useState(false);
|
|
const pr = isOrchestrator ? null : session.pr;
|
|
const terminalEnded = isDashboardRuntimeEnded(session);
|
|
const isRestorable =
|
|
!isOrchestrator &&
|
|
isDashboardSessionRestorable(session) &&
|
|
!NON_RESTORABLE_STATUSES.has(session.status);
|
|
const activity = (session.activity && activityMeta[session.activity]) ?? {
|
|
label: getActivitySignalLabel(session),
|
|
color: "var(--color-text-muted)",
|
|
};
|
|
const headline = getSessionTitle(session);
|
|
|
|
const accentColor = "var(--color-accent)";
|
|
const terminalVariant = isOrchestrator ? "orchestrator" : "agent";
|
|
|
|
const terminalHeight = isOrchestrator
|
|
? "clamp(400px, 52vh, 620px)"
|
|
: "clamp(380px, 48vh, 560px)";
|
|
const isOpenCodeSession = session.metadata["agent"] === "opencode";
|
|
const opencodeSessionId =
|
|
typeof session.metadata["opencodeSessionId"] === "string" &&
|
|
session.metadata["opencodeSessionId"].length > 0
|
|
? session.metadata["opencodeSessionId"]
|
|
: undefined;
|
|
const reloadCommand = opencodeSessionId
|
|
? `/exit\nopencode --session ${opencodeSessionId}\n`
|
|
: undefined;
|
|
const dashboardHref = session.projectId ? `/?project=${encodeURIComponent(session.projectId)}` : "/";
|
|
const prsHref = session.projectId ? `/prs?project=${encodeURIComponent(session.projectId)}` : "/prs";
|
|
const crumbHref = dashboardHref;
|
|
const crumbLabel = "Dashboard";
|
|
|
|
const handleKill = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`/api/sessions/${encodeURIComponent(session.id)}/kill`, { method: "POST" });
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
window.location.reload();
|
|
} catch (err) {
|
|
console.error("Failed to kill session:", err);
|
|
}
|
|
}, [session.id]);
|
|
|
|
const handleRestore = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`/api/sessions/${encodeURIComponent(session.id)}/restore`, { method: "POST" });
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
window.location.reload();
|
|
} catch (err) {
|
|
console.error("Failed to restore session:", err);
|
|
}
|
|
}, [session.id]);
|
|
const headerProjectLabel =
|
|
projects.find((project) => project.id === session.projectId)?.name ?? session.projectId;
|
|
const showHeaderProjectLabel =
|
|
headerProjectLabel.trim().toLowerCase() !== "agent orchestrator";
|
|
const orchestratorHref = useMemo(() => {
|
|
if (isOrchestrator) return `/sessions/${encodeURIComponent(session.id)}`;
|
|
if (!projectOrchestratorId) return null;
|
|
return `/sessions/${encodeURIComponent(projectOrchestratorId)}`;
|
|
}, [isOrchestrator, projectOrchestratorId, session.id]);
|
|
|
|
useEffect(() => {
|
|
const frame = window.requestAnimationFrame(() => setShowTerminal(true));
|
|
return () => {
|
|
window.cancelAnimationFrame(frame);
|
|
setShowTerminal(false);
|
|
};
|
|
}, [session.id]);
|
|
|
|
if (!isMobile) {
|
|
return (
|
|
<div className="dashboard-app-shell">
|
|
<header className="dashboard-app-header">
|
|
{projects.length > 0 ? (
|
|
<button
|
|
type="button"
|
|
className="dashboard-app-sidebar-toggle"
|
|
onClick={() => setSidebarCollapsed((current) => !current)}
|
|
aria-label="Toggle sidebar"
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.75"
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
<path d="M9 3v18" />
|
|
</svg>
|
|
</button>
|
|
) : null}
|
|
<div className="dashboard-app-header__brand">
|
|
<span>Agent Orchestrator</span>
|
|
</div>
|
|
{showHeaderProjectLabel ? (
|
|
<>
|
|
<span className="dashboard-app-header__sep" aria-hidden="true" />
|
|
<span className="dashboard-app-header__project">{headerProjectLabel}</span>
|
|
</>
|
|
) : null}
|
|
<div className="dashboard-app-header__spacer" />
|
|
<div className="dashboard-app-header__actions">
|
|
{!isOrchestrator && orchestratorHref ? (
|
|
<a
|
|
href={orchestratorHref}
|
|
className="dashboard-app-btn dashboard-app-btn--amber"
|
|
aria-label="Orchestrator"
|
|
>
|
|
<svg
|
|
width="12"
|
|
height="12"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.6"
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="12" cy="5" r="2" fill="currentColor" stroke="none" />
|
|
<path d="M12 7v4M12 11H6M12 11h6M6 11v3M12 11v3M18 11v3" />
|
|
<circle cx="6" cy="17" r="2" />
|
|
<circle cx="12" cy="17" r="2" />
|
|
<circle cx="18" cy="17" r="2" />
|
|
</svg>
|
|
Orchestrator
|
|
</a>
|
|
) : null}
|
|
</div>
|
|
</header>
|
|
|
|
<div
|
|
className={`dashboard-shell dashboard-shell--desktop${sidebarCollapsed ? " dashboard-shell--sidebar-collapsed" : ""}`}
|
|
>
|
|
{projects.length > 0 ? (
|
|
<ProjectSidebar
|
|
projects={projects}
|
|
sessions={sidebarSessions}
|
|
loading={sidebarLoading}
|
|
error={sidebarError}
|
|
onRetry={onRetrySidebar}
|
|
activeProjectId={session.projectId}
|
|
activeSessionId={session.id}
|
|
collapsed={sidebarCollapsed}
|
|
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
|
|
/>
|
|
) : null}
|
|
|
|
<div className="dashboard-main dashboard-main--desktop">
|
|
<main className="session-detail-page min-h-0 flex-1 overflow-y-auto bg-[var(--color-bg-base)]">
|
|
<div className="session-detail-layout">
|
|
<main className="min-w-0">
|
|
{(!isOrchestrator || (isOrchestrator && orchestratorZones)) && (
|
|
<SessionTopStrip
|
|
headline={headline}
|
|
crumbId={session.id}
|
|
activityLabel={activity.label}
|
|
activityColor={activity.color}
|
|
branch={isOrchestrator ? null : session.branch}
|
|
pr={pr}
|
|
isOrchestrator={isOrchestrator}
|
|
crumbHref={crumbHref}
|
|
crumbLabel={crumbLabel}
|
|
onKill={isOrchestrator || terminalEnded ? undefined : handleKill}
|
|
onRestore={isOrchestrator || !isRestorable ? undefined : handleRestore}
|
|
/>
|
|
)}
|
|
|
|
{!isOrchestrator && pr ? (
|
|
<section id="session-pr-section" className="session-detail-pr-section">
|
|
<SessionDetailPRCard
|
|
pr={pr}
|
|
sessionId={session.id}
|
|
metadata={session.metadata}
|
|
lifecyclePrReason={session.lifecycle?.prReason}
|
|
/>
|
|
</section>
|
|
) : null}
|
|
|
|
{!isOrchestrator ? <SessionTruthPanel session={session} /> : null}
|
|
{!isOrchestrator ? (
|
|
<SessionReportAuditPanel
|
|
sessionId={session.id}
|
|
entries={session.agentReportAudit ?? []}
|
|
/>
|
|
) : null}
|
|
|
|
<section className="session-detail-terminal-wrap">
|
|
<div id="session-terminal-section" aria-hidden="true" />
|
|
<div className="session-detail-section-label">
|
|
<div
|
|
className="session-detail-section-label__bar"
|
|
style={{ background: isOrchestrator ? accentColor : activity.color }}
|
|
/>
|
|
<span className="session-detail-section-label__text">
|
|
Live Terminal
|
|
</span>
|
|
</div>
|
|
{!showTerminal ? (
|
|
<div className="session-detail-terminal-placeholder" style={{ height: terminalHeight }} />
|
|
) : terminalEnded ? (
|
|
<div className="terminal-exited-placeholder" style={{ height: terminalHeight }}>
|
|
<span className="terminal-exited-placeholder__text">
|
|
Terminal session has ended
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<DirectTerminal
|
|
sessionId={session.id}
|
|
startFullscreen={startFullscreen}
|
|
variant={terminalVariant}
|
|
appearance="dark"
|
|
height={terminalHeight}
|
|
isOpenCodeSession={isOpenCodeSession}
|
|
reloadCommand={isOpenCodeSession ? reloadCommand : undefined}
|
|
/>
|
|
)}
|
|
</section>
|
|
</main>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const statusPillBg = activity.color === "var(--color-status-working)"
|
|
? "color-mix(in srgb, var(--color-status-working) 12%, transparent)"
|
|
: activity.color === "var(--color-status-attention)"
|
|
? "color-mix(in srgb, var(--color-status-attention) 12%, transparent)"
|
|
: activity.color === "var(--color-status-error)"
|
|
? "color-mix(in srgb, var(--color-status-error) 12%, transparent)"
|
|
: "color-mix(in srgb, var(--color-accent) 12%, transparent)";
|
|
|
|
return (
|
|
<div className="session-detail--terminal-first">
|
|
{/* Floating header */}
|
|
<div className="session-detail__floating-header">
|
|
<a href={crumbHref} className="session-detail__back" aria-label="Back to dashboard">
|
|
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path d="M19 12H5M12 19l-7-7 7-7" />
|
|
</svg>
|
|
</a>
|
|
<span className="session-detail__status-dot" style={{ background: activity.color }} />
|
|
<span className="session-detail__session-id">{session.id}</span>
|
|
<span
|
|
className="session-detail__status-pill"
|
|
style={{ background: statusPillBg, color: activity.color }}
|
|
>
|
|
{activity.label.toLowerCase()}
|
|
</span>
|
|
<span className="session-detail__time">
|
|
{formatTimeCompact(session.lastActivityAt)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Terminal fills the viewport */}
|
|
<div className={`session-detail__terminal-full${pr ? " session-detail__terminal-full--with-sheet" : ""}`}>
|
|
{!showTerminal ? (
|
|
<div className="session-detail-terminal-placeholder" style={{ height: "100%" }} />
|
|
) : terminalEnded ? (
|
|
<div className="terminal-exited-placeholder" style={{ height: "100%" }}>
|
|
<span className="terminal-exited-placeholder__text">
|
|
Terminal session has ended
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<DirectTerminal
|
|
sessionId={session.id}
|
|
startFullscreen={startFullscreen}
|
|
variant={terminalVariant}
|
|
appearance="dark"
|
|
height="100%"
|
|
chromeless
|
|
isOpenCodeSession={isOpenCodeSession}
|
|
reloadCommand={isOpenCodeSession ? reloadCommand : undefined}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bottom sheet with PR info */}
|
|
{pr ? (
|
|
<div className="session-detail__bottom-sheet">
|
|
<div className="session-detail__sheet-handle" />
|
|
<div className="session-detail__sheet-row">
|
|
<a
|
|
href={pr.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="session-detail__sheet-pr"
|
|
>
|
|
PR #{pr.number}
|
|
</a>
|
|
<span className="session-detail__sheet-item">
|
|
<span
|
|
className="session-detail__sheet-ci-dot"
|
|
style={{ background: getCiDotBg(pr) }}
|
|
/>
|
|
{getCiShortLabel(pr)}
|
|
</span>
|
|
<span className="session-detail__sheet-item">
|
|
{getReviewShortLabel(pr) || "—"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<MobileBottomNav
|
|
ariaLabel="Session navigation"
|
|
activeTab={isOrchestrator ? "orchestrator" : undefined}
|
|
dashboardHref={dashboardHref}
|
|
prsHref={prsHref}
|
|
showOrchestrator={orchestratorHref !== null}
|
|
orchestratorHref={orchestratorHref}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Session detail PR card ────────────────────────────────────────────
|
|
|
|
function SessionDetailPRCard({
|
|
pr,
|
|
sessionId,
|
|
metadata,
|
|
lifecyclePrReason,
|
|
}: {
|
|
pr: DashboardPR;
|
|
sessionId: string;
|
|
metadata: Record<string, string>;
|
|
lifecyclePrReason?: string;
|
|
}) {
|
|
const [sendingComments, setSendingComments] = useState<Set<string>>(new Set());
|
|
const [sentComments, setSentComments] = useState<Set<string>>(new Set());
|
|
const [errorComments, setErrorComments] = useState<Set<string>>(new Set());
|
|
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
timersRef.current.forEach((timer) => clearTimeout(timer));
|
|
timersRef.current.clear();
|
|
};
|
|
}, []);
|
|
|
|
const handleAskAgentToFix = async (comment: { url: string; path: string; body: string }) => {
|
|
setSentComments((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(comment.url);
|
|
return next;
|
|
});
|
|
setErrorComments((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(comment.url);
|
|
return next;
|
|
});
|
|
setSendingComments((prev) => new Set(prev).add(comment.url));
|
|
|
|
await askAgentToFix(
|
|
sessionId,
|
|
comment,
|
|
() => {
|
|
setSendingComments((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(comment.url);
|
|
return next;
|
|
});
|
|
setSentComments((prev) => new Set(prev).add(comment.url));
|
|
const existing = timersRef.current.get(comment.url);
|
|
if (existing) clearTimeout(existing);
|
|
const timer = setTimeout(() => {
|
|
setSentComments((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(comment.url);
|
|
return next;
|
|
});
|
|
timersRef.current.delete(comment.url);
|
|
}, 3000);
|
|
timersRef.current.set(comment.url, timer);
|
|
},
|
|
() => {
|
|
setSendingComments((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(comment.url);
|
|
return next;
|
|
});
|
|
setErrorComments((prev) => new Set(prev).add(comment.url));
|
|
const existing = timersRef.current.get(comment.url);
|
|
if (existing) clearTimeout(existing);
|
|
const timer = setTimeout(() => {
|
|
setErrorComments((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(comment.url);
|
|
return next;
|
|
});
|
|
timersRef.current.delete(comment.url);
|
|
}, 3000);
|
|
timersRef.current.set(comment.url, timer);
|
|
},
|
|
);
|
|
};
|
|
|
|
const allGreen = isPRMergeReady(pr);
|
|
const blockerIssues = buildBlockerChips(pr, metadata, lifecyclePrReason);
|
|
const fileCount = pr.changedFiles ?? 0;
|
|
|
|
return (
|
|
<div className={cn("session-detail-pr-card", allGreen && "session-detail-pr-card--green")}>
|
|
{/* Row 1: Title + diff stats */}
|
|
<div className="session-detail-pr-card__row">
|
|
<a
|
|
href={pr.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="session-detail-pr-card__title-link"
|
|
>
|
|
PR #{pr.number}: {pr.title}
|
|
</a>
|
|
<span className="session-detail-pr-card__diff-stats">
|
|
<span className="session-detail-diff--add">+{pr.additions}</span>{" "}
|
|
<span className="session-detail-diff--del">-{pr.deletions}</span>
|
|
</span>
|
|
{fileCount > 0 && (
|
|
<span className="session-detail-pr-card__diff-label">
|
|
{fileCount} file{fileCount !== 1 ? "s" : ""}
|
|
</span>
|
|
)}
|
|
{pr.isDraft && (
|
|
<span className="session-detail-pr-card__diff-label">Draft</span>
|
|
)}
|
|
{pr.state === "merged" && (
|
|
<span className="session-detail-pr-card__diff-label">Merged</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Row 2: Blocker chips + CI chips inline */}
|
|
<div className="session-detail-pr-card__details">
|
|
{allGreen ? (
|
|
<div className="session-detail-merge-banner">
|
|
<svg
|
|
width="11"
|
|
height="11"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2.5"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path d="M20 6L9 17l-5-5" />
|
|
</svg>
|
|
Ready to merge
|
|
</div>
|
|
) : (
|
|
blockerIssues.map((issue) => (
|
|
<span
|
|
key={issue.text}
|
|
className={cn(
|
|
"session-detail-blocker-chip",
|
|
issue.variant === "fail" && "session-detail-blocker-chip--fail",
|
|
issue.variant === "warn" && "session-detail-blocker-chip--warn",
|
|
issue.variant === "muted" && "session-detail-blocker-chip--muted",
|
|
)}
|
|
>
|
|
{issue.icon} {issue.text}
|
|
{issue.notified && (
|
|
<span className="session-detail-blocker-chip__note">· notified</span>
|
|
)}
|
|
</span>
|
|
))
|
|
)}
|
|
|
|
{/* Separator between blockers and CI chips */}
|
|
{pr.ciChecks.length > 0 && (
|
|
<>
|
|
<div className="session-detail-pr-sep" />
|
|
{pr.ciChecks.map((check) => {
|
|
const chip = (
|
|
<span
|
|
className={cn(
|
|
"session-detail-ci-chip",
|
|
check.status === "passed" && "session-detail-ci-chip--pass",
|
|
check.status === "failed" && "session-detail-ci-chip--fail",
|
|
check.status === "pending" && "session-detail-ci-chip--pending",
|
|
check.status !== "passed" && check.status !== "failed" && check.status !== "pending" && "session-detail-ci-chip--queued",
|
|
)}
|
|
>
|
|
{check.status === "passed" ? "\u2713" : check.status === "failed" ? "\u2717" : check.status === "pending" ? "\u25CF" : "\u25CB"}{" "}
|
|
{check.name}
|
|
</span>
|
|
);
|
|
return check.url ? (
|
|
<a
|
|
key={check.name}
|
|
href={check.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="hover:no-underline"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{chip}
|
|
</a>
|
|
) : (
|
|
<span key={check.name}>{chip}</span>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Row 3: Collapsible unresolved comments */}
|
|
{pr.unresolvedComments.length > 0 && (
|
|
<details className="session-detail-comments-strip">
|
|
<summary>
|
|
<div className="session-detail-comments-strip__toggle">
|
|
<svg
|
|
className="session-detail-comments-strip__chevron"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2.5"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
<span className="session-detail-comments-strip__label">Unresolved Comments</span>
|
|
<span className="session-detail-comments-strip__count">{pr.unresolvedThreads}</span>
|
|
<span className="session-detail-comments-strip__hint">click to expand</span>
|
|
</div>
|
|
</summary>
|
|
<div className="session-detail-comments-strip__body">
|
|
{pr.unresolvedComments.map((c, index) => {
|
|
const { title, description } = cleanBugbotComment(c.body);
|
|
return (
|
|
<details key={c.url} className="session-detail-comment" open={index === 0}>
|
|
<summary>
|
|
<div className="session-detail-comment__row">
|
|
<svg
|
|
className="session-detail-comment__chevron"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
<span className="session-detail-comment__title">{title}</span>
|
|
<span className="session-detail-comment__author">· {c.author}</span>
|
|
<a
|
|
href={c.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="session-detail-comment__view"
|
|
>
|
|
view →
|
|
</a>
|
|
</div>
|
|
</summary>
|
|
<div className="session-detail-comment__body">
|
|
<div className="session-detail-comment__file">{c.path}</div>
|
|
<p className="session-detail-comment__text">{description}</p>
|
|
<button
|
|
onClick={() => handleAskAgentToFix(c)}
|
|
disabled={sendingComments.has(c.url)}
|
|
className={cn(
|
|
"session-detail-comment__fix-btn",
|
|
sentComments.has(c.url) && "session-detail-comment__fix-btn--sent",
|
|
errorComments.has(c.url) && "session-detail-comment__fix-btn--error",
|
|
)}
|
|
>
|
|
{sendingComments.has(c.url)
|
|
? "Sending\u2026"
|
|
: sentComments.has(c.url)
|
|
? "Sent \u2713"
|
|
: errorComments.has(c.url)
|
|
? "Failed"
|
|
: "Ask Agent to Fix"}
|
|
</button>
|
|
</div>
|
|
</details>
|
|
);
|
|
})}
|
|
</div>
|
|
</details>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Blocker chips helper (pre-merge blockers) ───────────────────────
|
|
|
|
interface BlockerChip {
|
|
icon: string;
|
|
text: string;
|
|
variant: "fail" | "warn" | "muted";
|
|
notified?: boolean;
|
|
}
|
|
|
|
function buildBlockerChips(
|
|
pr: DashboardPR,
|
|
metadata: Record<string, string>,
|
|
lifecyclePrReason?: string,
|
|
): BlockerChip[] {
|
|
const chips: BlockerChip[] = [];
|
|
|
|
const ciNotified = Boolean(metadata["lastCIFailureDispatchHash"]);
|
|
const conflictNotified = metadata["lastMergeConflictDispatched"] === "true";
|
|
const reviewNotified = Boolean(metadata["lastPendingReviewDispatchHash"]);
|
|
const lifecycleStatus = metadata["status"];
|
|
|
|
const ciIsFailing =
|
|
pr.ciStatus === CI_STATUS.FAILING ||
|
|
lifecyclePrReason === "ci_failing" ||
|
|
lifecycleStatus === "ci_failed";
|
|
const hasChangesRequested =
|
|
pr.reviewDecision === "changes_requested" ||
|
|
lifecyclePrReason === "changes_requested" ||
|
|
lifecycleStatus === "changes_requested";
|
|
const hasConflicts = pr.state !== "merged" && !pr.mergeability.noConflicts;
|
|
|
|
if (ciIsFailing) {
|
|
const failCount = pr.ciChecks.filter((c) => c.status === "failed").length;
|
|
chips.push({
|
|
icon: "\u2717",
|
|
variant: "fail",
|
|
text: failCount > 0 ? `${failCount} check${failCount !== 1 ? "s" : ""} failing` : "CI failing",
|
|
notified: ciNotified,
|
|
});
|
|
} else if (pr.ciStatus === CI_STATUS.PENDING) {
|
|
chips.push({ icon: "\u25CF", variant: "warn", text: "CI pending" });
|
|
}
|
|
|
|
if (hasChangesRequested) {
|
|
chips.push({ icon: "\u2717", variant: "fail", text: "Changes requested", notified: reviewNotified });
|
|
} else if (!pr.mergeability.approved) {
|
|
chips.push({ icon: "\u25CB", variant: "muted", text: "Awaiting reviewer" });
|
|
}
|
|
|
|
if (hasConflicts) {
|
|
chips.push({ icon: "\u2717", variant: "fail", text: "Merge conflicts", notified: conflictNotified });
|
|
}
|
|
|
|
if (pr.isDraft) {
|
|
chips.push({ icon: "\u25CB", variant: "muted", text: "Draft" });
|
|
}
|
|
|
|
return chips;
|
|
}
|