diff --git a/.changeset/refactor-session-detail.md b/.changeset/refactor-session-detail.md new file mode 100644 index 000000000..d7d839581 --- /dev/null +++ b/.changeset/refactor-session-detail.md @@ -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. diff --git a/packages/web/src/components/PRCommentThread.tsx b/packages/web/src/components/PRCommentThread.tsx new file mode 100644 index 000000000..be6dce2f1 --- /dev/null +++ b/packages/web/src/components/PRCommentThread.tsx @@ -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; + sentUrls: Set; + errorUrls: Set; + onAskAgentToFix: (comment: UnresolvedComment) => void; +} + +export function PRCommentThread({ + comments, + unresolvedThreads, + sendingUrls, + sentUrls, + errorUrls, + onAskAgentToFix, +}: PRCommentThreadProps) { + if (comments.length === 0) return null; + + return ( +
+ +
+ + + + Unresolved Comments + {unresolvedThreads} + click to expand +
+
+
+ {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 ( +
+ + + +
+
{comment.path}
+

{description}

+ +
+
+ ); + })} +
+
+ ); +} diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index dbd30d109..df4b6e292 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -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 = { - 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("") || body.includes("### "); - if (isBugbot) { - const titleMatch = body.match(/###\s+(.+?)(?:\n|$)/); - const title = titleMatch ? titleMatch[1].replace(/\*\*/g, "").trim() : "Comment"; - const descMatch = body.match( - /\s*([\s\S]*?)\s*/, - ); - 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 ( -
-
-
Terminal ended
-
- -
-

{headline}

-

- {reason}. The live terminal is gone, but the session context is still available. -

-
-
- -
-
-
What happened
-

{summary}

-
- -
-
- Session - {session.id} -
-
- Ended - {formatEndedTime(endedAt)} -
-
- Runtime - {runtimeLabel} -
-
- PR - {prLabel} -
-
- - - - {session.lifecycle?.evidence ? ( -
- Evidence - {session.lifecycle.evidence} -
- ) : null} -
-
-
- ); -} - -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) => ( - - {stat.value} - {stat.label} - - ))} - - ); -} - -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(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 (
-
- {projects.length > 0 ? ( - - ) : null} -
- Agent Orchestrator -
- {/* Desktop sep (hidden on mobile since brand is hidden) */} - {showHeaderProjectLabel && ( -
+
{projects.length > 0 ? (
) : null} {mobileSidebarOpen && ( -
setMobileSidebarOpen(false)} /> +
setMobileSidebarOpen(false)} + /> )}
- {/* Terminal — fills all remaining height */}
{!showTerminal ? (
@@ -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({ ); } - -// ── Session detail PR card ──────────────────────────────────────────── - -function SessionDetailPRCard({ - pr, - sessionId, - metadata, -}: { - pr: DashboardPR; - sessionId: string; - metadata: Record; -}) { - const [sendingComments, setSendingComments] = useState>(new Set()); - const [sentComments, setSentComments] = useState>(new Set()); - const [errorComments, setErrorComments] = useState>(new Set()); - const [branchCopied, setBranchCopied] = useState(false); - const timersRef = useRef>(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 ( -
- {/* Row 1: Title + diff stats */} -
- - PR #{pr.number}: {pr.title} - - - +{pr.additions}{" "} - -{pr.deletions} - - {fileCount > 0 && ( - - {fileCount} file{fileCount !== 1 ? "s" : ""} - - )} - {pr.isDraft && Draft} - {pr.state === "merged" && ( - Merged - )} -
- - {showConflictActions ? ( -
- - Compare with base branch - - -
- ) : null} - - {/* Row 2: Blocker chips + CI chips inline */} -
- {allGreen ? ( -
- - - - Ready to merge -
- ) : ( - blockerIssues.map((issue) => ( - - {issue.icon} {issue.text} - {issue.notified && ( - · notified - )} - - )) - )} - - {/* Separator between blockers and CI chips */} - {pr.ciChecks.length > 0 && ( - <> -
- {pr.ciChecks.map((check) => { - const chip = ( - - {check.status === "passed" - ? "\u2713" - : check.status === "failed" - ? "\u2717" - : check.status === "pending" - ? "\u25CF" - : "\u25CB"}{" "} - {check.name} - - ); - return check.url ? ( - e.stopPropagation()} - > - {chip} - - ) : ( - {chip} - ); - })} - - )} -
- - {/* Row 3: Collapsible unresolved comments */} - {pr.unresolvedComments.length > 0 && ( -
- -
- - - - Unresolved Comments - {pr.unresolvedThreads} - click to expand -
-
-
- {pr.unresolvedComments.map((c, index) => { - const { title, description } = cleanBugbotComment(c.body); - return ( -
- - - -
-
{c.path}
-

{description}

- -
-
- ); - })} -
-
- )} -
- ); -} - -// ── Blocker chips helper (pre-merge blockers) ─────────────────────── - -interface BlockerChip { - icon: string; - text: string; - variant: "fail" | "warn" | "muted"; - notified?: boolean; -} - -function buildBlockerChips(pr: DashboardPR, metadata: Record): 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; -} diff --git a/packages/web/src/components/SessionDetailHeader.tsx b/packages/web/src/components/SessionDetailHeader.tsx new file mode 100644 index 000000000..f6090efcd --- /dev/null +++ b/packages/web/src/components/SessionDetailHeader.tsx @@ -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) => ( + + {s.value} + {s.label} + + ))} + + ); +} + +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(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 ( +
+ {projects.length > 0 ? ( + + ) : null} +
+ Agent Orchestrator +
+ {showHeaderProjectLabel && ( +
+ ); +} diff --git a/packages/web/src/components/SessionDetailPRCard.tsx b/packages/web/src/components/SessionDetailPRCard.tsx index 49046d49d..4089a87a4 100644 --- a/packages/web/src/components/SessionDetailPRCard.tsx +++ b/packages/web/src/components/SessionDetailPRCard.tsx @@ -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; } -interface BlockerChip { +export interface BlockerChip { icon: string; text: string; variant: "fail" | "warn" | "muted"; notified?: boolean; } -function buildBlockerChips( +export function buildBlockerChips( pr: DashboardPR, metadata: Record, 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>(new Set()); const [sentComments, setSentComments] = useState>(new Set()); const [errorComments, setErrorComments] = useState>(new Set()); + const [branchCopied, setBranchCopied] = useState(false); const timersRef = useRef>>(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 (
@@ -189,6 +228,31 @@ export function SessionDetailPRCard({ ) : null}
+ {showConflictActions ? ( +
+ + Compare with base branch + + +
+ ) : null} +
{allGreen ? (
@@ -242,12 +306,12 @@ export function SessionDetailPRCard({ )} > {check.status === "passed" - ? "\u2713" + ? "✓" : check.status === "failed" - ? "\u2717" + ? "✗" : check.status === "pending" - ? "\u25CF" - : "\u25CB"}{" "} + ? "●" + : "○"}{" "} {check.name} ); @@ -270,80 +334,14 @@ export function SessionDetailPRCard({ ) : null}
- {pr.unresolvedComments.length > 0 ? ( -
- -
- - - - Unresolved Comments - {pr.unresolvedThreads} - click to expand -
-
-
- {pr.unresolvedComments.map((comment, index) => { - const { title, description } = cleanBugbotComment(comment.body); - return ( -
- - - -
-
{comment.path}
-

{description}

- -
-
- ); - })} -
-
- ) : null} +
); } diff --git a/packages/web/src/components/SessionEndedSummary.tsx b/packages/web/src/components/SessionEndedSummary.tsx new file mode 100644 index 000000000..4ce5cba49 --- /dev/null +++ b/packages/web/src/components/SessionEndedSummary.tsx @@ -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 ( +
+
+
Terminal ended
+
+ +
+

{headline}

+

+ {reason}. The live terminal is gone, but the session context is still available. +

+
+
+ +
+
+
What happened
+

{summary}

+
+ +
+
+ Session + {session.id} +
+
+ Ended + {formatEndedTime(endedAt)} +
+
+ Runtime + {runtimeLabel} +
+
+ PR + {prLabel} +
+
+ + + + {session.lifecycle?.evidence ? ( +
+ Evidence + {session.lifecycle.evidence} +
+ ) : null} +
+
+
+ ); +} diff --git a/packages/web/src/components/__tests__/SessionDetailPRCard.buildBlockerChips.test.ts b/packages/web/src/components/__tests__/SessionDetailPRCard.buildBlockerChips.test.ts new file mode 100644 index 000000000..0ecf9eb56 --- /dev/null +++ b/packages/web/src/components/__tests__/SessionDetailPRCard.buildBlockerChips.test.ts @@ -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(); + }); +}); diff --git a/packages/web/src/components/__tests__/session-detail-agent-actions.test.ts b/packages/web/src/components/__tests__/session-detail-agent-actions.test.ts new file mode 100644 index 000000000..be117e022 --- /dev/null +++ b/packages/web/src/components/__tests__/session-detail-agent-actions.test.ts @@ -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\nMake the empty state shorter.", +}; + +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${longDescription}`, + }); + 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${huge}`, + }); + expect(message.length).toBeLessThanOrEqual(9_500); + }); + + it("trims surrounding whitespace from the rendered fields", () => { + const message = buildAgentFixMessage({ + ...baseComment, + body: "### Spaced \n\n\n spaced description \n\n", + }); + expect(message).toContain("Comment: Spaced"); + expect(message).toContain("Description: spaced description"); + }); +});