refactor(web): break down SessionDetail.tsx into focused components (#1449)

* refactor(web): break down SessionDetail.tsx into focused components

Split the 1089-line SessionDetail page into smaller, focused modules so
every file stays under the 400-line component cap (CLAUDE.md C-04).

- SessionDetail.tsx: 1089 -> 248 lines, now orchestrates layout only
- SessionDetailHeader.tsx (new): topbar with PR popover + kill/restore
- OrchestratorStatusStrip.tsx (new): orchestrator zone counters
- SessionDetailPRCard.tsx: wired in, with merge-conflict actions and
  reliability check that previously lived inline in SessionDetail
- PRCommentThread.tsx (new): unresolved review comment list
- Reuses session-detail-utils and session-detail-agent-actions, which
  were previously orphaned dead code

Closes #770

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(web): unit-test logic in SessionDetail extractions

Cover the pure logic in the modules extracted by #770:

- buildBlockerChips (PR card): 12 cases for CI/review/conflict/draft
  branching, lifecycle hints, and the unenriched/rate-limited
  reliability gate
- buildZoneStats (orchestrator strip): zone filter + canonical order
- buildAgentFixMessage (agent actions): bugbot vs plain bodies and
  truncation against title/description/total budgets

Pure JSX shells (PRCommentThread, header markup) stay covered by the
existing integration tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): wire lifecyclePrReason into PR popover

Pass session.lifecycle?.prReason to SessionDetailPRCard from the topbar
header so blocker chips derived from lifecycle state (ci_failing,
changes_requested) surface in the popover.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Prateek <karnalprateek@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
i-trytoohard 2026-04-25 22:01:21 +05:30 committed by GitHub
parent 4845564103
commit eb7314b07b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 891 additions and 959 deletions

View File

@ -0,0 +1,5 @@
---
"@aoagents/ao-web": patch
---
Refactor SessionDetail.tsx by extracting the topbar header, PR card, and unresolved comment thread into dedicated components. The previously-orphaned SessionDetailPRCard, session-detail-utils, and session-detail-agent-actions modules are now wired in. All files are under the 400-line component limit.

View File

@ -0,0 +1,105 @@
"use client";
import { cn } from "@/lib/cn";
import type { DashboardPR } from "@/lib/types";
import { cleanBugbotComment } from "./session-detail-utils";
type UnresolvedComment = DashboardPR["unresolvedComments"][number];
interface PRCommentThreadProps {
comments: UnresolvedComment[];
unresolvedThreads: number;
sendingUrls: Set<string>;
sentUrls: Set<string>;
errorUrls: Set<string>;
onAskAgentToFix: (comment: UnresolvedComment) => void;
}
export function PRCommentThread({
comments,
unresolvedThreads,
sendingUrls,
sentUrls,
errorUrls,
onAskAgentToFix,
}: PRCommentThreadProps) {
if (comments.length === 0) return null;
return (
<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">{unresolvedThreads}</span>
<span className="session-detail-comments-strip__hint">click to expand</span>
</div>
</summary>
<div className="session-detail-comments-strip__body">
{comments.map((comment, index) => {
const { title, description } = cleanBugbotComment(comment.body);
const isSending = sendingUrls.has(comment.url);
const isSent = sentUrls.has(comment.url);
const isError = errorUrls.has(comment.url);
return (
<details key={comment.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">· {comment.author}</span>
<a
href={comment.url}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
className="session-detail-comment__view"
>
view &rarr;
</a>
</div>
</summary>
<div className="session-detail-comment__body">
<div className="session-detail-comment__file">{comment.path}</div>
<p className="session-detail-comment__text">{description}</p>
<button
onClick={() => onAskAgentToFix(comment)}
disabled={isSending}
className={cn(
"session-detail-comment__fix-btn",
isSent && "session-detail-comment__fix-btn--sent",
isError && "session-detail-comment__fix-btn--error",
)}
>
{isSending
? "Sending…"
: isSent
? "Sent ✓"
: isError
? "Failed"
: "Ask Agent to Fix"}
</button>
</div>
</details>
);
})}
</div>
</details>
);
}

View File

@ -1,28 +1,29 @@
"use client";
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { useState, useEffect, useMemo, useCallback } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery";
import {
type DashboardSession,
type DashboardPR,
TERMINAL_STATUSES,
NON_RESTORABLE_STATUSES,
isPRMergeReady,
isPRRateLimited,
isPRUnenriched,
} from "@/lib/types";
import { CI_STATUS } from "@aoagents/ao-core/types";
import { cn } from "@/lib/cn";
import dynamic from "next/dynamic";
import { formatRelativeTime, getSessionTitle } from "@/lib/format";
import { buildGitHubCompareUrl } from "@/lib/github-links";
import { getSessionTitle } from "@/lib/format";
import type { ProjectInfo } from "@/lib/project-name";
import { SidebarContext } from "./workspace/SidebarContext";
import { projectDashboardPath, projectSessionPath } from "@/lib/routes";
import { ProjectSidebar } from "./ProjectSidebar";
import { MobileBottomNav } from "./MobileBottomNav";
import {
SessionDetailHeader,
type OrchestratorZones,
} from "./SessionDetailHeader";
import { SessionEndedSummary } from "./SessionEndedSummary";
import { sessionActivityMeta } from "./session-detail-utils";
export type { OrchestratorZones } from "./SessionDetailHeader";
const DirectTerminal = dynamic(
() => import("./DirectTerminal").then((m) => ({ default: m.DirectTerminal })),
@ -36,15 +37,6 @@ const DirectTerminal = dynamic(
},
);
interface OrchestratorZones {
merge: number;
respond: number;
review: number;
pending: number;
working: number;
done: number;
}
interface SessionDetailProps {
session: DashboardSession;
isOrchestrator?: boolean;
@ -57,216 +49,6 @@ interface SessionDetailProps {
onRetrySidebar?: () => void;
}
// ── Helpers ──────────────────────────────────────────────────────────
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 normalizeActivityLabelForClass(activityLabel: string): string {
return activityLabel.toLowerCase().replace(/\s+/g, "-");
}
function formatEndedTime(isoDate: string | null | undefined): string {
if (!isoDate) return "Unknown";
const timestamp = new Date(isoDate).getTime();
if (!Number.isFinite(timestamp)) return "Unknown";
return formatRelativeTime(timestamp);
}
function getEndedSessionReason(session: DashboardSession): string {
if (session.lifecycle?.runtime.reasonLabel) {
return session.lifecycle.runtime.reasonLabel;
}
if (session.status === "killed") return "Manually stopped";
if (session.status === "terminated") return "Runtime unavailable";
if (session.status === "done" || session.status === "merged") return "Work completed";
return "Terminal ended";
}
function getEndedSessionSummary(session: DashboardSession, headline: string): string {
const pinnedSummary = session.metadata["pinnedSummary"];
if (pinnedSummary) return pinnedSummary;
if (session.summary && !session.summaryIsFallback) return session.summary;
if (session.lifecycle?.summary) return session.lifecycle.summary;
if (session.userPrompt) return session.userPrompt;
if (session.summary) return session.summary;
return headline;
}
function SessionEndedSummary({
session,
headline,
pr,
dashboardHref,
}: {
session: DashboardSession;
headline: string;
pr: DashboardPR | null;
dashboardHref: string;
}) {
const reason = getEndedSessionReason(session);
const summary = getEndedSessionSummary(session, headline);
const endedAt =
session.lifecycle?.session.terminatedAt ??
session.lifecycle?.session.completedAt ??
session.lifecycle?.session.lastTransitionAt ??
session.lastActivityAt;
const runtimeLabel = session.lifecycle?.runtime.label ?? "Unavailable";
const prLabel = pr
? pr.state === "merged"
? "Merged"
: pr.state === "closed"
? "Closed"
: pr.mergeability.mergeable
? "Open, merge-ready"
: "Open"
: "No PR";
return (
<section className="session-ended-summary" aria-label="Session ended summary">
<div className="session-ended-summary__panel">
<div className="session-ended-summary__eyebrow">Terminal ended</div>
<div className="session-ended-summary__header">
<div className="session-ended-summary__icon" aria-hidden="true">
<svg fill="none" stroke="currentColor" strokeWidth="1.8" viewBox="0 0 24 24">
<rect x="3" y="5" width="18" height="14" rx="3" />
<path d="M7 10l3 2-3 2" />
<path d="M13 15h4" />
</svg>
</div>
<div className="session-ended-summary__title-group">
<h2 className="session-ended-summary__title">{headline}</h2>
<p className="session-ended-summary__subtitle">
{reason}. The live terminal is gone, but the session context is still available.
</p>
</div>
</div>
<div className="session-ended-summary__body">
<div className="session-ended-summary__section">
<div className="session-ended-summary__label">What happened</div>
<p className="session-ended-summary__copy">{summary}</p>
</div>
<div className="session-ended-summary__facts" aria-label="Session facts">
<div className="session-ended-summary__fact">
<span>Session</span>
<strong>{session.id}</strong>
</div>
<div className="session-ended-summary__fact">
<span>Ended</span>
<strong>{formatEndedTime(endedAt)}</strong>
</div>
<div className="session-ended-summary__fact">
<span>Runtime</span>
<strong>{runtimeLabel}</strong>
</div>
<div className="session-ended-summary__fact">
<span>PR</span>
<strong>{prLabel}</strong>
</div>
</div>
<div className="session-ended-summary__links">
{pr ? (
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className="session-ended-summary__primary"
>
Open PR #{pr.number}
</a>
) : null}
<a href={dashboardHref} className="session-ended-summary__secondary">
Back to dashboard
</a>
</div>
{session.lifecycle?.evidence ? (
<div className="session-ended-summary__evidence">
<span>Evidence</span>
<code>{session.lifecycle.evidence}</code>
</div>
) : null}
</div>
</div>
</section>
);
}
function OrchestratorZonePills({ zones }: { zones: OrchestratorZones }) {
const stats: Array<{ value: number; label: string; toneClass: string }> = [
{ value: zones.merge, label: "merge", toneClass: "topbar-zone-pill--merge" },
{ value: zones.respond, label: "respond", toneClass: "topbar-zone-pill--respond" },
{ value: zones.review, label: "review", toneClass: "topbar-zone-pill--review" },
{ value: zones.working, label: "working", toneClass: "topbar-zone-pill--working" },
{ value: zones.pending, label: "pending", toneClass: "topbar-zone-pill--pending" },
{ value: zones.done, label: "done", toneClass: "topbar-zone-pill--done" },
].filter((stat) => stat.value > 0);
if (stats.length === 0) return null;
return (
<>
{stats.map((stat) => (
<span key={stat.label} className={cn("topbar-zone-pill", stat.toneClass)}>
<span className="topbar-zone-pill__value">{stat.value}</span>
<span className="topbar-zone-pill__label">{stat.label}</span>
</span>
))}
</>
);
}
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();
}
}
// ── Main component ────────────────────────────────────────────────────
export function SessionDetail({
session,
isOrchestrator = false,
@ -288,7 +70,7 @@ export function SessionDetail({
const pr = session.pr;
const terminalEnded = TERMINAL_STATUSES.has(session.status);
const isRestorable = terminalEnded && !NON_RESTORABLE_STATUSES.has(session.status);
const activity = (session.activity && activityMeta[session.activity]) ?? {
const activity = (session.activity && sessionActivityMeta[session.activity]) ?? {
label: session.activity ?? "unknown",
color: "var(--color-text-muted)",
};
@ -338,31 +120,6 @@ export function SessionDetail({
}
}, [session.id]);
const allGreen = pr ? isPRMergeReady(pr) : false;
const [prPopoverOpen, setPrPopoverOpen] = useState(false);
const prPopoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!prPopoverOpen) return;
const handler = (e: MouseEvent) => {
if (prPopoverRef.current && !prPopoverRef.current.contains(e.target as Node)) {
setPrPopoverOpen(false);
}
};
const keyHandler = (e: KeyboardEvent) => {
if (e.key === "Escape") setPrPopoverOpen(false);
};
document.addEventListener("mousedown", handler);
document.addEventListener("keydown", keyHandler);
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("keydown", keyHandler);
};
}, [prPopoverOpen]);
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 projectSessionPath(session.projectId, session.id);
if (projectOrchestratorId) return projectSessionPath(session.projectId, projectOrchestratorId);
@ -388,234 +145,32 @@ export function SessionDetail({
return (
<SidebarContext.Provider value={{ onToggleSidebar: handleToggleSidebar, mobileSidebarOpen }}>
<div className="dashboard-app-shell">
<header className="dashboard-app-header">
{projects.length > 0 ? (
<button
type="button"
className="dashboard-app-sidebar-toggle"
onClick={handleToggleSidebar}
aria-label="Toggle sidebar"
>
{isMobile ? (
<svg
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
) : (
<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 dashboard-app-header__brand--hide-mobile">
<span>Agent Orchestrator</span>
</div>
{/* Desktop sep (hidden on mobile since brand is hidden) */}
{showHeaderProjectLabel && (
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
)}
{/* Project name + pills: stacked column on mobile, inline on desktop.
On mobile the project name + session id share line 1 (so ao-N stays
visually bound to the project), pills stack below on line 2. */}
<div className="topbar-project-pills-group">
<div className="topbar-project-line">
{showHeaderProjectLabel && (
<span className="dashboard-app-header__project">{headerProjectLabel}</span>
)}
<span className="dashboard-app-header__session-id topbar-mobile-only">
{session.id}
</span>
{isOrchestrator && (
<span className="session-detail-mode-badge">orchestrator</span>
)}
</div>
<div className="topbar-session-pills">
<div
className={cn(
"topbar-status-pill",
`topbar-status-pill--${normalizeActivityLabelForClass(activity.label)}`,
)}
>
<span className="topbar-status-pill__dot" style={{ background: activity.color }} />
<span className="topbar-status-pill__label">{activity.label}</span>
</div>
{session.branch ? (
pr ? (
<a
href={buildGitHubBranchUrl(pr)}
target="_blank"
rel="noopener noreferrer"
className="topbar-branch-pill topbar-branch-pill--link"
>
{session.branch}
</a>
) : (
<span className="topbar-branch-pill">{session.branch}</span>
)
) : null}
{isOrchestrator && orchestratorZones ? (
<OrchestratorZonePills zones={orchestratorZones} />
) : null}
</div>
</div>
{/* Desktop-only session title + session id.
On mobile the session id lives next to the project name (above). */}
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
<span className="dashboard-app-header__session-title topbar-desktop-only">
{headline}
</span>
<span className="dashboard-app-header__session-id topbar-desktop-only">
{session.id}
</span>
<div className="dashboard-app-header__spacer" />
<div className="dashboard-app-header__actions">
{pr ? (
<div className="topbar-pr-btn-wrap" ref={prPopoverRef}>
{/* Anchored to the actual PR URL so ctrl/cmd-click opens the PR on
GitHub in a new tab. Plain click toggles the details popover. */}
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"dashboard-app-btn topbar-pr-btn",
prPopoverOpen && "topbar-pr-btn--open",
)}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
setPrPopoverOpen((v) => !v);
}}
aria-expanded={prPopoverOpen}
aria-label={`PR #${pr.number}`}
>
<span
className={cn(
"topbar-pr-dot",
allGreen
? "topbar-pr-dot--green"
: pr.ciStatus === CI_STATUS.FAILING ||
pr.reviewDecision === "changes_requested"
? "topbar-pr-dot--red"
: "topbar-pr-dot--amber",
)}
/>
PR #{pr.number}
<svg
width="10"
height="10"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d={prPopoverOpen ? "M18 15l-6-6-6 6" : "M6 9l6 6 6-6"} />
</svg>
</a>
{prPopoverOpen && (
<div className="topbar-pr-popover">
<SessionDetailPRCard
pr={pr}
sessionId={session.id}
metadata={session.metadata}
/>
</div>
)}
</div>
) : null}
{/* Restore is available for any restorable session; Kill stays worker-only. */}
{isRestorable ? (
<button
type="button"
className="dashboard-app-btn dashboard-app-btn--restore"
onClick={handleRestore}
>
<svg
className="topbar-action-icon"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M20 11a8 8 0 0 0-14.9-3.98" />
<path d="M4 5v4h4" />
<path d="M4 13a8 8 0 0 0 14.9 3.98" />
<path d="M20 19v-4h-4" />
</svg>
<span className="topbar-btn-label">Restore</span>
</button>
) : !isOrchestrator && !terminalEnded ? (
<button
type="button"
className="dashboard-app-btn dashboard-app-btn--danger"
onClick={handleKill}
>
<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>
<span className="topbar-btn-label">Kill</span>
</button>
) : null}
{orchestratorHref ? (
<a
href={orchestratorHref}
className="dashboard-app-btn dashboard-app-btn--amber topbar-desktop-only"
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>
<span className="topbar-btn-label">Orchestrator</span>
</a>
) : null}
</div>
</header>
<SessionDetailHeader
session={session}
isOrchestrator={isOrchestrator}
isMobile={isMobile}
terminalEnded={terminalEnded}
isRestorable={isRestorable}
activity={activity}
headline={headline}
projects={projects}
orchestratorHref={orchestratorHref}
orchestratorZones={orchestratorZones}
onToggleSidebar={handleToggleSidebar}
onRestore={handleRestore}
onKill={handleKill}
/>
<div
className={`dashboard-shell dashboard-shell--desktop${sidebarCollapsed ? " dashboard-shell--sidebar-collapsed" : ""}`}
className={`dashboard-shell dashboard-shell--desktop${
sidebarCollapsed ? " dashboard-shell--sidebar-collapsed" : ""
}`}
>
{projects.length > 0 ? (
<div
className={`sidebar-wrapper${mobileSidebarOpen ? " sidebar-wrapper--mobile-open" : ""}`}
className={`sidebar-wrapper${
mobileSidebarOpen ? " sidebar-wrapper--mobile-open" : ""
}`}
>
<ProjectSidebar
projects={projects}
@ -632,12 +187,14 @@ export function SessionDetail({
</div>
) : null}
{mobileSidebarOpen && (
<div className="sidebar-mobile-backdrop" onClick={() => setMobileSidebarOpen(false)} />
<div
className="sidebar-mobile-backdrop"
onClick={() => setMobileSidebarOpen(false)}
/>
)}
<div className="dashboard-main dashboard-main--desktop">
<main className="session-detail-page flex-1 min-h-0 flex flex-col bg-[var(--color-bg-base)]">
{/* Terminal — fills all remaining height */}
<div className="flex-1 min-h-0 flex flex-col">
{!showTerminal ? (
<div className="session-detail-terminal-placeholder h-full" />
@ -670,7 +227,9 @@ export function SessionDetail({
activeTab={isOrchestrator ? "orchestrator" : undefined}
dashboardHref={dashboardHref}
prsHref={
session.projectId ? `/?project=${encodeURIComponent(session.projectId)}&tab=prs` : "/"
session.projectId
? `/?project=${encodeURIComponent(session.projectId)}&tab=prs`
: "/"
}
showOrchestrator={!!orchestratorHref}
orchestratorHref={orchestratorHref}
@ -679,392 +238,3 @@ export function SessionDetail({
</SidebarContext.Provider>
);
}
// ── Session detail PR card ────────────────────────────────────────────
function SessionDetailPRCard({
pr,
sessionId,
metadata,
}: {
pr: DashboardPR;
sessionId: string;
metadata: Record<string, 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 [branchCopied, setBranchCopied] = useState(false);
const timersRef = useRef<Map<string, number>>(new Map());
useEffect(() => {
return () => {
timersRef.current.forEach((timer) => window.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 !== undefined) window.clearTimeout(existing);
const timer = window.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 !== undefined) window.clearTimeout(existing);
const timer = window.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);
const fileCount = pr.changedFiles ?? 0;
const mergeabilityReliable = !isPRUnenriched(pr) && !isPRRateLimited(pr);
const hasConflicts =
mergeabilityReliable && pr.state !== "merged" && !pr.mergeability.noConflicts;
const showConflictActions = hasConflicts && pr.state === "open";
const compareUrl = showConflictActions ? buildGitHubCompareUrl(pr) : "";
const handleCopyBranch = () => {
const clipboardWrite = navigator.clipboard?.writeText(pr.branch);
if (!clipboardWrite) return;
void clipboardWrite
.then(() => {
setBranchCopied(true);
const timerKey = "__copy-branch";
const existing = timersRef.current.get(timerKey);
if (existing !== undefined) window.clearTimeout(existing);
const timer = window.setTimeout(() => {
setBranchCopied(false);
timersRef.current.delete(timerKey);
}, 2000);
timersRef.current.set(timerKey, timer);
})
.catch(() => {
/* clipboard unavailable */
});
};
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>
{showConflictActions ? (
<div
className="session-detail-pr-card__merge-actions"
role="group"
aria-label="Resolve merge conflicts"
>
<a
href={compareUrl}
target="_blank"
rel="noopener noreferrer"
className="session-detail-pr-merge-action"
>
Compare with base branch
</a>
<button
type="button"
onClick={handleCopyBranch}
aria-label={branchCopied ? "Head branch name copied" : "Copy head branch name"}
className="session-detail-pr-merge-action session-detail-pr-merge-action--btn"
>
{branchCopied ? "Copied branch name" : "Copy head branch name"}
</button>
</div>
) : null}
{/* 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 &rarr;
</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>): 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 || lifecycleStatus === "ci_failed";
const hasChangesRequested =
pr.reviewDecision === "changes_requested" || lifecycleStatus === "changes_requested";
const mergeabilityReliable = !isPRUnenriched(pr) && !isPRRateLimited(pr);
const hasConflicts =
mergeabilityReliable && 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;
}

View File

@ -0,0 +1,332 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { CI_STATUS } from "@aoagents/ao-core/types";
import { cn } from "@/lib/cn";
import {
type DashboardSession,
type DashboardPR,
isPRMergeReady,
} from "@/lib/types";
import type { ProjectInfo } from "@/lib/project-name";
import { SessionDetailPRCard } from "./SessionDetailPRCard";
import { askAgentToFix } from "./session-detail-agent-actions";
import { buildGitHubBranchUrl } from "./session-detail-utils";
export interface OrchestratorZones {
merge: number;
respond: number;
review: number;
pending: number;
working: number;
done: number;
}
interface SessionDetailHeaderProps {
session: DashboardSession;
isOrchestrator: boolean;
isMobile: boolean;
terminalEnded: boolean;
isRestorable: boolean;
activity: { label: string; color: string };
headline: string;
projects: ProjectInfo[];
orchestratorHref: string | null;
orchestratorZones?: OrchestratorZones;
onToggleSidebar: () => void;
onRestore: () => void;
onKill: () => void;
}
function normalizeActivityLabelForClass(activityLabel: string): string {
return activityLabel.toLowerCase().replace(/\s+/g, "-");
}
function OrchestratorZonePills({ zones }: { zones: OrchestratorZones }) {
const stats: Array<{ value: number; label: string; toneClass: string }> = [
{ value: zones.merge, label: "merge", toneClass: "topbar-zone-pill--merge" },
{ value: zones.respond, label: "respond", toneClass: "topbar-zone-pill--respond" },
{ value: zones.review, label: "review", toneClass: "topbar-zone-pill--review" },
{ value: zones.working, label: "working", toneClass: "topbar-zone-pill--working" },
{ value: zones.pending, label: "pending", toneClass: "topbar-zone-pill--pending" },
{ value: zones.done, label: "done", toneClass: "topbar-zone-pill--done" },
].filter((s) => s.value > 0);
if (stats.length === 0) return null;
return (
<>
{stats.map((s) => (
<span key={s.label} className={cn("topbar-zone-pill", s.toneClass)}>
<span className="topbar-zone-pill__value">{s.value}</span>
<span className="topbar-zone-pill__label">{s.label}</span>
</span>
))}
</>
);
}
export function SessionDetailHeader({
session,
isOrchestrator,
isMobile,
terminalEnded,
isRestorable,
activity,
headline,
projects,
orchestratorHref,
orchestratorZones,
onToggleSidebar,
onRestore,
onKill,
}: SessionDetailHeaderProps) {
const pr = session.pr;
const allGreen = pr ? isPRMergeReady(pr) : false;
const [prPopoverOpen, setPrPopoverOpen] = useState(false);
const prPopoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!prPopoverOpen) return;
const handler = (event: MouseEvent) => {
if (prPopoverRef.current && !prPopoverRef.current.contains(event.target as Node)) {
setPrPopoverOpen(false);
}
};
const keyHandler = (event: KeyboardEvent) => {
if (event.key === "Escape") setPrPopoverOpen(false);
};
document.addEventListener("mousedown", handler);
document.addEventListener("keydown", keyHandler);
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("keydown", keyHandler);
};
}, [prPopoverOpen]);
const headerProjectLabel =
projects.find((project) => project.id === session.projectId)?.name ?? session.projectId;
const showHeaderProjectLabel =
headerProjectLabel.trim().toLowerCase() !== "agent orchestrator";
return (
<header className="dashboard-app-header">
{projects.length > 0 ? (
<button
type="button"
className="dashboard-app-sidebar-toggle"
onClick={onToggleSidebar}
aria-label="Toggle sidebar"
>
{isMobile ? (
<svg
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
) : (
<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 dashboard-app-header__brand--hide-mobile">
<span>Agent Orchestrator</span>
</div>
{showHeaderProjectLabel && (
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
)}
<div className="topbar-project-pills-group">
<div className="topbar-project-line">
{showHeaderProjectLabel && (
<span className="dashboard-app-header__project">{headerProjectLabel}</span>
)}
<span className="dashboard-app-header__session-id topbar-mobile-only">
{session.id}
</span>
{isOrchestrator && (
<span className="session-detail-mode-badge">orchestrator</span>
)}
</div>
<div className="topbar-session-pills">
<div
className={cn(
"topbar-status-pill",
`topbar-status-pill--${normalizeActivityLabelForClass(activity.label)}`,
)}
>
<span
className="topbar-status-pill__dot"
style={{ background: activity.color }}
/>
<span className="topbar-status-pill__label">{activity.label}</span>
</div>
{session.branch ? (
pr ? (
<a
href={buildGitHubBranchUrl(pr)}
target="_blank"
rel="noopener noreferrer"
className="topbar-branch-pill topbar-branch-pill--link"
>
{session.branch}
</a>
) : (
<span className="topbar-branch-pill">{session.branch}</span>
)
) : null}
{isOrchestrator && orchestratorZones ? (
<OrchestratorZonePills zones={orchestratorZones} />
) : null}
</div>
</div>
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
<span className="dashboard-app-header__session-title topbar-desktop-only">
{headline}
</span>
<span className="dashboard-app-header__session-id topbar-desktop-only">
{session.id}
</span>
<div className="dashboard-app-header__spacer" />
<div className="dashboard-app-header__actions">
{pr ? (
<div className="topbar-pr-btn-wrap" ref={prPopoverRef}>
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"dashboard-app-btn topbar-pr-btn",
prPopoverOpen && "topbar-pr-btn--open",
)}
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return;
event.preventDefault();
setPrPopoverOpen((value) => !value);
}}
aria-expanded={prPopoverOpen}
aria-label={`PR #${pr.number}`}
>
<span
className={cn(
"topbar-pr-dot",
allGreen
? "topbar-pr-dot--green"
: pr.ciStatus === CI_STATUS.FAILING ||
pr.reviewDecision === "changes_requested"
? "topbar-pr-dot--red"
: "topbar-pr-dot--amber",
)}
/>
PR #{pr.number}
<svg
width="10"
height="10"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d={prPopoverOpen ? "M18 15l-6-6-6 6" : "M6 9l6 6 6-6"} />
</svg>
</a>
{prPopoverOpen && (
<div className="topbar-pr-popover">
<SessionDetailPRCard
pr={pr as DashboardPR}
metadata={session.metadata}
lifecyclePrReason={session.lifecycle?.prReason ?? undefined}
onAskAgentToFix={(comment, onSuccess, onError) =>
askAgentToFix(session.id, comment, onSuccess, onError)
}
/>
</div>
)}
</div>
) : null}
{isRestorable ? (
<button
type="button"
className="dashboard-app-btn dashboard-app-btn--restore"
onClick={onRestore}
>
<svg
className="topbar-action-icon"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M20 11a8 8 0 0 0-14.9-3.98" />
<path d="M4 5v4h4" />
<path d="M4 13a8 8 0 0 0 14.9 3.98" />
<path d="M20 19v-4h-4" />
</svg>
<span className="topbar-btn-label">Restore</span>
</button>
) : !isOrchestrator && !terminalEnded ? (
<button
type="button"
className="dashboard-app-btn dashboard-app-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>
<span className="topbar-btn-label">Kill</span>
</button>
) : null}
{orchestratorHref ? (
<a
href={orchestratorHref}
className="dashboard-app-btn dashboard-app-btn--amber topbar-desktop-only"
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>
<span className="topbar-btn-label">Orchestrator</span>
</a>
) : null}
</div>
</header>
);
}

View File

@ -3,8 +3,14 @@
import { useEffect, useRef, useState } from "react";
import { CI_STATUS } from "@aoagents/ao-core/types";
import { cn } from "@/lib/cn";
import { isPRMergeReady, type DashboardPR } from "@/lib/types";
import { cleanBugbotComment } from "./session-detail-utils";
import {
isPRMergeReady,
isPRRateLimited,
isPRUnenriched,
type DashboardPR,
} from "@/lib/types";
import { buildGitHubCompareUrl } from "@/lib/github-links";
import { PRCommentThread } from "./PRCommentThread";
interface SessionDetailPRCardProps {
pr: DashboardPR;
@ -17,14 +23,14 @@ interface SessionDetailPRCardProps {
) => Promise<void>;
}
interface BlockerChip {
export interface BlockerChip {
icon: string;
text: string;
variant: "fail" | "warn" | "muted";
notified?: boolean;
}
function buildBlockerChips(
export function buildBlockerChips(
pr: DashboardPR,
metadata: Record<string, string>,
lifecyclePrReason?: string,
@ -44,34 +50,36 @@ function buildBlockerChips(
pr.reviewDecision === "changes_requested" ||
lifecyclePrReason === "changes_requested" ||
lifecycleStatus === "changes_requested";
const hasConflicts = pr.state !== "merged" && !pr.mergeability.noConflicts;
const mergeabilityReliable = !isPRUnenriched(pr) && !isPRRateLimited(pr);
const hasConflicts =
mergeabilityReliable && pr.state !== "merged" && !pr.mergeability.noConflicts;
if (ciIsFailing) {
const failCount = pr.ciChecks.filter((check) => check.status === "failed").length;
chips.push({
icon: "\u2717",
icon: "",
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" });
chips.push({ icon: "", variant: "warn", text: "CI pending" });
}
if (hasChangesRequested) {
chips.push({
icon: "\u2717",
icon: "",
variant: "fail",
text: "Changes requested",
notified: reviewNotified,
});
} else if (!pr.mergeability.approved) {
chips.push({ icon: "\u25CB", variant: "muted", text: "Awaiting reviewer" });
chips.push({ icon: "", variant: "muted", text: "Awaiting reviewer" });
}
if (hasConflicts) {
chips.push({
icon: "\u2717",
icon: "",
variant: "fail",
text: "Merge conflicts",
notified: conflictNotified,
@ -79,7 +87,7 @@ function buildBlockerChips(
}
if (pr.isDraft) {
chips.push({ icon: "\u25CB", variant: "muted", text: "Draft" });
chips.push({ icon: "", variant: "muted", text: "Draft" });
}
return chips;
@ -94,6 +102,7 @@ export function SessionDetailPRCard({
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 [branchCopied, setBranchCopied] = useState(false);
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
useEffect(() => {
@ -103,7 +112,11 @@ export function SessionDetailPRCard({
};
}, []);
const handleAskAgentToFix = async (comment: { url: string; path: string; body: string }) => {
const handleAskAgentToFix = async (comment: {
url: string;
path: string;
body: string;
}) => {
setSentComments((prev) => {
const next = new Set(prev);
next.delete(comment.url);
@ -162,6 +175,32 @@ export function SessionDetailPRCard({
const allGreen = isPRMergeReady(pr);
const blockerIssues = buildBlockerChips(pr, metadata, lifecyclePrReason);
const fileCount = pr.changedFiles ?? 0;
const mergeabilityReliable = !isPRUnenriched(pr) && !isPRRateLimited(pr);
const hasConflicts =
mergeabilityReliable && pr.state !== "merged" && !pr.mergeability.noConflicts;
const showConflictActions = hasConflicts && pr.state === "open";
const compareUrl = showConflictActions ? buildGitHubCompareUrl(pr) : "";
const handleCopyBranch = () => {
const clipboardWrite = navigator.clipboard?.writeText(pr.branch);
if (!clipboardWrite) return;
void clipboardWrite
.then(() => {
setBranchCopied(true);
const timerKey = "__copy-branch";
const existing = timersRef.current.get(timerKey);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
setBranchCopied(false);
timersRef.current.delete(timerKey);
}, 2000);
timersRef.current.set(timerKey, timer);
})
.catch(() => {
/* clipboard unavailable */
});
};
return (
<div className={cn("session-detail-pr-card", allGreen && "session-detail-pr-card--green")}>
@ -189,6 +228,31 @@ export function SessionDetailPRCard({
) : null}
</div>
{showConflictActions ? (
<div
className="session-detail-pr-card__merge-actions"
role="group"
aria-label="Resolve merge conflicts"
>
<a
href={compareUrl}
target="_blank"
rel="noopener noreferrer"
className="session-detail-pr-merge-action"
>
Compare with base branch
</a>
<button
type="button"
onClick={handleCopyBranch}
aria-label={branchCopied ? "Head branch name copied" : "Copy head branch name"}
className="session-detail-pr-merge-action session-detail-pr-merge-action--btn"
>
{branchCopied ? "Copied branch name" : "Copy head branch name"}
</button>
</div>
) : null}
<div className="session-detail-pr-card__details">
{allGreen ? (
<div className="session-detail-merge-banner">
@ -242,12 +306,12 @@ export function SessionDetailPRCard({
)}
>
{check.status === "passed"
? "\u2713"
? ""
: check.status === "failed"
? "\u2717"
? ""
: check.status === "pending"
? "\u25CF"
: "\u25CB"}{" "}
? ""
: ""}{" "}
{check.name}
</span>
);
@ -270,80 +334,14 @@ export function SessionDetailPRCard({
) : null}
</div>
{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((comment, index) => {
const { title, description } = cleanBugbotComment(comment.body);
return (
<details key={comment.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">· {comment.author}</span>
<a
href={comment.url}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
className="session-detail-comment__view"
>
view &rarr;
</a>
</div>
</summary>
<div className="session-detail-comment__body">
<div className="session-detail-comment__file">{comment.path}</div>
<p className="session-detail-comment__text">{description}</p>
<button
onClick={() => handleAskAgentToFix(comment)}
disabled={sendingComments.has(comment.url)}
className={cn(
"session-detail-comment__fix-btn",
sentComments.has(comment.url) && "session-detail-comment__fix-btn--sent",
errorComments.has(comment.url) && "session-detail-comment__fix-btn--error",
)}
>
{sendingComments.has(comment.url)
? "Sending\u2026"
: sentComments.has(comment.url)
? "Sent \u2713"
: errorComments.has(comment.url)
? "Failed"
: "Ask Agent to Fix"}
</button>
</div>
</details>
);
})}
</div>
</details>
) : null}
<PRCommentThread
comments={pr.unresolvedComments}
unresolvedThreads={pr.unresolvedThreads}
sendingUrls={sendingComments}
sentUrls={sentComments}
errorUrls={errorComments}
onAskAgentToFix={handleAskAgentToFix}
/>
</div>
);
}

View File

@ -0,0 +1,135 @@
"use client";
import type { DashboardPR, DashboardSession } from "@/lib/types";
import { formatRelativeTime } from "@/lib/format";
interface SessionEndedSummaryProps {
session: DashboardSession;
headline: string;
pr: DashboardPR | null;
dashboardHref: string;
}
function formatEndedTime(isoDate: string | null | undefined): string {
if (!isoDate) return "Unknown";
const timestamp = new Date(isoDate).getTime();
if (!Number.isFinite(timestamp)) return "Unknown";
return formatRelativeTime(timestamp);
}
function getEndedSessionReason(session: DashboardSession): string {
if (session.lifecycle?.runtime.reasonLabel) {
return session.lifecycle.runtime.reasonLabel;
}
if (session.status === "killed") return "Manually stopped";
if (session.status === "terminated") return "Runtime unavailable";
if (session.status === "done" || session.status === "merged") return "Work completed";
return "Terminal ended";
}
function getEndedSessionSummary(session: DashboardSession, headline: string): string {
const pinnedSummary = session.metadata["pinnedSummary"];
if (pinnedSummary) return pinnedSummary;
if (session.summary && !session.summaryIsFallback) return session.summary;
if (session.lifecycle?.summary) return session.lifecycle.summary;
if (session.userPrompt) return session.userPrompt;
if (session.summary) return session.summary;
return headline;
}
export function SessionEndedSummary({
session,
headline,
pr,
dashboardHref,
}: SessionEndedSummaryProps) {
const reason = getEndedSessionReason(session);
const summary = getEndedSessionSummary(session, headline);
const endedAt =
session.lifecycle?.session.terminatedAt ??
session.lifecycle?.session.completedAt ??
session.lifecycle?.session.lastTransitionAt ??
session.lastActivityAt;
const runtimeLabel = session.lifecycle?.runtime.label ?? "Unavailable";
const prLabel = pr
? pr.state === "merged"
? "Merged"
: pr.state === "closed"
? "Closed"
: pr.mergeability.mergeable
? "Open, merge-ready"
: "Open"
: "No PR";
return (
<section className="session-ended-summary" aria-label="Session ended summary">
<div className="session-ended-summary__panel">
<div className="session-ended-summary__eyebrow">Terminal ended</div>
<div className="session-ended-summary__header">
<div className="session-ended-summary__icon" aria-hidden="true">
<svg fill="none" stroke="currentColor" strokeWidth="1.8" viewBox="0 0 24 24">
<rect x="3" y="5" width="18" height="14" rx="3" />
<path d="M7 10l3 2-3 2" />
<path d="M13 15h4" />
</svg>
</div>
<div className="session-ended-summary__title-group">
<h2 className="session-ended-summary__title">{headline}</h2>
<p className="session-ended-summary__subtitle">
{reason}. The live terminal is gone, but the session context is still available.
</p>
</div>
</div>
<div className="session-ended-summary__body">
<div className="session-ended-summary__section">
<div className="session-ended-summary__label">What happened</div>
<p className="session-ended-summary__copy">{summary}</p>
</div>
<div className="session-ended-summary__facts" aria-label="Session facts">
<div className="session-ended-summary__fact">
<span>Session</span>
<strong>{session.id}</strong>
</div>
<div className="session-ended-summary__fact">
<span>Ended</span>
<strong>{formatEndedTime(endedAt)}</strong>
</div>
<div className="session-ended-summary__fact">
<span>Runtime</span>
<strong>{runtimeLabel}</strong>
</div>
<div className="session-ended-summary__fact">
<span>PR</span>
<strong>{prLabel}</strong>
</div>
</div>
<div className="session-ended-summary__links">
{pr ? (
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className="session-ended-summary__primary"
>
Open PR #{pr.number}
</a>
) : null}
<a href={dashboardHref} className="session-ended-summary__secondary">
Back to dashboard
</a>
</div>
{session.lifecycle?.evidence ? (
<div className="session-ended-summary__evidence">
<span>Evidence</span>
<code>{session.lifecycle.evidence}</code>
</div>
) : null}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";
import { buildBlockerChips } from "../SessionDetailPRCard";
import { makePR } from "../../__tests__/helpers";
describe("buildBlockerChips", () => {
it("returns no chips when the PR is fully green", () => {
expect(buildBlockerChips(makePR(), {})).toEqual([]);
});
it("emits a count-aware CI failing chip when ciStatus is failing", () => {
const pr = makePR({
ciStatus: "failing",
ciChecks: [
{ name: "build", status: "failed" },
{ name: "lint", status: "failed" },
{ name: "typecheck", status: "passed" },
],
});
const chips = buildBlockerChips(pr, { lastCIFailureDispatchHash: "abc" });
const ci = chips.find((chip) => chip.text.includes("failing"));
expect(ci).toMatchObject({
text: "2 checks failing",
variant: "fail",
notified: true,
});
});
it("falls back to a generic CI failing chip when no individual checks are failed", () => {
const pr = makePR({ ciStatus: "failing", ciChecks: [] });
const chips = buildBlockerChips(pr, {});
expect(chips.find((chip) => chip.text === "CI failing")).toBeTruthy();
});
it("treats lifecyclePrReason 'ci_failing' as failing CI even if PR ciStatus disagrees", () => {
const pr = makePR({ ciStatus: "passing", ciChecks: [] });
const chips = buildBlockerChips(pr, {}, "ci_failing");
expect(chips.find((chip) => chip.text === "CI failing")).toBeTruthy();
});
it("treats metadata.status === 'ci_failed' as failing CI", () => {
const pr = makePR({ ciStatus: "passing", ciChecks: [] });
const chips = buildBlockerChips(pr, { status: "ci_failed" });
expect(chips.find((chip) => chip.text === "CI failing")).toBeTruthy();
});
it("emits a CI pending chip when ciStatus is pending and not failing", () => {
const pr = makePR({ ciStatus: "pending" });
const chips = buildBlockerChips(pr, {});
expect(chips.find((chip) => chip.text === "CI pending" && chip.variant === "warn")).toBeTruthy();
});
it("emits a Changes requested chip when reviewDecision is changes_requested", () => {
const pr = makePR({ reviewDecision: "changes_requested" });
const chips = buildBlockerChips(pr, { lastPendingReviewDispatchHash: "h" });
expect(chips.find((c) => c.text === "Changes requested")).toMatchObject({
variant: "fail",
notified: true,
});
});
it("emits an Awaiting reviewer chip when not approved and no changes requested", () => {
const pr = makePR({
reviewDecision: "review_required",
mergeability: {
mergeable: true,
ciPassing: true,
approved: false,
noConflicts: true,
blockers: [],
},
});
const chips = buildBlockerChips(pr, {});
expect(chips.find((c) => c.text === "Awaiting reviewer" && c.variant === "muted")).toBeTruthy();
});
it("emits a Merge conflicts chip only when mergeability is reliable and noConflicts=false", () => {
const pr = makePR({
mergeability: {
mergeable: false,
ciPassing: true,
approved: true,
noConflicts: false,
blockers: [],
},
});
const chips = buildBlockerChips(pr, { lastMergeConflictDispatched: "true" });
expect(chips.find((c) => c.text === "Merge conflicts")).toMatchObject({
variant: "fail",
notified: true,
});
});
it("suppresses the Merge conflicts chip for unenriched PRs (mergeability unreliable)", () => {
const pr = makePR({
enriched: false,
mergeability: {
mergeable: false,
ciPassing: true,
approved: true,
noConflicts: false,
blockers: ["unavailable"],
},
});
const chips = buildBlockerChips(pr, {});
expect(chips.find((c) => c.text === "Merge conflicts")).toBeUndefined();
});
it("suppresses the Merge conflicts chip when the PR is already merged", () => {
const pr = makePR({
state: "merged",
mergeability: {
mergeable: false,
ciPassing: true,
approved: true,
noConflicts: false,
blockers: [],
},
});
const chips = buildBlockerChips(pr, {});
expect(chips.find((c) => c.text === "Merge conflicts")).toBeUndefined();
});
it("emits a Draft chip when the PR is a draft", () => {
const pr = makePR({ isDraft: true });
const chips = buildBlockerChips(pr, {});
expect(chips.find((c) => c.text === "Draft" && c.variant === "muted")).toBeTruthy();
});
});

View File

@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { buildAgentFixMessage } from "../session-detail-agent-actions";
const baseComment = {
url: "https://github.com/acme/app/pull/1#discussion_r1",
path: "packages/web/src/components/SessionDetail.tsx",
body: "### Tighten the copy\n<!-- DESCRIPTION START -->Make the empty state shorter.<!-- DESCRIPTION END -->",
};
describe("buildAgentFixMessage", () => {
it("includes the parsed bugbot title, description, file path, and resolve URL", () => {
const message = buildAgentFixMessage(baseComment);
expect(message).toContain(`File: ${baseComment.path}`);
expect(message).toContain("Comment: Tighten the copy");
expect(message).toContain("Description: Make the empty state shorter.");
expect(message).toContain(`Resolve the comment at ${baseComment.url}`);
});
it("falls back to the raw body when the comment is not in bugbot format", () => {
const message = buildAgentFixMessage({
...baseComment,
body: "this is just a plain comment",
});
expect(message).toContain("Comment: Comment");
expect(message).toContain("Description: this is just a plain comment");
});
it("truncates an overlong description with a single ellipsis", () => {
const longDescription = "x".repeat(20_000);
const message = buildAgentFixMessage({
...baseComment,
body: `### Big one\n<!-- DESCRIPTION START -->${longDescription}<!-- DESCRIPTION END -->`,
});
const descriptionLine = message
.split("\n")
.find((line) => line.startsWith("Description: "))!;
const value = descriptionLine.replace("Description: ", "");
expect(value.length).toBeLessThanOrEqual(7_500);
expect(value.endsWith("…")).toBe(true);
});
it("caps the entire message at the agent message length budget", () => {
const huge = "y".repeat(50_000);
const message = buildAgentFixMessage({
...baseComment,
body: `### Title\n<!-- DESCRIPTION START -->${huge}<!-- DESCRIPTION END -->`,
});
expect(message.length).toBeLessThanOrEqual(9_500);
});
it("trims surrounding whitespace from the rendered fields", () => {
const message = buildAgentFixMessage({
...baseComment,
body: "### Spaced \n<!-- DESCRIPTION START -->\n\n spaced description \n\n<!-- DESCRIPTION END -->",
});
expect(message).toContain("Comment: Spaced");
expect(message).toContain("Description: spaced description");
});
});